Current section

Files

Jump to
chilp src chilp.gleam
Raw

src/chilp.gleam

//// <script src="https://site.strawmelonjuice.com/chilp-only.js" type="module"></script>
//// <style>
//// @media only screen and (max-width: 600px) { blockquote { overflow-x: auto; } }
//// .comment-widget {
//// position: relative;
//// font-size: unset;
//// --fx-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");
//// border: solid 2px var(--hot-pink);
//// border-radius: 25px;
//// transition: 0.3s;
//// width: 100%;
//// margin: auto;
//// box-shadow: 0 8px 16px 0 var(--shadow);
//// padding: 10px;
//// background-color: var(--light-grey);
//// @media only screen and (max-width: 600px) { width: 120VW; margin: 10px; }
//// * {
//// box-sizing: unset;
//// }
//// .btn {
//// cursor: pointer;
//// }
//// .tabs-box {
//// color: var(--black);
//// text-size-adjust: 100%;
//// tab-size: 4;
//// line-height: 1.5;
//// -webkit-tap-highlight-color: transparent;
//// box-sizing: border-box;
//// border: 0px solid;
//// margin: 0px;
//// flex-wrap: wrap;
//// flex-direction: row;
//// display: flex;
//// background-color: var(--accent);
//// border-radius: 20px;
//// box-shadow: 0 -.5px oklch(100% 0 0 / .1) inset, 0 .5px oklch(0% 0 0 / .05) inset;
//// padding: 0.25rem;
//// height: 45px;
//// border-bottom-width: 2px;
//// border-color: var(--accent);
//// padding: 5.6px 0px 1.5px;
//// justify-content: center;
//// align-items: stretch;
//// gap: 0.5rem;
//// .tab {
//// /* margin: 5px; */
//// height: 30px;
//// width: 40%;
//// border-radius: 20px;
//// border: none;
//// order: 0;
//// background-color: var(--light-grey);
//// padding-inline: 3%;
//// :hover&:not(&.tab-active) {
//// background-color: var(--boi-blue)
//// }
//// &.tab-active {
//// background-color: var(--hot-pink);
//// color: var(--light-grey);
//// }
//// }
//// }
//// p.text-xs {
//// font-size: 12px;
//// }
//// .join.w-full.shadow-sm {
//// background-color: var(--pink);
//// border-radius: var(--gap);
//// width: 100%;
//// display: inline-flex;
//// align-items: stretch;
//// padding: 3px;
//// .flex-1 {
//// border-radius: var(--gap);
//// flex: 1 1 0%;
//// cursor: text;
//// background-color: var(--pink-white);
//// vertical-align: middle;
//// white-space: nowrap;
//// width: clamp(3rem, 20rem, 100%);
//// height: var(--gap);
//// touch-action: manipulation;
//// border: none;
//// flex-shrink: 1;
//// align-items: center;
//// gap: 0.5rem;
//// padding-inline: 0.75rem;
//// display: inline-flex;
//// position: relative;
//// }
//// }
//// .content {
//// margin-left: 0px;
//// margin-bottom: 0px;
//// padding: 0px;
//// }
//// .comment {
//// margin-top: 8px;
//// }
//// .card.card-dash.bg-base-200\/70.border-base-300 {
//// margin: 0px;
//// padding: 0px;
//// border-radius: var(--gap);
//// outline-offset: 2px;
//// outline: rgba(0, 0, 0, 0) solid 0px;
//// flex-direction: column;
//// transition: outline 0.2s ease-in-out;
//// display: flex;
//// position: relative;
//// border: 2px dashed var(--pink-white);
//// border-color: var(--pink);
//// background-color: color-mix(in oklab, var(--color-base-200) 70%, transparent);
//// }
////
//// footer > div > .btn.btn-sm.absolute.right-8 {
//// display: inline-block;
//// outline-offset: 2px;
//// vertical-align: middle;
//// position: absolute;
//// right: 28px;
//// }
////
////
//// .hidden {
//// display: none;
//// }
//// header {
//// display: flex;
//// figure {
//// margin: 0;
//// }
//// & .avatar {
//// mask-image: url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M100 0C20 0 0 20 0 100s20 100 100 100 100-20 100-100S180 0 100 0Z'/%3e%3c/svg%3e");
//// width: 45px;
//// height: 45px;
//// vertical-align: middle;
//// display: inline-block;
//// mask-position: 50% center;
//// mask-size: contain;
//// mask-repeat: no-repeat;
//// display: inline-block;
//// }
//// .meta {
////
//// padding-left: 16px;
//// max-height: 45px;
//// margin: 0;
//// max-height: 45px;
//// display: flex;
//// flex-direction: column;
//// justify-content: space-around;
//// align-items: flex-start;
//// * {
//// max-height: 22px;
//// padding: 0;
//// margin: 0;
//// line-height: 0.9;
//// }
//// p.text-xs {
//// font-size: 10px;
//// }
//// .badge {
//// border-radius: .5rem;
//// vertical-align: middle;
//// border: 2px solid;
//// background-size: auto, calc(var(--noise) * 100%);
//// background-image: none, var(--fx-noise);
//// height: 24px;
//// padding: 1px 3px 1.5px 3px;
//// justify-content: center;
//// align-items: center;
//// gap: 0.5rem;
//// font-size: 12px;
//// display: inline-flex;
//// margin-inline-start: calc(.25rem * 2);
//// &.bg-\[\#595aff\] {
//// --badge-bg: #595aff;
//// --badge-fg: white;
//// background-color: #595aff;
//// color: var(--white);
//// }
//// &.bg-\[\#006aff\] {
//// --badge-bg: #006aff;
//// --badge-fg: white;
//// background-color: #006aff;
//// color: var(--white);
//// }
//// &.badge-accent {
//// --badge-bg: var(--boi-blue);
//// --badge-fg: var(--black);
//// background-color: var(--boi-blue);
//// opacity: 70%;
//// color: var(--black);
//// }
//// }
//// }
////
//// }
////
//// .chilp-oat-enhance-inset-2em { padding-inline-start: 2em; }
//// .chilp-oat-enhance-inset-blueskyreply, .chilp-oat-enhance-inset-witchskyreply, .chilp-oat-enhance-inset-blackskyreply {
//// margin: 5px;
//// height: 30px;
//// width: fit-content;
//// border-radius: 20px;
//// border: none;
//// order: 0;
//// background-color: var(--light-grey);
//// padding-inline: 12px;
//// box-shadow: -1px 1px 1px 0px rgba(0,0,0,0.75);
//// -webkit-box-shadow: -1px 1px 1px 0px rgba(0,0,0,0.75);
//// -moz-box-shadow: -1px 1px 1px 0px rgba(0,0,0,0.75);
//// }
//// .chilp-oat-enhance-inset-quoteblock { border-left: 1px solid; padding-inline-start: 1.5em; }
//// .chilp-oat-enhance-inset-blueskyreply { color: rgb(0, 106, 255); background-color: white; &::after { content: "luesky"; } }
//// .chilp-oat-enhance-inset-blackskyreply { color: white; background-color: black; &::after { content: "lacksky"; } }
//// .chilp-oat-enhance-inset-witchskyreply { color: white; background-color: rgb(237, 83, 69); &::after { content: "itchsky"; } }
//// }
////
//// </style>
////
////
////
//// # 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"`.<br><br>Not sure how to find your DID? <https://bsky-did.neocities.org> 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/<your-username-or-did>/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/@<username>/[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!
/// > <comment-widget bluesky-anchor="did:plc:jgtfsmv25thfs4zmydtbccnn\3mk5u4xwqus2i" mastodon-anchor="pony.social\116453666510859359"></comment-widget>
///
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),
)
}