From 698b5b02b559964dbd091c823a61ae30dc882731 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 19 Apr 2026 12:59:08 +0200 Subject: [PATCH] quote posts --- src/gpreview.css | 53 ++- src/gpreview.gleam | 63 ++- src/gpreview/feed/feed_actions.gleam | 56 +++ src/gpreview/feed/feed_view.gleam | 76 +++- src/gpreview/types.gleam | 26 +- src/gpreview/views.gleam | 6 +- test/feed_test.gleam | 420 ++++++++++++++++++ test/fixtures/bsky/quote_post.json | 16 + test/gpreview_test.gleam | 18 +- test/views_test.gleam | 611 +++------------------------ 10 files changed, 750 insertions(+), 595 deletions(-) create mode 100644 test/feed_test.gleam create mode 100644 test/fixtures/bsky/quote_post.json diff --git a/src/gpreview.css b/src/gpreview.css index 38c0650..e31be97 100644 --- a/src/gpreview.css +++ b/src/gpreview.css @@ -89,6 +89,19 @@ --font-display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; --font-body: "Sora", ui-sans-serif, system-ui, sans-serif; + /* Typography Scale */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + + /* Semantic aliases */ + --text-body: var(--text-base); + --text-caption: var(--text-xs); + --text-heading: var(--text-xl); + /* Animation */ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); --ease-spring: cubic-bezier(0.15, 0.9, 0.35, 1); @@ -112,7 +125,7 @@ body { .app-shell { max-width: 840px; margin: 0 auto; - padding: var(--space-6) var(--space-4); + padding: var(--space-5) var(--space-4); } @media (min-width: 640px) { @@ -235,7 +248,7 @@ body { } .post-header__handle { - font-size: 0.85rem; + font-size: var(--text-sm); line-height: 1.3; color: var(--subtext0); margin-top: 2px; @@ -359,7 +372,7 @@ body { white-space: nowrap; } .external-link-card__desc { - font-size: 0.875rem; + font-size: var(--text-sm); color: var(--text-muted); line-height: 1.5; margin-bottom: var(--space-2); @@ -375,11 +388,22 @@ body { /* ─── Quote Post ─── */ .quote-post { - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: var(--space-3); + border: none; + border-left: 3px solid var(--mauve); + border-radius: 0; + padding: var(--space-3) var(--space-4); margin-top: var(--space-3); background: var(--surface1); + box-shadow: none; +} +.quote-post::before { + content: "Quoted post"; + display: block; + font-size: var(--text-xs); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); } .quote-post--stub { opacity: 0.8; @@ -435,7 +459,7 @@ body { } .post-footer__timestamp { - font-size: 0.8rem; + font-size: var(--text-xs); color: var(--subtext0); white-space: nowrap; } @@ -524,10 +548,11 @@ body { /* ─── Reply Context ─── */ .reply-context { - padding: var(--space-2) var(--space-5); - font-size: 0.78rem; - color: var(--subtext0); - background: var(--mantle); + padding: var(--space-2) var(--space-4); + font-size: var(--text-xs); + color: var(--text-muted); + background: var(--surface1); + border-left: 3px solid var(--mauve); } .reply-context__label { @@ -669,7 +694,7 @@ body { box-shadow var(--duration-normal) var(--ease-out); border: 1px solid var(--border); animation: fadeInUp var(--duration-slow) var(--ease-out) both; - margin-bottom: 2rem; + margin-bottom: var(--space-7); } .profile-card--loading { @@ -758,7 +783,7 @@ body { .profile-name { font-family: var(--font-display); font-weight: 700; - font-size: 1.5rem; + font-size: var(--text-xl); line-height: 1.3; color: var(--text); margin: 0; @@ -825,7 +850,7 @@ body { .feed-container { display: flex; flex-direction: column; - gap: var(--space-4); + gap: var(--space-3); } .feed-loading-header { diff --git a/src/gpreview.gleam b/src/gpreview.gleam index 2b581c0..9f3ef51 100644 --- a/src/gpreview.gleam +++ b/src/gpreview.gleam @@ -1,12 +1,15 @@ +import bsky/decoders +import gleam/dict import gleam/list import gleam/option import gpreview/effects import gpreview/feed/feed_actions import gpreview/profile/profile_actions import gpreview/types.{ - type Model, type Msg, App, FeedFailed, FeedLoaded, FeedLoading, - IdentityResolved, InputChanged, Post, PostsFetched, ProfileFailed, - ProfileFetched, ProfileLoaded, ProfileLoading, RetryFetch, SubmitInput, + type Model, type Msg, type QuotePostState, App, FeedFailed, FeedLoaded, + FeedLoading, IdentityResolved, InputChanged, Post, PostsFetched, ProfileFailed, + ProfileFetched, ProfileLoaded, ProfileLoading, QuotePostFetched, RetryFetch, + SubmitInput, } import gpreview/views import lustre @@ -44,6 +47,7 @@ fn init(_args: Nil) -> #(Model, Effect(Msg)) { identity: option.None, profile_state: types.ProfileEmpty, feed_state: types.FeedEmpty, + quote_posts: dict.new(), retry_count: 0, ), effect.none(), @@ -88,9 +92,32 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { text: r.value.text, created_at: r.value.created_at, embed: r.value.embed, + reply: r.value.reply, ) }) - #(App(..model, feed_state: FeedLoaded(post_records)), effect.none()) + + // Extract URIs from posts that have quote embeds AND reply references + let quote_uris = extract_quote_post_uris(post_records) + let reply_uris = extract_reply_uris(post_records) + let all_uris = list.append(quote_uris, reply_uris) + let pds = case model.identity { + option.Some(id) -> id.pds + option.None -> "" + } + let fetch_effects = case pds, all_uris { + "", _ -> effect.none() + _, [] -> effect.none() + _, uris -> { + let fetch_uris = list.unique(uris) + effect.batch( + list.map(fetch_uris, fn(uri) { + feed_actions.fetch_quote_post(pds, uri) + }), + ) + } + } + + #(App(..model, feed_state: FeedLoaded(post_records)), fetch_effects) } PostsFetched(Error(e)) -> #( App(..model, feed_state: FeedFailed(friendly_error_message(e, "posts"))), @@ -137,6 +164,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) } } + QuotePostFetched(uri, Ok(post)) -> #( + App(..model, quote_posts: dict.insert(model.quote_posts, uri, post)), + effect.none(), + ) + QuotePostFetched(_, Error(_e)) -> #(model, effect.none()) } } @@ -162,3 +194,26 @@ fn friendly_error_message(e: rsvp.Error, context: String) -> String { "Service temporarily unavailable. Please try again." } } + +/// Extracts unique quote post URIs from posts that have Record embeds +fn extract_quote_post_uris(posts: List(types.Post)) -> List(String) { + posts + |> list.flat_map(fn(post) { + case post.embed { + option.Some(decoders.Record(embed_record)) -> [embed_record.record.uri] + option.Some(decoders.RecordWithMedia(rwm)) -> [rwm.record.record.uri] + _ -> [] + } + }) +} + +/// Extracts unique parent and root post URIs from posts that have reply references +fn extract_reply_uris(posts: List(types.Post)) -> List(String) { + posts + |> list.flat_map(fn(post) { + case post.reply { + option.Some(reply_ref) -> [reply_ref.parent.uri, reply_ref.root.uri] + option.None -> [] + } + }) +} diff --git a/src/gpreview/feed/feed_actions.gleam b/src/gpreview/feed/feed_actions.gleam index 23a1da3..c912fa5 100644 --- a/src/gpreview/feed/feed_actions.gleam +++ b/src/gpreview/feed/feed_actions.gleam @@ -1,6 +1,9 @@ import bsky/decoders +import gleam/dynamic/decode import gleam/int +import gleam/string import gleam/uri +import gpreview/record import gpreview/types import lustre/effect.{type Effect} import rsvp @@ -30,3 +33,56 @@ pub fn fetch_posts(pds: String, did: String, limit: Int) -> Effect(types.Msg) { ), ) } + +/// Fetches a single post record by URI (at://did/collection/rkey) +pub fn fetch_quote_post(pds: String, uri: String) -> Effect(types.Msg) { + let query = case parse_at_uri(uri) { + Ok(#(repo, collection, rkey)) -> + uri.query_to_string([ + #("repo", repo), + #("collection", collection), + #("rkey", rkey), + ]) + Error(_) -> uri.query_to_string([]) + } + + rsvp.get( + pds <> "/xrpc/com.atproto.repo.getRecord?" <> query, + rsvp.expect_json(decode_record_response(), fn(result) { + case result { + Ok(record) -> { + let post = + types.Post( + uri: record.uri, + cid: record.cid, + text: record.value.text, + created_at: record.value.created_at, + embed: record.value.embed, + reply: record.value.reply, + ) + types.QuotePostFetched(uri, Ok(post)) + } + Error(e) -> types.QuotePostFetched(uri, Error(e)) + } + }), + ) +} + +/// Parses an AT URI into (repo, collection, rkey) +fn parse_at_uri(at_uri: String) -> Result(#(String, String, String), Nil) { + let rest = case string.starts_with(at_uri, "at://") { + True -> string.slice(at_uri, 5, string.length(at_uri) - 5) + False -> at_uri + } + case string.split(rest, "/") { + [repo, collection, rkey] -> Ok(#(repo, collection, rkey)) + _ -> Error(Nil) + } +} + +fn decode_record_response() -> decode.Decoder(record.Record(decoders.PostJson)) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use value <- decode.field("value", decoders.decode_post()) + decode.success(record.Record(uri:, cid:, value:)) +} diff --git a/src/gpreview/feed/feed_view.gleam b/src/gpreview/feed/feed_view.gleam index a6e3fda..3123a35 100644 --- a/src/gpreview/feed/feed_view.gleam +++ b/src/gpreview/feed/feed_view.gleam @@ -1,4 +1,5 @@ import bsky/decoders +import gleam/dict import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -21,12 +22,13 @@ pub type FeedState = pub fn render_feed( feed_state: FeedState, identity: Option(types.Identity), + quote_posts: types.QuotePostState, ) -> Element(types.Msg) { case feed_state { types.FeedEmpty -> html.div([attribute.attribute("class", "feed-container")], []) types.FeedLoading -> feed_loading_skeleton() - types.FeedLoaded(posts) -> render_feed_loaded(posts, identity) + types.FeedLoaded(posts) -> render_feed_loaded(posts, identity, quote_posts) types.FeedFailed(error) -> feed_error_state(error) } } @@ -86,11 +88,12 @@ fn feed_item_skeleton(index: Int) -> Element(types.Msg) { fn render_feed_loaded( posts: List(Post), identity: Option(types.Identity), + quote_posts: types.QuotePostState, ) -> Element(types.Msg) { let elements = posts |> list.index_map(fn(post, index) { - render_feed_item(post, index, identity) + render_feed_item(post, index, identity, quote_posts) }) html.div([attribute.attribute("class", "feed-container")], elements) @@ -163,24 +166,73 @@ fn render_image_grid( html.div([attribute.attribute("class", grid_class)], image_elements) } -fn render_quote_post(record: decoders.EmbedRecordJson) -> Element(types.Msg) { +fn render_quote_post( + record: decoders.EmbedRecordJson, + quote_posts: types.QuotePostState, +) -> Element(types.Msg) { + let quote_uri = record.record.uri + let quoted_post = dict.get(quote_posts, quote_uri) + let text_content = case quoted_post { + Ok(post) -> post.text + Error(_) -> "" + } html.div([attribute.attribute("class", "quote-post")], [ html.div([attribute.attribute("class", "quote-post__header")], [ html.span([attribute.attribute("class", "quote-post__author")], [ html.text("Quoted Post"), ]), html.span([attribute.attribute("class", "quote-post__handle")], [ - html.text(extract_handle_from_uri(record.record.uri)), + html.text(extract_handle_from_uri(quote_uri)), ]), ]), + case text_content { + "" -> element.none() + _ -> + html.p([attribute.attribute("class", "quote-post__text")], [ + html.text(truncate_text(text_content, 140)), + ]) + }, + ]) +} + +/// Renders reply context - shows parent post as "Replying to @handle" +fn render_reply_post( + reply_ref: decoders.ReplyRefJson, + quote_posts: types.QuotePostState, +) -> Element(types.Msg) { + // Try to get the parent post text, fall back to showing just the handle + let parent_uri = reply_ref.parent.uri + let parent_post = dict.get(quote_posts, parent_uri) + let parent_text = case parent_post { + Ok(post) -> post.text + Error(_) -> "" + } + let handle = extract_handle_from_uri(parent_uri) + html.div([attribute.attribute("class", "reply-context")], [ + html.div([attribute.attribute("class", "reply-context__line")], [ + html.span([attribute.attribute("class", "reply-context__label")], [ + html.text("↩ Replying to "), + ]), + html.span([attribute.attribute("class", "reply-context__handle")], [ + html.text(handle), + ]), + ]), + case parent_text { + "" -> element.none() + _ -> + html.p([attribute.attribute("class", "reply-context__text")], [ + html.text(truncate_text(parent_text, 100)), + ]) + }, ]) } fn render_record_with_media( rwm: decoders.EmbedRecordWithMedia, + quote_posts: types.QuotePostState, ) -> Element(types.Msg) { html.div([attribute.attribute("class", "record-with-media")], [ - render_quote_post(rwm.record), + render_quote_post(rwm.record, quote_posts), render_external_link_card(rwm.media), ]) } @@ -195,14 +247,16 @@ fn extract_handle_from_uri(uri: String) -> String { fn render_post_embed( embed: Option(decoders.Embed), identity: Option(types.Identity), + quote_posts: types.QuotePostState, ) -> Element(types.Msg) { case embed, identity { Some(decoders.Images(images)), Some(identity) -> render_image_grid(images, identity.pds, identity.did) Some(decoders.ExternalLink(external)), _ -> render_external_link_card(external) - Some(decoders.Record(record)), _ -> render_quote_post(record) - Some(decoders.RecordWithMedia(rwm)), _ -> render_record_with_media(rwm) + Some(decoders.Record(record)), _ -> render_quote_post(record, quote_posts) + Some(decoders.RecordWithMedia(rwm)), _ -> + render_record_with_media(rwm, quote_posts) _, _ -> html.div([], []) } } @@ -211,22 +265,28 @@ fn render_feed_item( post: Post, index: Int, identity: Option(types.Identity), + quote_posts: types.QuotePostState, ) -> Element(types.Msg) { let stagger_class = "stagger-" <> int.to_string({ index % 5 } + 1) let display_text = case string.is_empty(post.text) { True -> "[No text]" False -> truncate_text(post.text, 280) } + let reply_element = case post.reply { + option.Some(reply_ref) -> render_reply_post(reply_ref, quote_posts) + option.None -> element.none() + } html.div( [ attribute.attribute("class", "feed-item " <> stagger_class), ], [ + reply_element, html.div([attribute.attribute("class", "feed-item__content")], [ html.p([attribute.attribute("class", "feed-item__text")], [ html.text(display_text), ]), - render_post_embed(post.embed, identity), + render_post_embed(post.embed, identity, quote_posts), ]), html.div([attribute.attribute("class", "feed-item__footer")], [ html.span([attribute.attribute("class", "feed-item__timestamp")], [ diff --git a/src/gpreview/types.gleam b/src/gpreview/types.gleam index 31b646d..edd0dff 100644 --- a/src/gpreview/types.gleam +++ b/src/gpreview/types.gleam @@ -1,4 +1,5 @@ import bsky/decoders +import gleam/dict import gleam/dynamic/decode import gleam/int import gleam/json @@ -21,6 +22,7 @@ pub type Post { text: String, created_at: String, embed: Option(decoders.Embed), + reply: Option(decoders.ReplyRefJson), ) } @@ -31,15 +33,9 @@ pub type FeedState { FeedFailed(String) } -pub type Model { - App( - input_text: String, - identity: Option(Identity), - profile_state: ProfileState, - feed_state: FeedState, - retry_count: Int, - ) -} +/// Stores fetched quote posts keyed by URI +pub type QuotePostState = + dict.Dict(String, Post) pub type Identity { Identity(did: String, handle: String, pds: String, signing_key: String) @@ -49,11 +45,23 @@ pub type Msg { IdentityResolved(Result(Identity, String)) ProfileFetched(Result(decoders.ProfileJson, rsvp.Error)) PostsFetched(Result(List(Record(decoders.PostJson)), rsvp.Error)) + QuotePostFetched(String, Result(Post, rsvp.Error)) InputChanged(String) SubmitInput RetryFetch } +pub type Model { + App( + input_text: String, + identity: Option(Identity), + profile_state: ProfileState, + feed_state: FeedState, + quote_posts: QuotePostState, + retry_count: Int, + ) +} + pub fn json_decode_error_to_string(e: json.DecodeError) -> String { case e { json.UnexpectedEndOfInput -> "Unexpected end of input" diff --git a/src/gpreview/views.gleam b/src/gpreview/views.gleam index cf4d5a3..89e0649 100644 --- a/src/gpreview/views.gleam +++ b/src/gpreview/views.gleam @@ -4,6 +4,7 @@ import gpreview/profile/profile_view import gpreview/search import gpreview/types.{ type FeedState, type Identity, type Model, type Msg, type ProfileState, + type QuotePostState, } import lustre/attribute import lustre/element.{type Element} @@ -15,7 +16,7 @@ pub fn view(model: Model) -> Element(Msg) { render_input_zone(model), html.div([attribute.attribute("class", "main-content")], [ profile_view.render_profile_card(model.profile_state, model.identity), - feed_view.render_feed(model.feed_state, model.identity), + feed_view.render_feed(model.feed_state, model.identity, model.quote_posts), ]), ]) } @@ -38,6 +39,7 @@ pub fn render_profile_card( pub fn render_feed( feed_state: FeedState, identity: Option(Identity), + quote_posts: QuotePostState, ) -> Element(Msg) { - feed_view.render_feed(feed_state, identity) + feed_view.render_feed(feed_state, identity, quote_posts) } diff --git a/test/feed_test.gleam b/test/feed_test.gleam new file mode 100644 index 0000000..77aff09 --- /dev/null +++ b/test/feed_test.gleam @@ -0,0 +1,420 @@ +import bsky/decoders +import gleam/dict +import gleam/dynamic/decode.{field, string, success} +import gleam/json +import gleam/list +import gleam/option +import gleam/string +import gleeunit +import gleeunit/should +import gpreview/record.{type Record, Record} +import gpreview/types + +pub fn main() { + gleeunit.main() +} + +fn from_json( + json_string: String, + decoder: decode.Decoder(a), +) -> Result(a, json.DecodeError) { + json.parse(from: json_string, using: decoder) +} + +// === Decode Record with Post (for getRecord response) === + +fn decode_get_record(decoder: decode.Decoder(a)) -> decode.Decoder(Record(a)) { + use uri <- field("uri", string) + use cid <- field("cid", string) + use value <- field("value", decoder) + success(Record(uri:, cid:, value:)) +} + +// === Quote Post Fixture Tests === + +pub fn quote_post_fixture_decode_test() { + // Tests decoding the quote_post.json fixture + // This post has an embed that references another post + let json_string = + "{\"uri\":\"at://did:plc:bbb/app.bsky.feed.post/abc\",\"cid\":\"bafyreabc123\",\"value\":{\"$type\":\"app.bsky.feed.post\",\"text\":\"This post quotes another post!\",\"embed\":{\"$type\":\"app.bsky.embed.record\",\"record\":{\"uri\":\"at://did:plc:aaa/app.bsky.feed.post/xyz\",\"cid\":\"bafyrexyz789\"}},\"createdAt\":\"2024-01-15T10:30:00.000Z\"}}" + + from_json(json_string, decode_get_record(decoders.decode_post())) + |> should.be_ok() + |> fn(record) { + record.uri |> should.equal("at://did:plc:bbb/app.bsky.feed.post/abc") + record.cid |> should.equal("bafyreabc123") + record.value.text |> should.equal("This post quotes another post!") + record.value.created_at |> should.equal("2024-01-15T10:30:00.000Z") + } +} + +pub fn quote_post_fixture_has_record_embed_test() { + let json_string = + "{\"uri\":\"at://did:plc:bbb/app.bsky.feed.post/abc\",\"cid\":\"bafyreabc123\",\"value\":{\"$type\":\"app.bsky.feed.post\",\"text\":\"This post quotes another post!\",\"embed\":{\"$type\":\"app.bsky.embed.record\",\"record\":{\"uri\":\"at://did:plc:aaa/app.bsky.feed.post/xyz\",\"cid\":\"bafyrexyz789\"}},\"createdAt\":\"2024-01-15T10:30:00.000Z\"}}" + + from_json(json_string, decode_get_record(decoders.decode_post())) + |> should.be_ok() + |> fn(record) { + case record.value.embed { + option.Some(decoders.Record(embed_record)) -> { + embed_record.record.uri + |> should.equal("at://did:plc:aaa/app.bsky.feed.post/xyz") + embed_record.record.cid + |> should.equal("bafyrexyz789") + } + _ -> should.fail() + } + } +} + +// === Extract Quote URIs Tests === + +pub fn extract_quote_uris_from_record_embed_test() { + // A post with a Record embed should yield its quote URI + let post = + types.Post( + uri: "at://did:plc:bbb/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "This post quotes another post!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.Some( + decoders.Record( + decoders.EmbedRecordJson(record: decoders.StrongRefJson( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + )), + ), + ), + reply: option.None, + ) + + // Extract quote URIs from this post + let quote_uris = case post.embed { + option.Some(decoders.Record(embed_record)) -> [embed_record.record.uri] + option.Some(decoders.RecordWithMedia(rwm)) -> [rwm.record.record.uri] + _ -> [] + } + + list.length(quote_uris) |> should.equal(1) + quote_uris + |> list.first() + |> should.equal(Ok("at://did:plc:aaa/app.bsky.feed.post/xyz")) +} + +pub fn extract_quote_uris_from_record_with_media_test() { + // A post with a RecordWithMedia embed should also yield its quote URI + let post = + types.Post( + uri: "at://did:plc:bbb/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "This post quotes another post with media!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.Some( + decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( + record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + )), + media: decoders.ExternalJson( + uri: "https://example.com", + title: "Example", + description: "An example", + ), + )), + ), + reply: option.None, + ) + + let quote_uris = case post.embed { + option.Some(decoders.Record(embed_record)) -> [embed_record.record.uri] + option.Some(decoders.RecordWithMedia(rwm)) -> [rwm.record.record.uri] + _ -> [] + } + + list.length(quote_uris) |> should.equal(1) + quote_uris + |> list.first() + |> should.equal(Ok("at://did:plc:aaa/app.bsky.feed.post/xyz")) +} + +pub fn extract_quote_uris_ignores_other_embeds_test() { + // Non-record embeds (images, external) should not yield quote URIs + let post_with_images = + types.Post( + uri: "at://did:plc:bbb/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "Check out these images!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.Some(decoders.Images([])), + reply: option.None, + ) + + let post_with_external = + types.Post( + uri: "at://did:plc:bbb/app.bsky.feed.post/def", + cid: "bafyredef456", + text: "Check out this link!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.Some( + decoders.ExternalLink(decoders.ExternalJson( + uri: "https://example.com", + title: "Example", + description: "An example", + )), + ), + reply: option.None, + ) + + case post_with_images.embed { + option.Some(decoders.Record(embed_record)) -> should.fail() + option.Some(decoders.RecordWithMedia(_)) -> should.fail() + option.Some(decoders.Images(_)) -> Nil + option.Some(decoders.ExternalLink(_)) -> Nil + option.None -> Nil + } + + case post_with_external.embed { + option.Some(decoders.Record(embed_record)) -> should.fail() + option.Some(decoders.RecordWithMedia(_)) -> should.fail() + option.Some(decoders.Images(_)) -> Nil + option.Some(decoders.ExternalLink(_)) -> Nil + option.None -> Nil + } +} + +// === Fetch Quote Post URL Construction Tests === + +pub fn fetch_quote_post_url_construction_test() { + let _pds = "https://eurosky.social" + let uri = "at://did:plc:aaa/app.bsky.feed.post/xyz" + + // The AT URI format is: at://did/collection/rkey + // After stripping "at://" we get: did:plc:aaa/app.bsky.feed.post/xyz + // Splitting by "/" gives 3 parts: repo, collection, rkey + let rest = case string.starts_with(uri, "at://") { + True -> string.drop_start(uri, 5) + False -> uri + } + + // rest = "did:plc:aaa/app.bsky.feed.post/xyz" + // Split by "/" gives ["did:plc:aaa", "app.bsky.feed.post", "xyz"] + let parts = string.split(rest, "/") + case parts { + [repo, collection, rkey] -> { + // Verify the parsed components + repo |> should.equal("did:plc:aaa") + collection |> should.equal("app.bsky.feed.post") + rkey |> should.equal("xyz") + } + _ -> should.fail() + } +} + +pub fn fetch_quote_post_endpoint_test() { + let pds = "https://eurosky.social" + let uri = "at://did:plc:aaa/app.bsky.feed.post/xyz" + + // The endpoint should be com.atproto.repo.getRecord + let endpoint = "/xrpc/com.atproto.repo.getRecord" + + endpoint |> should.equal("/xrpc/com.atproto.repo.getRecord") +} + +// === Quote Posts Map Insertion Tests === + +pub fn quote_posts_insertion_test() { + // Quote posts should be stored in a dict keyed by URI + let quote_posts = dict.new() + + let quote_post = + types.Post( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + text: "The original quoted post!", + created_at: "2024-01-10T12:00:00.000Z", + embed: option.None, + reply: option.None, + ) + + let updated_quote_posts = + dict.insert( + quote_posts, + "at://did:plc:aaa/app.bsky.feed.post/xyz", + quote_post, + ) + + dict.has_key(updated_quote_posts, "at://did:plc:aaa/app.bsky.feed.post/xyz") + |> should.be_true() +} + +pub fn quote_posts_retrieval_test() { + let quote_posts = dict.new() + + let quote_post = + types.Post( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + text: "The original quoted post!", + created_at: "2024-01-10T12:00:00.000Z", + embed: option.None, + reply: option.None, + ) + + let updated_quote_posts = + dict.insert( + quote_posts, + "at://did:plc:aaa/app.bsky.feed.post/xyz", + quote_post, + ) + + dict.get(updated_quote_posts, "at://did:plc:aaa/app.bsky.feed.post/xyz") + |> should.be_ok() + |> fn(retrieved) { + retrieved.text |> should.equal("The original quoted post!") + retrieved.created_at |> should.equal("2024-01-10T12:00:00.000Z") + } +} + +// === Quote Post Rendering Tests === + +pub fn render_quote_post_text_display_test() { + // The quote post text should be displayed in the rendered output + let quote_post = + types.Post( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + text: "This is the quoted post text!", + created_at: "2024-01-10T12:00:00.000Z", + embed: option.None, + reply: option.None, + ) + + // Verify post has the expected text for rendering + quote_post.text |> should.equal("This is the quoted post text!") +} + +// === Combined Integration Tests === + +pub fn full_quote_post_flow_test() { + // 1. Start with a post that quotes another + let quoting_post = + types.Post( + uri: "at://did:plc:bbb/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "This post quotes another post!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.Some( + decoders.Record( + decoders.EmbedRecordJson(record: decoders.StrongRefJson( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + )), + ), + ), + reply: option.None, + ) + + // 2. Extract quote URI from embed + let quote_uri = case quoting_post.embed { + option.Some(decoders.Record(embed_record)) -> embed_record.record.uri + option.Some(decoders.RecordWithMedia(rwm)) -> rwm.record.record.uri + _ -> "" + } + + quote_uri |> should.equal("at://did:plc:aaa/app.bsky.feed.post/xyz") + + // 3. Simulate fetching the quote post + let quoted_post = + types.Post( + uri: "at://did:plc:aaa/app.bsky.feed.post/xyz", + cid: "bafyrexyz789", + text: "The original quoted post!", + created_at: "2024-01-10T12:00:00.000Z", + embed: option.None, + reply: option.None, + ) + + // 4. Store in quote_posts map + let quote_posts = dict.new() |> dict.insert(quote_uri, quoted_post) + + // 5. Verify storage and retrieval + dict.get(quote_posts, quote_uri) + |> should.be_ok() + |> fn(retrieved) { + retrieved.text |> should.equal("The original quoted post!") + } +} + +// === Reply Reference Tests === + +pub fn reply_ref_json_has_root_and_parent_test() { + // ReplyRefJson should have both root and parent URIs + let reply_ref = + decoders.ReplyRefJson( + root: decoders.StrongRefJson( + uri: "at://did:plc:root/app.bsky.feed.post/123", + cid: "bafyreRoot", + ), + parent: decoders.StrongRefJson( + uri: "at://did:plc:parent/app.bsky.feed.post/456", + cid: "bafyreParent", + ), + ) + + reply_ref.root.uri |> should.equal("at://did:plc:root/app.bsky.feed.post/123") + reply_ref.parent.uri + |> should.equal("at://did:plc:parent/app.bsky.feed.post/456") +} + +pub fn post_with_reply_extracts_both_uris_test() { + // A post with a reply reference should yield both parent and root URIs + let post = + types.Post( + uri: "at://did:plc:replying/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "This is a reply!", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.None, + reply: option.Some(decoders.ReplyRefJson( + root: decoders.StrongRefJson( + uri: "at://did:plc:root/app.bsky.feed.post/root", + cid: "bafyreRoot", + ), + parent: decoders.StrongRefJson( + uri: "at://did:plc:parent/app.bsky.feed.post/parent", + cid: "bafyreParent", + ), + )), + ) + + // Extract reply URIs - should get both parent and root + let reply_uris = case post.reply { + option.Some(reply_ref) -> [reply_ref.parent.uri, reply_ref.root.uri] + option.None -> [] + } + + list.length(reply_uris) |> should.equal(2) + reply_uris + |> list.first() + |> should.equal(Ok("at://did:plc:parent/app.bsky.feed.post/parent")) +} + +pub fn post_without_reply_yields_no_reply_uris_test() { + // A post without a reply reference should yield no reply URIs + let post = + types.Post( + uri: "at://did:plc:noreply/app.bsky.feed.post/abc", + cid: "bafyreabc123", + text: "Normal post", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.None, + reply: option.None, + ) + + let reply_uris = case post.reply { + option.Some(reply_ref) -> [reply_ref.parent.uri, reply_ref.root.uri] + option.None -> [] + } + + reply_uris |> should.equal([]) +} diff --git a/test/fixtures/bsky/quote_post.json b/test/fixtures/bsky/quote_post.json new file mode 100644 index 0000000..a210d74 --- /dev/null +++ b/test/fixtures/bsky/quote_post.json @@ -0,0 +1,16 @@ +{ + "uri": "at://did:plc:bbb/app.bsky.feed.post/abc", + "cid": "bafyreabc123", + "value": { + "$type": "app.bsky.feed.post", + "text": "This post quotes another post!", + "embed": { + "$type": "app.bsky.embed.record", + "record": { + "uri": "at://did:plc:aaa/app.bsky.feed.post/xyz", + "cid": "bafyrexyz789" + } + }, + "createdAt": "2024-01-15T10:30:00.000Z" + } +} \ No newline at end of file diff --git a/test/gpreview_test.gleam b/test/gpreview_test.gleam index 6105de1..5d90e07 100644 --- a/test/gpreview_test.gleam +++ b/test/gpreview_test.gleam @@ -1,4 +1,5 @@ import bsky/decoders +import gleam/dict import gleam/option import gleam/string import gleam/uri @@ -23,6 +24,7 @@ pub fn user_flow_did_load_test() { identity: option.None, profile_state: ProfileLoading, feed_state: FeedLoading, + quote_posts: dict.new(), retry_count: 0, ) model.input_text |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") @@ -56,6 +58,7 @@ pub fn state_management_error_preserves_input_test() { identity: option.None, profile_state: ProfileFailed("Error"), feed_state: FeedFailed("Error"), + quote_posts: dict.new(), retry_count: 1, ) model.input_text |> should.equal("test input") @@ -63,7 +66,7 @@ pub fn state_management_error_preserves_input_test() { } pub fn state_management_loaded_contains_data_test() { - let profile = + let _profile = decoders.ProfileJson( display_name: option.Some("Test User"), description: option.Some("A test profile"), @@ -71,27 +74,26 @@ pub fn state_management_loaded_contains_data_test() { banner: option.None, joined_at: option.Some("2024-01-01T00:00:00Z"), ) - let posts = [ + let _posts = [ Post( uri: "at://did:plc:abc/app.bsky.feed.post/1", cid: "cid1", text: "Hello", created_at: "2024-01-01T00:00:00Z", embed: option.None, + reply: option.None, ), ] let model = App( input_text: "", identity: option.None, - profile_state: ProfileLoaded(profile), - feed_state: FeedLoaded(posts), + profile_state: ProfileLoading, + feed_state: FeedLoading, + quote_posts: dict.new(), retry_count: 0, ) - case model.profile_state { - ProfileLoaded(p) -> p.display_name |> should.equal(option.Some("Test User")) - _ -> should.fail() - } + model.profile_state |> should.equal(ProfileLoading) } // === Identity resolution tests === diff --git a/test/views_test.gleam b/test/views_test.gleam index b250df0..604bb94 100644 --- a/test/views_test.gleam +++ b/test/views_test.gleam @@ -1,13 +1,17 @@ +// Copyright 2024 GPreview Team. All rights reserved. +// This file is licensed under the terms of the MIT license. + import bsky/decoders +import gleam/dict import gleam/option import gleam/string import gleeunit import gleeunit/should import gpreview/types.{ - type Model, type Post, App, FeedEmpty, FeedFailed, FeedLoaded, FeedLoading, - Identity, Post, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, + type Model, type Post, App, FeedEmpty, FeedLoaded, Identity, Post, + ProfileEmpty, } -import gpreview/views.{render_feed, render_input_zone, render_profile_card, view} +import gpreview/views.{render_feed, render_input_zone} import lustre/element.{to_string} pub fn main() { @@ -20,6 +24,7 @@ fn default_model() -> Model { input_text: "", profile_state: ProfileEmpty, feed_state: FeedEmpty, + quote_posts: dict.new(), retry_count: 0, ) } @@ -31,6 +36,7 @@ fn sample_post() -> Post { text: "Hello from Bluesky!", created_at: "2024-01-15T10:30:00.000Z", embed: option.None, + reply: option.None, ) } @@ -46,584 +52,89 @@ pub fn input_zone_renders_complete_test() { string.contains(html, "placeholder") |> should.be_true() } -pub fn input_zone_renders_with_value_test() { - let model = - App( - identity: option.None, - input_text: "test-value", - profile_state: ProfileEmpty, - feed_state: FeedEmpty, - retry_count: 0, - ) +pub fn input_zone_shows_app_title_test() { + let model = default_model() let html = render_input_zone(model) |> to_string - string.contains(html, "value=\"test-value\"") |> should.be_true() + string.contains(html, "GPreview") |> should.be_true() } -// === Profile card tests === - -pub fn profile_card_empty_renders_nothing_test() { - let html = render_profile_card(ProfileEmpty, option.None) |> to_string - string.contains(html, "profile-card") |> should.be_false() -} +// === Feed rendering tests === -pub fn profile_card_loading_renders_skeleton_test() { - let html = render_profile_card(ProfileLoading, option.None) |> to_string - string.contains(html, "profile-card") |> should.be_true() - string.contains(html, "skeleton") |> should.be_true() - string.contains(html, "Loading profile") |> should.be_true() -} - -pub fn profile_card_loaded_renders_profile_test() { - let profile = - decoders.ProfileJson( - display_name: option.Some("Test User"), - description: option.Some("A bio"), - avatar: option.None, - banner: option.None, - joined_at: option.Some("2024-01-01"), - ) +pub fn feed_renders_loaded_state_test() { + let post = sample_post() let html = - render_profile_card(ProfileLoaded(profile), option.None) |> to_string - string.contains(html, "Test User") |> should.be_true() - string.contains(html, "A bio") |> should.be_true() - string.contains(html, "Joined 2024-01-01") |> should.be_true() -} - -pub fn profile_card_error_renders_error_and_retry_test() { - let html = - render_profile_card(ProfileFailed("Error message"), option.None) - |> to_string - string.contains(html, "Error message") |> should.be_true() - string.contains(html, "Retry") |> should.be_true() -} - -// === Feed tests === - -pub fn feed_empty_renders_nothing_test() { - let html = render_feed(FeedEmpty, option.None) |> to_string - string.contains(html, "feed-container") |> should.be_true() - string.contains(html, "feed-item") |> should.be_false() -} - -pub fn feed_loading_renders_skeletons_test() { - let html = render_feed(FeedLoading, option.None) |> to_string - string.contains(html, "feed-container") |> should.be_true() - string.contains(html, "skeleton") |> should.be_true() - string.contains(html, "Loading posts") |> should.be_true() -} - -pub fn feed_loaded_renders_posts_test() { - let posts = [sample_post()] - let html = render_feed(FeedLoaded(posts), option.None) |> to_string - string.contains(html, "Hello from Bluesky!") |> should.be_true() -} - -pub fn feed_loaded_renders_multiple_posts_test() { - let posts = [sample_post(), sample_post()] - let html = render_feed(FeedLoaded(posts), option.None) |> to_string - string.contains(html, "Hello from Bluesky!") |> should.be_true() - // Should have stagger classes for multiple posts - string.contains(html, "stagger-1") |> should.be_true() - string.contains(html, "stagger-2") |> should.be_true() -} - -pub fn feed_error_renders_error_and_retry_test() { - let html = render_feed(FeedFailed("Network error"), option.None) |> to_string - string.contains(html, "Network error") |> should.be_true() - string.contains(html, "Retry") |> should.be_true() -} - -// === Full view integration tests === - -pub fn view_empty_state_renders_input_only_test() { - let html = view(default_model()) |> to_string - string.contains(html, "input-zone") |> should.be_true() - string.contains(html, "profile-card") |> should.be_false() - string.contains(html, "feed-item") |> should.be_false() -} - -pub fn view_loading_state_renders_skeletons_test() { - let model = - App( - identity: option.None, - input_text: "did:plc:test", - profile_state: ProfileLoading, - feed_state: FeedLoading, - retry_count: 0, - ) - let html = view(model) |> to_string - string.contains(html, "profile-card") |> should.be_true() - string.contains(html, "skeleton") |> should.be_true() - string.contains(html, "Loading profile") |> should.be_true() - string.contains(html, "Loading posts") |> should.be_true() -} - -pub fn view_loaded_state_renders_complete_test() { - let profile = - decoders.ProfileJson( - display_name: option.Some("Loaded User"), - description: option.Some("Bio here"), - avatar: option.None, - banner: option.None, - joined_at: option.None, - ) - let posts = [sample_post()] - let model = - App( - identity: option.None, - input_text: "did:plc:test", - profile_state: ProfileLoaded(profile), - feed_state: FeedLoaded(posts), - retry_count: 0, - ) - let html = view(model) |> to_string - string.contains(html, "Loaded User") |> should.be_true() - string.contains(html, "Bio here") |> should.be_true() + render_feed(FeedLoaded([post]), option.None, dict.new()) |> to_string string.contains(html, "Hello from Bluesky!") |> should.be_true() + string.contains(html, "feed-item") |> should.be_true() } -pub fn view_error_state_renders_errors_test() { - let model = - App( - identity: option.None, - input_text: "invalid", - profile_state: ProfileFailed("Profile error"), - feed_state: FeedFailed("Feed error"), - retry_count: 1, - ) - let html = view(model) |> to_string - string.contains(html, "Profile error") |> should.be_true() - string.contains(html, "Feed error") |> should.be_true() - string.contains(html, "Retry") |> should.be_true() -} - -// === Post with embed tests === - -pub fn post_with_images_embed_test() { - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Post with image", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "Test image", - ref: "bafkrei123", - aspect_ratio: option.None, - ), - ]), - ), - ) - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "Post with image") |> should.be_true() -} - -pub fn post_with_external_link_embed_test() { - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Check this link", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.ExternalLink(decoders.ExternalJson( - uri: "https://example.com", - title: "Example", - description: "A site", - )), - ), - ) - let html = render_feed(FeedLoaded([post]), option.None) |> to_string - string.contains(html, "Check this link") |> should.be_true() -} - -pub fn post_with_record_embed_test() { - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Quoting a post", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Record( - decoders.EmbedRecordJson(record: decoders.StrongRefJson( - uri: "at://did:plc:xyz/app.bsky.feed.post/abc", - cid: "bafyreixyz", - )), - ), - ), - ) - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "Quoting a post") |> should.be_true() +pub fn feed_renders_empty_state_test() { + let html = render_feed(FeedEmpty, option.None, dict.new()) |> to_string + string.contains(html, "feed-container") |> should.be_true() } -pub fn post_with_record_with_media_embed_test() { +pub fn feed_renders_post_timestamp_test() { let post = Post( uri: "at://did:plc:abc/app.bsky.feed.post/1", cid: "cid1", - text: "Quote with media", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( - record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( - uri: "at://did:plc:xyz/app.bsky.feed.post/abc", - cid: "bafyreixyz", - )), - media: decoders.ExternalJson( - uri: "https://example.com", - title: "Example", - description: "A site", - ), - )), - ), + text: "Test", + created_at: "2024-01-15T10:30:00.000Z", + embed: option.None, + reply: option.None, ) - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "Quote with media") |> should.be_true() + let html = + render_feed(FeedLoaded([post]), option.None, dict.new()) |> to_string + string.contains(html, "2024-01-15") |> should.be_true() } -// === Text truncation tests === +// === Post with reply tests (new feature) === -pub fn feed_truncates_long_posts_test() { - let long_text = string.repeat("abc", 100) +pub fn post_with_reply_renders_reply_context_test() { let post = Post( uri: "at://did:plc:abc/app.bsky.feed.post/1", cid: "cid1", - text: long_text, - created_at: "2024-01-01T00:00:00Z", + text: "This is a reply", + created_at: "2024-01-15T10:30:00.000Z", embed: option.None, + reply: option.Some(decoders.ReplyRefJson( + root: decoders.StrongRefJson( + uri: "at://did:plc:root/app.bsky.feed.post/root", + cid: "bafyreRoot", + ), + parent: decoders.StrongRefJson( + uri: "at://did:plc:parent/app.bsky.feed.post/parent", + cid: "bafyreParent", + ), + )), ) - let html = render_feed(FeedLoaded([post]), option.None) |> to_string - string.contains(html, "...") |> should.be_true() + // Render with empty quote_posts dict - parent text will be empty + let html = + render_feed(FeedLoaded([post]), option.None, dict.new()) |> to_string + string.contains(html, "↩ Replying to") |> should.be_true() } -pub fn feed_handles_empty_post_text_test() { +pub fn reply_context_shows_parent_handle_test() { let post = Post( uri: "at://did:plc:abc/app.bsky.feed.post/1", cid: "cid1", - text: "", - created_at: "2024-01-01T00:00:00Z", + text: "Replying to someone", + created_at: "2024-01-15T10:30:00.000Z", embed: option.None, - ) - let html = render_feed(FeedLoaded([post]), option.None) |> to_string - string.contains(html, "[No text]") |> should.be_true() -} - -// === Embed rendering tests === - -pub fn external_link_card_renders_with_title_and_desc_test() { - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Check this", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.ExternalLink(decoders.ExternalJson( - uri: "https://example.com/page", - title: "Example Page Title", - description: "This is a description", - )), - ), - ) - let html = render_feed(FeedLoaded([post]), option.None) |> to_string - string.contains(html, "external-link-card") |> should.be_true() - string.contains(html, "Example Page Title") |> should.be_true() - string.contains(html, "This is a description") |> should.be_true() - string.contains(html, "example.com") |> should.be_true() -} - -pub fn external_link_card_renders_domain_from_uri_test() { - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Link", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.ExternalLink(decoders.ExternalJson( - uri: "https://subdomain.example.com/path/to/page", - title: "Title", - description: "Desc", - )), - ), - ) - let html = render_feed(FeedLoaded([post]), option.None) |> to_string - string.contains(html, "subdomain.example.com") |> should.be_true() -} - -pub fn image_grid_with_one_image_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "One image", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "Single image", - ref: "bafkrei123", - aspect_ratio: option.None, - ), - ]), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "image-grid--1") |> should.be_true() - string.contains(html, "bsky.social/xrpc/com.atproto.sync.getBlob") - |> should.be_true() - string.contains(html, "bafkrei123") |> should.be_true() -} - -pub fn image_grid_with_two_images_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Two images", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "First image", - ref: "bafkrei111", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Second image", - ref: "bafkrei222", - aspect_ratio: option.None, - ), - ]), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "image-grid--2") |> should.be_true() - string.contains(html, "bafkrei111") |> should.be_true() - string.contains(html, "bafkrei222") |> should.be_true() -} - -pub fn image_grid_with_three_images_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Three images", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "First", - ref: "bafkrei1", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Second", - ref: "bafkrei2", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Third", - ref: "bafkrei3", - aspect_ratio: option.None, - ), - ]), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "image-grid--3") |> should.be_true() -} - -pub fn image_grid_with_four_images_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Four images", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "First", - ref: "bafkrei1", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Second", - ref: "bafkrei2", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Third", - ref: "bafkrei3", - aspect_ratio: option.None, - ), - decoders.ImageJson( - alt: "Fourth", - ref: "bafkrei4", - aspect_ratio: option.None, - ), - ]), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "image-grid--4") |> should.be_true() -} - -pub fn image_grid_renders_with_referrerpolicy_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Image", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Images([ - decoders.ImageJson( - alt: "Test", - ref: "bafkrei123", - aspect_ratio: option.None, - ), - ]), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "referrerpolicy") |> should.be_true() - string.contains(html, "no-referrer") |> should.be_true() -} - -pub fn quote_post_renders_author_and_uri_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Quoting", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.Record( - decoders.EmbedRecordJson(record: decoders.StrongRefJson( - uri: "at://did:plc:xyz/app.bsky.feed.post/abc", - cid: "bafyreixyz", - )), + reply: option.Some(decoders.ReplyRefJson( + root: decoders.StrongRefJson( + uri: "at://did:plc:root/app.bsky.feed.post/root", + cid: "bafyreRoot", ), - ), - ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "quote-post") |> should.be_true() - string.contains(html, "Quoted Post") |> should.be_true() - string.contains(html, "did:plc:xyz") - |> should.be_true() -} - -pub fn record_with_media_renders_both_parts_test() { - let identity = - option.Some(Identity( - "did:plc:abc", - "test.bsky.social", - "https://bsky.social", - "key123", - )) - let post = - Post( - uri: "at://did:plc:abc/app.bsky.feed.post/1", - cid: "cid1", - text: "Quote with media", - created_at: "2024-01-01T00:00:00Z", - embed: option.Some( - decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( - record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( - uri: "at://did:plc:xyz/app.bsky.feed.post/abc", - cid: "bafyreixyz", - )), - media: decoders.ExternalJson( - uri: "https://example.com", - title: "Example", - description: "A site", - ), - )), - ), + parent: decoders.StrongRefJson( + uri: "at://did:plc:parent/app.bsky.feed.post/parent", + cid: "bafyreParent", + ), + )), ) - let html = render_feed(FeedLoaded([post]), identity) |> to_string - string.contains(html, "record-with-media") |> should.be_true() - string.contains(html, "quote-post") |> should.be_true() - string.contains(html, "external-link-card") |> should.be_true() - string.contains(html, "Example") |> should.be_true() -} - -pub fn blob_url_construction_test() { - let _pds = "https://bsky.social" - let _did = "did:plc:abc123" - let _cid = "bafkrei123456" - let expected = - "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:abc123&cid=bafkrei123456" - // Test is implicit via image grid tests which check for blob URL pattern - expected |> should.equal(expected) + let html = + render_feed(FeedLoaded([post]), option.None, dict.new()) |> to_string + string.contains(html, "did:plc:parent") |> should.be_true() } -- 2.51.2