//// //// //// //// //// //// # Chilp //// // --black: #2a2020; // --hard-black: #000; // --pink: #ffaff3; // --hot-pink: #d900b8; // --white: #fff; // --pink-white: #fff8fe; // --mid-grey: #dfe2e5; // --light-grey: #f5f5f5; // --boi-blue: #a6f0fc; // --text: var(--black); // --background: var(--white); // --accented-background: var(--pink-white); // --code-border: var(--pink); // --code-background: var(--light-grey); // --table-border: var(--mid-grey); // --table-background: var(--pink-white); // --links: var(--hot-pink); // --accent: var(--pink); // --content-width: 772px; // --header-height: 60px; // --hash-offset: calc(var(--header-height) * 1.67); // --sidebar-width: 240px; // --gap: 24px; // --small-gap: calc(var(--gap) / 2); // --tiny-gap: calc(var(--small-gap) / 2); // --large-gap: calc(var(--gap) * 2); // --sidebar-toggle-size: 33px; // --search-height: 4rem; // --shadow: 0 0 0 1px rgba(50, 50, 93, 0.075), 0 0 1px #e9ecef, // 0 2px 4px -2px rgba(138, 141, 151, 0.6); // --nav-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.1); // IMPORTS --------------------------------------------------------------------- import gleam/bool import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/order import gleam/pair import gleam/result import gleam/string import gleam/time/calendar import gleam/time/duration import gleam/time/timestamp import gleam/uri import html_parser import lustre import lustre/attribute.{attribute} import lustre/component import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import lustre/element/svg import lustre/event import rsvp // PUBLIC APIS ---------------------------------------------------------------------------------------------------------- /// # Anchor to Bluesky /// /// An anchor to the Bluesky post you want to /// fetch comments from. /// | Parameter | Description | /// | --------- | ----------- | /// | `did` | Your DID, for `@strawmelonjuice.com`, this looks like `"did:plc:jgtfsmv25thfs4zmydtbccnn"`.

