Current section
Files
Jump to
Current section
Files
src/chilp/widget.gleam
// IMPORTS ---------------------------------------------------------------------
import chilp/widget/anchors
import gleam/bool
import gleam/dynamic/decode
import gleam/int
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
// MAIN ------------------------------------------------------------------------
pub fn register() -> Result(Nil, lustre.Error) {
// I went along with the examples at https://github.com/lustre-labs/lustre/tree/main/examples/05-components!
// If you're looking for good examples, look there!
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")
}
pub fn element(
mastodon_anchor: Option(anchors.Mastodon),
bsky_anchor: Option(anchors.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 -> ""
}),
],
[],
)
}
// MODEL -----------------------------------------------------------------------
type Model {
Model(
mastodon_anchor: Option(anchors.Mastodon),
cached_mastodon_descendants: List(MastodonDescendant),
mastodon_op_username: String,
bluesky_anchor: Option(anchors.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,
)
}
fn init(_) -> #(Model, Effect(Msg)) {
#(
Model(
mastodon_anchor: None,
cached_mastodon_descendants: [],
mastodon_op_username: "username",
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 ----------------------------------------------------------------------
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)
SetTab(Int)
MastodonAnswer(instance: String)
}
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(anchor) if instance != "" -> {
#(
model,
browse({
"https://"
<> instance
<> "/authorize_interaction?uri="
<> {
{
"https://"
<> anchor.instance
<> "/@"
<> model.mastodon_op_username
<> "/"
<> anchor.postid
}
|> uri.percent_encode
}
}),
)
}
_ -> #(model, effect.none())
}
}
MastodonAnchored(instance:, post:) -> {
let model =
Model(
..model,
mastodon_anchor: Some(anchors.Mastodon(instance:, postid: post)),
)
#(
model,
effect.batch([get_username_mastodon(model), refresh_mastodon(model)]),
)
}
MastodonIncomingOpUsername(mastodon_op_username) -> #(
Model(..model, mastodon_op_username: mastodon_op_username),
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(anchors.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(),
)
}
}
// VIEW ------------------------------------------------------------------------
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 #(linkedto, respond_here) = case
model.bluesky_anchor,
model.mastodon_anchor
{
Some(bsky), Some(masto) -> {
#(
[
html.a(
[
attribute.href(
"https://"
<> masto.instance
<> "/@"
<> model.mastodon_op_username
<> "/"
<> masto.postid,
),
attribute.class("link text-[#595aff]"),
],
[html.text("this post")],
),
html.text(" on Mastodon, and to "),
html.a(
[
attribute.href(
"https://bsky.app/profile/"
<> bsky.did
<> "/post/"
<> bsky.postid,
),
attribute.class("link text-[#006aff]"),
],
[html.text("this post")],
),
html.text(" on Bluesky."),
]
|> element.fragment,
[
html.div(
[
attribute.class("tabs tabs-box border-b-2 border-base-300 h-full"),
attribute.role("tablist"),
],
[
html.a(
[
attribute.role("tab"),
event.on_click(SetTab(1)),
attribute.classes([
#("tab", True),
#("tab-active", model.open_tab == 1),
]),
],
[
html.text("Mastodon"),
],
),
html.a(
[
attribute.role("tab"),
event.on_click(SetTab(2)),
attribute.classes([
#("tab", True),
#("tab-active", model.open_tab == 2),
]),
],
[html.text("Bluesky")],
),
html.a(
[
attribute.role("tab"),
event.on_click(SetTab(3)),
attribute.classes([
#("tab", True),
#("tab-active", model.open_tab == 3),
]),
],
[
html.text("About"),
],
),
],
),
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) -> {
#(
[
html.a(
[
attribute.href(
"https://"
<> masto.instance
<> "/"
<> model.mastodon_op_username
<> "/"
<> masto.postid,
),
attribute.class("link text-[#595aff]"),
],
[html.text("this post")],
),
html.text(" on Mastodon."),
]
|> element.fragment,
view_mastodon_respond_form(masto),
)
}
Some(bsky), None -> {
#(
[
html.a(
[
attribute.href(
"https://bsky.app/profile/"
<> bsky.did
<> "/post/"
<> bsky.postid,
),
attribute.class("link link-[#006aff]"),
],
[html.text("this post")],
),
html.text(" on Bluesky."),
]
|> 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")], [
html.text("Linked to "),
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
}),
)),
),
],
),
])
}
fn view_rendered_comment(
comment: CoalescedView,
bsky_op_profile: Option(String),
mastodon_op_profile: Option(String),
) -> Element(Msg) {
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), _ -> {
// Had some mismatches here, but decided it is of no importance what hostname we use.
{
op
|> string.replace("https://bsky.app", "")
|> string.replace("https://bluesky.app", "")
}
== {
child.author_profile_link
|> string.replace("https://bsky.app", "")
|> string.replace("https://bluesky.app", "")
}
}
_, _, _ -> 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.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")], [
html.text(comment.displayname),
case comment.source {
"Mastodon" ->
html.div(
[
attribute.class(
"badge badge-sm badge-info ms-2 bg-[#595aff] text-white",
),
],
[
element.text("Mastodon"),
],
)
"Bluesky" ->
html.div(
[
attribute.class(
"badge badge-sm badge-info ms-2 bg-[#006aff] text-white",
),
],
[
element.text("Bluesky"),
],
)
_ -> 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",
),
],
list.sort(comment.children, sort_comments)
|> list.map(view_rendered_comment(
_,
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"),
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"),
icon_link("Blacksky", "blacksky.community", "text-white bg-black"),
icon_link("Witchsky", "witchsky.app", "text-[#ed5345] bg-white"),
]),
])
}
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: anchors.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: anchors.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)
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 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.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!
type BskyThreadView {
BskyThreadView(at_uri: String, replies: List(BskyThreadReply))
}
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`.
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)
}
}),
)
}
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://bluesky.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) },
)
}
})
}
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),
)
}