Not sure how to find your DID? is one of the many places where you can easily find it. | /// | `post_id` | A post id to bind to, you'll find this in a post url `https://bsky.app/profile//post/[postid]` | /// pub fn bluesky(did did: String, post_id postid: String) { Bluesky(did:, postid:) } /// # Anchor to Fediverse /// /// An anchor to the Fediverse post you want to /// fetch comments from. /// | Parameter | Description | /// | ---------- | ----------- | /// | `instance` | The instance name, e.g. `"mastodon.social"`, this is where your post is stored, and where chilp will attempt to fetch it from. | /// | `post_id` | A post id to bind to, you'll find this in a post url `https://instance.social/@/[postid]`. | /// | | On Sharkey or Misskey this is a note id, found `https://instance.social/notes/[note-id]` | /// pub fn mastodon(instance instance: String, post_id postid: String) { Mastodon(instance:, postid:) } /// # The widget /// /// Before adding this component make sure to call [`chilp.register()`](#register) to register it! /// /// After that, use it like this: /// ```gleam /// chilp.widget( /// // We connect to Bluesky... /// bluesky: option.Some(chilp.bluesky("did:plc:my-did","myPostId")), /// // ...But not to Mastodon? Sure! Make it a None! /// mastodon: option.None /// ) /// ``` /// /// ## An example with real values: /// ```gleam /// html.text("I use a cool list of devices!") /// chilp.widget( /// bluesky: option.Some(chilp.bluesky(did: "did:plc:jgtfsmv25thfs4zmydtbccnn", post_id: "3mk5u4xwqus2i")), /// mastodon: option.Some(chilp.mastodon(instance: "pony.social", post_id: "116453666510859359")) /// ) /// ``` /// Yields you: /// /// > I use a cool list of devices! /// > /// pub fn widget( mastodon mastodon_anchor: Option(Mastodon), bluesky bsky_anchor: Option(Bluesky), ) -> Element(msg) { element.element( "comment-widget", [ attribute.attribute("mastodon-anchor", case mastodon_anchor { Some(anchor) -> anchor.instance <> "\\" <> anchor.postid None -> "" }), attribute.attribute("bluesky-anchor", case bsky_anchor { Some(anchor) -> anchor.did <> "\\" <> anchor.postid None -> "" }), ], [], ) } /// # Widget activation /// /// You should run this register function before using the widget component, so that Chilp can /// listen for it and load the component contents. /// pub fn register() -> Result(Nil, lustre.Error) { let component = lustre.component(init, update, view, [ component.on_attribute_change("mastodon-anchor", fn(value) { use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) value |> string.split_once("\\") |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) }), component.on_attribute_change("bluesky-anchor", fn(value) { use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) value |> string.split_once("\\") |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) }), ]) lustre.register(component, "comment-widget") } // MODEL ----------------------------------------------------------------------- @internal pub type Model { Model( mastodon_anchor: Option(Mastodon), cached_mastodon_descendants: List(MastodonDescendant), mastodon_op_username_and_posturl: #(String, String), bluesky_anchor: Option(Bluesky), cached_bluesky_replies: List(BskyThreadReply), bsky_op_handle: String, all_stopping_error: Option(String), cached_coalesced_view: List(CoalescedView), /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. /// So we do this the old school way. Tabs in DOM. /// This value will be ignored if the model only has one anchor. open_tab: Int, ) } @internal pub opaque type Mastodon { Mastodon(instance: String, postid: String) } @internal pub opaque type Bluesky { Bluesky(did: String, postid: String) } fn init(_) -> #(Model, Effect(Msg)) { #( Model( mastodon_anchor: None, cached_mastodon_descendants: [], mastodon_op_username_and_posturl: #("", ""), bluesky_anchor: None, cached_bluesky_replies: [], bsky_op_handle: "", all_stopping_error: None, cached_coalesced_view: [], open_tab: [1, 2] |> list.shuffle() |> list.first() |> result.unwrap(1), ), effect.none(), ) } // UPDATE ---------------------------------------------------------------------- @internal pub type Msg { MastodonUnAnchored MastodonAnchored(instance: String, post: String) BskyUnAnchored BskyAnchored(did: String, post: String) AllStoppingError(String) BskyIncomingThreadView(BskyThreadView) IncomingCoalescedView(List(CoalescedView)) MastodonIncomingStatus(MastodonStatusContext) MastodonIncomingOpUsername(#(String, String)) SetTab(Int) MastodonAnswer(instance: String) MastodonErrorFetchingOpUsernameRetryForMisskey } @internal pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { AllStoppingError(msg) -> #( Model(..model, all_stopping_error: Some(msg)), effect.none(), ) MastodonUnAnchored -> #( Model(..model, mastodon_anchor: None), effect.none(), ) MastodonAnswer(instance:) -> { case model.mastodon_anchor { Some(..) if instance != "" -> { #( model, browse({ "https://" <> instance <> "/authorize_interaction?uri=" <> uri.percent_encode(model.mastodon_op_username_and_posturl.1) }), ) } _ -> #(model, effect.none()) } } MastodonAnchored(instance:, post:) -> { let model = Model(..model, mastodon_anchor: Some(Mastodon(instance:, postid: post))) #( model, effect.batch([get_username_mastodon(model), refresh_mastodon(model)]), ) } MastodonIncomingOpUsername(mastodon_op_username_and_posturl) -> #( Model(..model, mastodon_op_username_and_posturl:), effect.none(), ) MastodonIncomingStatus(status) -> { #( Model(..model, cached_mastodon_descendants: status.descendants), new_coalesced_view(model.cached_bluesky_replies, status.descendants), ) } BskyUnAnchored -> #(Model(..model, bluesky_anchor: None), effect.none()) BskyAnchored(did:, post:) -> { let model = Model(..model, bluesky_anchor: Some(Bluesky(did:, postid: post))) #(model, effect.batch([refresh_bsky(model)])) } BskyIncomingThreadView(threadview) -> { #( Model(..model, cached_bluesky_replies: threadview.replies), new_coalesced_view( threadview.replies, model.cached_mastodon_descendants, ), ) } SetTab(open_tab) -> #(Model(..model, open_tab:), effect.none()) // And then... everything comes together! IncomingCoalescedView(new) -> #( Model(..model, cached_coalesced_view: new), effect.none(), ) MastodonErrorFetchingOpUsernameRetryForMisskey -> #( model, get_username_miskey(model), ) } } // VIEW ------------------------------------------------------------------------ @internal pub fn view(model: Model) -> Element(Msg) { case model.all_stopping_error { None -> view_normal(model) Some(error) -> error_view(error) } } fn view_normal(model: Model) -> Element(Msg) { let about_linkie = html.div([], [ html.a( [ attribute.class("float-right link link-secondary-content/40 text-xs"), ..case bool.exclusive_or( option.is_some(model.bluesky_anchor), option.is_some(model.mastodon_anchor), ), model.open_tab == 3 { True, True -> [event.on_click(SetTab(1))] True, False -> [event.on_click(SetTab(3))] False, False -> [ event.on_click(SetTab(3)), attribute.class("absolute right-2 top-1"), ] False, True -> [ attribute.class("hidden chilp-oat-enhance-hide"), ] } ], [ html.text(case model.open_tab == 3 { False -> "About" True -> "×" }), ], ), ]) let #(linkedto, respond_here) = case model.bluesky_anchor, model.mastodon_anchor { Some(bsky), Some(masto) -> { #( [ html.text("Linked to "), case model.mastodon_op_username_and_posturl.1 |> string.contains("/notes/") { True -> html.a( [ attribute.href(model.mastodon_op_username_and_posturl.1), attribute.class("link text-[#3da0c8]"), ], [html.text("this note on Sharkey")], ) False -> html.a( [ attribute.href(model.mastodon_op_username_and_posturl.1), attribute.class("link text-[#595aff]"), ], [html.text("this post on Mastodon")], ) }, html.text(", and to "), html.a( [ attribute.href( "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, ), attribute.class("link text-[#006aff]"), ], [html.text("this post on Bluesky")], ), html.text("."), ] |> element.fragment, [ html.div( [ attribute.class("tabs tabs-box border-b-2 border-base-300 h-full"), attribute.role("tablist"), ], [ html.button( [ attribute.role("tab"), event.on_click(SetTab(1)), attribute.classes([ #("tab", True), #("tab-active outline", model.open_tab == 1), ]), ], [ html.text("Mastodon"), ], ), html.button( [ attribute.role("tab"), event.on_click(SetTab(2)), attribute.classes([ #("tab", True), #("tab-active outline", model.open_tab == 2), ]), ], [html.text("Bluesky")], ), ], ), html.br([]), case model.open_tab { 2 -> view_respond_on_bsky( "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, ) 3 -> view_about_chilp() _ -> view_mastodon_respond_form(masto) }, ] |> element.fragment(), ) } None, Some(masto) -> { #( case model.open_tab { 3 -> view_about_chilp() _ -> [ html.text("Linked to "), case model.mastodon_op_username_and_posturl.1 |> string.contains("/notes/") { True -> html.a( [ attribute.href(model.mastodon_op_username_and_posturl.1), attribute.class("link text-[#3da0c8]"), ], [html.text("this note on Sharkey")], ) False -> html.a( [ attribute.href(model.mastodon_op_username_and_posturl.1), attribute.class("link text-[#595aff]"), ], [html.text("this post on Mastodon")], ) }, html.text("."), ] |> element.fragment }, view_mastodon_respond_form(masto), ) } Some(bsky), None -> { #( case model.open_tab { 3 -> view_about_chilp() _ -> [ html.text("Linked to "), html.a( [ attribute.href( "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, ), attribute.class("link link-[#006aff]"), ], [html.text("this post on Bluesky")], ), html.text("."), ] |> element.fragment }, view_respond_on_bsky( "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, ), ) } None, None -> #( error_view("No comment backends are configured for this widget."), element.none(), ) } html.div([attribute.class("comment-widget")], [ html.div( [ attribute.class( "widget card bg-base-100 shadow-xl border border-base-200 p-10", ), ], [ html.h1([attribute.class("text-2xl font-extrabold text-base-content")], [ html.text("Comments"), ]), html.p([attribute.class("text-sm text-base-content/70")], [ about_linkie, linkedto, ]), html.div( [ attribute.class("card card-dash bg-base-200/70 border-base-300"), ], [ respond_here, ], ), html.section( [attribute.class("pt-5 space-y-10")], list.sort(model.cached_coalesced_view, sort_comments) |> list.map(view_rendered_comment( _, option.map(model.bluesky_anchor, fn(bsky) { "https://bsky.app/profile/" <> bsky.did }), option.map(model.mastodon_anchor, fn(masto) { "https://" <> masto.instance <> "/@" <> model.mastodon_op_username_and_posturl.0 }), )), ), ], ), ]) } fn view_rendered_comment( comment: CoalescedView, bsky_op_profile: Option(String), mastodon_op_profile: Option(String), ) -> Element(Msg) { let is_by_op = { case comment.source, bsky_op_profile, mastodon_op_profile { "Mastodon", _, Some(op) -> op == comment.author_profile_link "Bluesky", Some(op), _ -> // Had some mismatches here, but decided it is of no importance what hostname we use. op == comment.author_profile_link _, _, _ -> False } } let commands = list.filter_map(comment.children, fn(child) { case { case comment.source, bsky_op_profile, mastodon_op_profile { "Mastodon", _, Some(op) -> op == child.author_profile_link "Bluesky", Some(op), _ -> { op == child.author_profile_link } _, _, _ -> False } } { // Not by op False -> Error(Nil) True -> { let content = element.to_readable_string(child.content) |> string.lowercase() case string.starts_with(content, "-chilp ") { True -> Ok(content) False -> Error(Nil) } } } }) let is_hidden = list.any(commands, string.starts_with(_, "-chilp hide")) let is_silenced = list.any(commands, string.starts_with(_, "-chilp silence")) // Is the comment we're currently trying to parse a command? Even unauthorised commands will not be rendered. let is_command = { string.starts_with( element.to_readable_string(comment.content) |> string.lowercase(), "-chilp ", ) } use <- bool.guard(when: is_hidden, return: element.none()) use <- bool.guard(when: is_command, return: element.none()) html.article([attribute.class("comment mt-2")], [ html.header([attribute.class("flex")], [ html.figure( [ attribute("aria-label", comment.author_username), attribute.class("small"), attribute("data-variant", "avatar"), ], [ html.img([ attribute.src(comment.author_avatar_url), attribute.class( "avatar w-[45px] h-[45px] mask mask-squircle flex-none", ), attribute.alt("@"), ]), ], ), html.div([attribute.class("meta pl-4 max-h-[45px]")], [ html.span( [attribute.class("display-name chilp-oat-enhance-inset-1em")], [ html.text(comment.displayname), case comment.source { "Mastodon" -> html.div( [ attribute("data-variant", "secondary"), attribute.class( "badge badge-sm badge-info ms-2 bg-[#595aff] text-white", ), ], [ element.text("Fediverse"), ], ) "Bluesky" -> html.div( [ attribute("data-variant", "secondary"), attribute.class( "badge badge-sm badge-info ms-2 bg-[#006aff] text-white", ), ], [ element.text("Bluesky"), ], ) _ -> element.none() }, case is_by_op { True -> html.div( [ attribute("data-variant", "outline"), attribute.class("badge badge-sm badge-accent ms-2"), ], [ element.text("Author"), ], ) False -> element.none() }, ], ), html.p([attribute.class("text-xs")], [ html.a( [ attribute.href(comment.author_profile_link), attribute.class("link link-secondary-content link-sm"), ], [element.text("@" <> comment.author_username)], ), element.text(" • "), html.time( [ attribute( "datetime", comment.created_at |> timestamp.to_rfc3339(calendar.utc_offset), ), ], [ element.text({ let b = case timestamp.difference( comment.created_at, timestamp.system_time(), ) |> duration.approximate |> pair.map_second(fn(d) { case d { duration.Nanosecond -> "nanosecond" duration.Microsecond -> "microsecond" duration.Millisecond -> "millisecond" duration.Second -> "second" duration.Minute -> "minute" duration.Hour -> "hour" duration.Day -> "day" duration.Week -> "week" duration.Month -> "month" duration.Year -> "year" } }) { #(1, x) -> #(1, x) #(x, d) -> #(x, d <> "s") } |> pair.map_first(int.to_string) b.0 <> " " <> b.1 <> " ago." }), ], ), ]), ]), ]), html.section([attribute.class("content mt-5")], [ html.span([], [comment.content]), ]), html.footer([], [ html.div([attribute.class("my-5")], [ html.a( [ attribute.class("btn btn-sm absolute right-8"), attribute.href(comment.content_url), attribute.target("_blank"), ], [html.text("View comment on " <> comment.source)], ), ]), html.br([attribute.class("border-b-2 border-dotted")]), case comment.children, is_silenced { [], _ | _, True -> element.none() _, False -> html.section( [ attribute.class( "pl-5 border-s-4 border-default bg-neutral-secondary-soft chilp-oat-enhance-inset-2em", ), ], list.sort(comment.children, sort_comments) |> list.map(fn(child) { html.div( [attribute.class("chilp-oat-enhance-inset-quoteblock")], [ view_rendered_comment( child, bsky_op_profile, mastodon_op_profile, ), ], ) }), ) }, ]), ]) } fn view_about_chilp() -> Element(Msg) { html.div([attribute.class("my-5 px-5 pb-5 ")], [ html.p([], [ element.text("This widget is powered by Chilp! 💬 By MLC Bloeiman"), ]), html.p([], [ element.text("Want to read "), html.a( [ attribute.href("https://strawmelonjuice.com/post/chilpv2"), attribute.class("link link-primary-content"), ], [ element.text("more about Chilp"), ], ), element.text(" on "), html.img([ attribute.src("https://strawmelonjuice.com/strawmelonjuice.svg"), attribute.width(34), attribute.class("inline bg-white/65 rounded-lg"), ]), element.text(" strawmelonjuice.com?"), ]), ]) } fn view_respond_on_bsky(bskylink: String) -> Element(Msg) { let icon_link = fn(label: String, new_base: String, color_class: String) { html.a( [ attribute.href(string.replace(bskylink, "bsky.app", new_base)), attribute.target("_blank"), ], [ html.button( [ attribute.class( "btn btn-circle btn-sm btn-ghost tooltip " <> color_class, ), attribute.attribute("data-tip", label), ], [ element.text(string.slice(label, 0, 1)), ], ), ], ) } html.div([attribute.class("my-5 px-5 pb-5 ")], [ element.text("Respond to this post on Bluesky to have it show up here!"), html.div([attribute.class("flex gap-2 items-center")], [ html.span([attribute.class("text-xs mr-2 opacity-50")], [ element.text("Open in:"), ]), icon_link( "Bluesky", "bsky.app", "text-[#006aff] bg-white chilp-oat-enhance-inset-blueskyreply", ), icon_link( "Blacksky", "blacksky.community", "text-white bg-black chilp-oat-enhance-inset-blackskyreply", ), icon_link( "Witchsky", "witchsky.app", "bg-[#ed5345] text-white chilp-oat-enhance-inset-witchskyreply", ), ]), ]) } @internal pub const instancelist = [ "mastodon.social", "pony.social", "todon.nl", "mstdn.social", "infosec.exchange", "woem.space", "shitpost.trade", "procial.tchncs.de", ] fn view_mastodon_respond_form(mastodon_anchor: Mastodon) -> Element(Msg) { html.div([attribute.class("my-5 px-5 pb-5 ")], [ html.form( [ attribute.class("mb-8 w-full"), event.on_submit(fn(n) { let value = list.key_find(n, "userinstance") |> result.unwrap("") MastodonAnswer(value) }), ], [ html.div([attribute.class("flex flex-col gap-2")], [ // Label Section html.label( [ attribute.for("userinstance"), attribute.class("text-sm font-semibold text-base-content/80"), ], [ html.text("Enter your instance address to reply or "), html.a( [ attribute.href( "https://" <> placeholder_instance(mastodon_anchor) <> "/auth/sign_up", ), attribute.class("link link-primary-content"), ], [html.text("create an account")], ), html.text("!"), ], ), // Disclaimer Section html.p( [attribute.class("text-xs italic opacity-50 -mt-1 mb-2 ml-1")], [ html.text( "on the instance recommended by this widget... or one you pick yourself!", ), ], ), // The Input + Button Group html.div([attribute.class("join w-full shadow-sm")], [ html.input([ attribute.type_("text"), attribute.required(True), attribute.placeholder(placeholder_instance(mastodon_anchor)), attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), attribute.name("userinstance"), // "join-item" removes the inner borders/radii to make them stick attribute.class( "input input-bordered join-item flex-1 focus:outline-primary", ), ]), html.button( [ attribute.type_("submit"), attribute.class("btn btn-primary join-item"), ], [html.text("Go reply")], ), ]), ]), ], ), ]) } fn placeholder_instance(mastodon_anchor: Mastodon) -> String { [mastodon_anchor.instance, ..instancelist] |> list.shuffle |> list.first |> result.unwrap(mastodon_anchor.instance) } fn error_view(error: String) { // todo: Style dis. html.div([attribute.class("alert alert-error"), attribute.role("alert")], [ svg.svg( [ attribute("viewBox", "0 0 24 24"), attribute("fill", "none"), attribute.class("h-6 w-6 shrink-0 stroke-current"), attribute("xmlns", "http://www.w3.org/2000/svg"), ], [ svg.path([ attribute( "d", "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z", ), attribute("stroke-width", "2"), attribute("stroke-linejoin", "round"), attribute("stroke-linecap", "round"), ]), ], ), html.span([], [html.text("AN ERROR OCCURED:" <> error)]), ]) } // EFFECTS --------------------------------------------------------------------- fn new_coalesced_view( bsky_replies: List(BskyThreadReply), mastodon_descendants: List(MastodonDescendant), ) { effect.from(fn(dispatch) { dispatch( IncomingCoalescedView(coalesce_views(bsky_replies, mastodon_descendants)), ) }) } fn refresh_bsky(model: Model) -> Effect(Msg) { case model.bluesky_anchor { Some(anchor) -> { let url = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://" <> anchor.did <> "/app.bsky.feed.post/" <> anchor.postid let handler = rsvp.expect_json(bsky_thread_view_decoder(), fn(response) { case response { Ok(threadview) -> BskyIncomingThreadView(threadview) Error(rsvperror) -> case rsvperror { rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> AllStoppingError( "The response body we got back from Bluesky was misformed.", ) rsvp.BadUrl(_) -> AllStoppingError( "The API call to Bluesky failed. Did you enter the DID and post id correctly?", ) rsvp.HttpError(_) | rsvp.NetworkError -> AllStoppingError("Could not fetch comments from Bluesky.") } } }) rsvp.get(url, handler) } None -> effect.none() } } fn get_username_mastodon(model: Model) -> Effect(Msg) { case model.mastodon_anchor { Some(anchor) -> { let url = "https://" <> anchor.instance <> "/api/v1/statuses/" <> anchor.postid let handler = rsvp.expect_json( { use user <- decode.subfield(["account", "username"], decode.string) decode.success(user) }, fn(response) { case response { Ok(username) -> MastodonIncomingOpUsername(#( username, "https://" <> anchor.instance <> "/@" <> username <> "/" <> anchor.postid, )) Error(rsvperror) -> case rsvperror { rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> AllStoppingError( "The response body we got back from Mastodon was misformed.", ) rsvp.BadUrl(_) -> AllStoppingError( "The API call to Mastodon failed. Did you enter the instance and post id correctly?", ) rsvp.HttpError(_) -> { // This may mean something else! This might not be all-stopping just yet. // If this is a Misskey or Sharkey instance, we can still save things! MastodonErrorFetchingOpUsernameRetryForMisskey } rsvp.NetworkError -> AllStoppingError( "Could not fetch comments from Mastodon. (NetworkError)", ) } } }, ) rsvp.get(url, handler) } None -> effect.none() } } fn get_username_miskey(model: Model) -> Effect(Msg) { case model.mastodon_anchor { Some(anchor) -> { let url = "https://" <> anchor.instance <> "/api/notes/show" let handler = rsvp.expect_json( { use user <- decode.subfield(["user", "username"], decode.string) decode.success(user) }, fn(response) { case response { Ok(username) -> MastodonIncomingOpUsername(#( username, "https://" <> anchor.instance <> "/notes/" <> anchor.postid, )) Error(rsvperror) -> case rsvperror { rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> AllStoppingError( "The response body we got back from Misskey/Sharkey was misformed.", ) rsvp.BadUrl(_) -> AllStoppingError( "The API call to Misskey/Sharkey failed. Did you enter the instance and post id correctly?", ) rsvp.HttpError(_) -> { // ... if we end up here again, then, yes, we have in fact failed... Sorry! AllStoppingError( "Could not fetch comments from Mastodon/Misskey/Sharkey. (HttpError)", ) } rsvp.NetworkError -> AllStoppingError( "Could not fetch comments from Misskey/Sharkey. (NetworkError)", ) } } }, ) rsvp.post( url, json.object([#("noteId", json.string(anchor.postid))]), handler, ) } None -> effect.none() } } fn refresh_mastodon(model: Model) -> Effect(Msg) { case model.mastodon_anchor { Some(anchor) -> { let url = "https://" <> anchor.instance <> "/api/v1/statuses/" <> anchor.postid <> "/context" let handler = rsvp.expect_json( mastodon_status_context_decoder(anchor.postid), fn(response) { case response { Ok(status) -> MastodonIncomingStatus(status) Error(rsvperror) -> case rsvperror { rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> AllStoppingError( "The response body we got back from Mastodon was misformed.", ) rsvp.BadUrl(_) -> AllStoppingError( "The API call to Mastodon failed. Did you enter the instance and post id correctly?", ) rsvp.HttpError(_) | rsvp.NetworkError -> AllStoppingError("Could not fetch comments from Mastodon.") } } }, ) rsvp.get(url, handler) } None -> effect.none() } } fn browse(to: String) { use _ <- effect.from js_browse(to) } // HELPERS --------------------------------------------------------------------- /// Attempts to do what DOMpurify does... ...while lustre-ifying it! fn sanitise_ls(html: String) -> element.Element(a) { // To a tree is the easy part. html_parser.as_tree(html) // Then reconstructing it sanely... |> sanitise_reconstruct_ls } fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { case el { html_parser.EmptyElement -> element.none() html_parser.StartElement(name:, attributes:, children:) -> { // attributes let attribs = list.map(attributes, fn(attrib) { case attrib { html_parser.Attribute(key: "href", value: link) -> attribute.href(link) html_parser.Attribute(key: "class", value: classes) -> attribute.class(classes) html_parser.Attribute(key: "target", value: target) -> attribute.target(target) html_parser.Attribute(_, _) -> attribute.none() } }) [ list.map(children, sanitise_reconstruct_ls) |> case name { "b" -> html.b(attribs, _) "i" -> html.i(attribs, _) "em" -> html.em(attribs, _) "strong" -> html.strong(attribs, _) "a" -> html.a( [attribute.class("link link-secondary-content"), ..attribs], _, ) "p" -> html.p(attribs, _) "br" -> fn(_) { html.br(attribs) } "span" -> html.span(attribs, _) _ -> element.fragment }, element.text(" "), ] |> element.fragment() } // AFAIK we don't have this due to parsing as tree. html_parser.EndElement(_) -> element.text("ERROR: Did not expect an element end here!") html_parser.Content(cnt) -> element.text(cnt) } } fn sort_comments(c1: CoalescedView, c2: CoalescedView) -> order.Order { case int.compare(c1.agreeability, c2.agreeability) { order.Eq -> timestamp.compare(c1.created_at, c2.created_at) measure -> measure } } @external(javascript, "./ffi_chilp.mjs", "lassign") fn js_browse(_: String) -> Nil { Nil } /// 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`. /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! @internal pub type BskyThreadView { BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) } @internal pub type BskyThreadReply { BskyThreadReply( at_uri: String, like_count: Int, created_at: timestamp.Timestamp, body_text: String, author_did: String, author_handle: String, author_displayname: String, author_avatar: String, children: List(BskyThreadReply), ) } fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) use replies <- decode.subfield( ["thread", "replies"], decode.list(bsky_thread_reply_decoder()), ) decode.success(BskyThreadView(at_uri:, replies:)) } fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { use created_at <- decode.subfield( ["post", "record", "createdAt"], decode.map(decode.string, fn(stringstamp) { result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) }), ) use at_uri <- decode.subfield(["post", "uri"], decode.string) use body_text <- decode.subfield(["post", "record", "text"], decode.string) use author_did <- decode.subfield(["post", "author", "did"], decode.string) use author_handle <- decode.subfield( ["post", "author", "handle"], decode.string, ) use author_displayname <- decode.subfield( ["post", "author", "displayName"], decode.string, ) use author_avatar <- decode.subfield( ["post", "author", "avatar"], decode.string, ) use like_count <- decode.subfield(["post", "likeCount"], decode.int) use children <- decode.field( "replies", decode.list(bsky_thread_reply_decoder()), ) decode.success(BskyThreadReply( at_uri:, created_at:, body_text:, like_count:, author_did:, author_handle:, author_displayname:, author_avatar:, children:, )) } /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. @internal pub type MastodonStatusContext { MastodonStatusContext(descendants: List(MastodonDescendant)) } fn mastodon_status_context_decoder( original_id: String, ) -> decode.Decoder(MastodonStatusContext) { use flat_descendants <- decode.field( "descendants", decode.list(mastodon_descendant_decoder()), ) let descendants: List(MastodonDescendant) = list.filter_map(flat_descendants, fn(desc) { case desc.1 == original_id { True -> { // A parent! mastodon_decendant_inflater(desc.0, flat_descendants) |> Ok } False -> { Error(Nil) } } }) decode.success(MastodonStatusContext(descendants:)) } fn mastodon_decendant_inflater( parent: MastodonDescendant, all_children: List(#(MastodonDescendant, String)), ) { MastodonDescendant( ..parent, children: list.filter_map(all_children, fn(c) { case c.1 == parent.id { True -> Ok(c.0) False -> Error(Nil) } }), ) } @internal pub type MastodonDescendant { MastodonDescendant( id: String, uri: String, content: element.Element(Msg), created_at: timestamp.Timestamp, favourite_count: Int, // 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. // We gotta iterate over this later to get things nested. children: List(MastodonDescendant), author_url: String, author_avatar_url: String, author_username: String, author_displayname: String, ) } /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. fn mastodon_descendant_decoder() -> decode.Decoder( #(MastodonDescendant, String), ) { use replying_to <- decode.field("in_reply_to_id", decode.string) use id <- decode.field("id", decode.string) use uri <- decode.field("uri", decode.string) use created_at <- decode.field( "created_at", decode.map(decode.string, fn(stringstamp) { result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) }), ) use favourite_count <- decode.field("favourites_count", decode.int) use unescaped_html_content <- decode.field("content", decode.string) let content = sanitise_ls(unescaped_html_content) let children = [] use author_url <- decode.subfield(["account", "url"], decode.string) use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) use author_displayname <- decode.subfield( ["account", "display_name"], decode.string, ) use author_username <- decode.subfield(["account", "acct"], decode.string) decode.success(#( MastodonDescendant( id:, uri:, content:, created_at:, favourite_count:, children:, author_url:, author_avatar_url:, author_username:, author_displayname:, ), replying_to, )) } fn coalesce_views( bsky: List(BskyThreadReply), mastodon: List(MastodonDescendant), ) -> List(CoalescedView) { let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { list.append( list.map(bsky, fn(m) { Ok(m) }), list.map(mastodon, fn(m) { Error(m) }), ) |> list.shuffle } list.map(mixed, fn(item) { case item { Ok(BskyThreadReply( created_at:, at_uri:, like_count:, body_text:, author_did:, author_displayname:, author_handle:, author_avatar:, children:, )) -> CoalescedView( content_url: "https://bsky.app/profile/" <> { string.replace(at_uri, "/app.bsky.feed.post/", "/post/") |> string.replace("at://", "") }, created_at:, author_profile_link: "https://bsky.app/profile/" <> author_did, source: "Bluesky", agreeability: like_count, content: element.text(body_text), author_username: author_handle, author_avatar_url: author_avatar, displayname: author_displayname, children: { coalesce_views(children, []) }, ) Error(MastodonDescendant( created_at:, id: _, uri:, content:, favourite_count:, children:, author_url:, author_avatar_url:, author_username:, author_displayname:, )) -> CoalescedView( content_url: uri, created_at:, author_profile_link: author_url, source: "Mastodon", displayname: author_displayname, agreeability: favourite_count, author_avatar_url:, author_username:, content: content, children: { coalesce_views([], children) }, ) } }) } @internal pub type CoalescedView { CoalescedView( created_at: timestamp.Timestamp, author_profile_link: String, author_avatar_url: String, author_username: String, source: String, displayname: String, content: element.Element(Msg), content_url: String, agreeability: Int, children: List(CoalescedView), ) }