From a350c34b132ca430e6fdcc20d6e39405f99c8113 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 19 Apr 2026 11:54:22 +0200 Subject: [PATCH] refactor: extract profile and feed into modular components - Extract profile state + view to gpreview/profile/ (profile_view.gleam, profile_actions.gleam) - Extract feed state + view to gpreview/feed/ (feed_view.gleam, feed_actions.gleam) - Simplify views.gleam to delegate to profile_view and feed_view - All types re-exported from gpreview/types for compatibility - Tests pass (102 tests) --- src/gpreview.gleam | 14 +- src/gpreview/feed/feed_actions.gleam | 32 ++ src/gpreview/feed/feed_view.gleam | 278 +++++++++++ src/gpreview/profile/profile_actions.gleam | 69 +++ src/gpreview/profile/profile_view.gleam | 247 ++++++++++ src/gpreview/search.gleam | 36 ++ src/gpreview/views.gleam | 516 +-------------------- 7 files changed, 683 insertions(+), 509 deletions(-) create mode 100644 src/gpreview/feed/feed_actions.gleam create mode 100644 src/gpreview/feed/feed_view.gleam create mode 100644 src/gpreview/profile/profile_actions.gleam create mode 100644 src/gpreview/profile/profile_view.gleam create mode 100644 src/gpreview/search.gleam diff --git a/src/gpreview.gleam b/src/gpreview.gleam index ae55da6..2b581c0 100644 --- a/src/gpreview.gleam +++ b/src/gpreview.gleam @@ -1,6 +1,8 @@ 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, @@ -53,8 +55,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { IdentityResolved(Ok(identity)) -> #( App(..model, identity: option.Some(identity)), effect.batch([ - effects.fetch_profile(identity.pds, identity.did), - effects.fetch_posts(identity.pds, identity.did, 10), + profile_actions.fetch_profile(identity.pds, identity.did), + feed_actions.fetch_posts(identity.pds, identity.did, 10), ]), ) IdentityResolved(Error(_e)) -> #( @@ -99,7 +101,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case model.input_text { "" -> #(model, effect.none()) input -> - case effects.is_did(input) { + case profile_actions.is_did(input) { True -> #( App( ..model, @@ -107,7 +109,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { feed_state: FeedLoading, retry_count: 0, ), - effects.resolve_identity(input), + profile_actions.resolve_identity(input), ) False -> #( App( @@ -116,7 +118,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { feed_state: FeedLoading, retry_count: 0, ), - effects.resolve_identity(input), + profile_actions.resolve_identity(input), ) } } @@ -131,7 +133,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { feed_state: FeedLoading, retry_count: model.retry_count + 1, ), - effects.resolve_identity(input), + profile_actions.resolve_identity(input), ) } } diff --git a/src/gpreview/feed/feed_actions.gleam b/src/gpreview/feed/feed_actions.gleam new file mode 100644 index 0000000..23a1da3 --- /dev/null +++ b/src/gpreview/feed/feed_actions.gleam @@ -0,0 +1,32 @@ +import bsky/decoders +import gleam/int +import gleam/uri +import gpreview/types +import lustre/effect.{type Effect} +import rsvp + +pub fn fetch_posts(pds: String, did: String, limit: Int) -> Effect(types.Msg) { + let query = + uri.query_to_string([ + #("repo", did), + #("collection", "app.bsky.feed.post"), + #("limit", int.to_string(limit)), + ]) + + rsvp.get( + pds <> "/xrpc/com.atproto.repo.listRecords?" <> query, + rsvp.expect_json( + decoders.decode_list_records_response(decoders.decode_post()), + fn(result) { + case result { + Ok(response) -> { + types.PostsFetched(Ok(response.records)) + } + Error(e) -> { + types.PostsFetched(Error(e)) + } + } + }, + ), + ) +} diff --git a/src/gpreview/feed/feed_view.gleam b/src/gpreview/feed/feed_view.gleam new file mode 100644 index 0000000..a6e3fda --- /dev/null +++ b/src/gpreview/feed/feed_view.gleam @@ -0,0 +1,278 @@ +import bsky/decoders +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import gpreview/types +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +// Re-export types from gpreview/types +pub type Post = + types.Post + +pub type FeedState = + types.FeedState + +// -- View -- + +pub fn render_feed( + feed_state: FeedState, + identity: Option(types.Identity), +) -> 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.FeedFailed(error) -> feed_error_state(error) + } +} + +fn feed_loading_skeleton() -> Element(types.Msg) { + html.div([attribute.attribute("class", "feed-container")], [ + html.div([attribute.attribute("class", "feed-loading-header")], [ + html.div([attribute.attribute("class", "loading-text")], [ + html.text("Loading posts..."), + ]), + ]), + feed_item_skeleton(1), + feed_item_skeleton(2), + feed_item_skeleton(3), + feed_item_skeleton(4), + feed_item_skeleton(5), + ]) +} + +fn feed_item_skeleton(index: Int) -> Element(types.Msg) { + let stagger_class = "stagger-" <> int.to_string(index) + html.div( + [ + attribute.attribute( + "class", + "feed-item feed-item--loading " <> stagger_class, + ), + ], + [ + html.div([attribute.attribute("class", "feed-item__header")], [ + html.div([attribute.attribute("class", "skeleton-circle")], []), + html.div([attribute.attribute("class", "feed-item__header-info")], [ + html.div( + [attribute.attribute("class", "skeleton-line skeleton-line-md")], + [], + ), + html.div( + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], + [], + ), + ]), + ]), + html.div([attribute.attribute("class", "feed-item__body")], [ + html.div([attribute.attribute("class", "skeleton-line")], []), + html.div([attribute.attribute("class", "skeleton-line")], []), + ]), + html.div([attribute.attribute("class", "feed-item__footer")], [ + html.div( + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], + [], + ), + ]), + ], + ) +} + +fn render_feed_loaded( + posts: List(Post), + identity: Option(types.Identity), +) -> Element(types.Msg) { + let elements = + posts + |> list.index_map(fn(post, index) { + render_feed_item(post, index, identity) + }) + + html.div([attribute.attribute("class", "feed-container")], elements) +} + +fn render_external_link_card( + external: decoders.ExternalJson, +) -> Element(types.Msg) { + html.a( + [ + attribute.attribute("href", external.uri), + attribute.attribute("class", "external-link-card"), + attribute.attribute("target", "_blank"), + attribute.attribute("rel", "noopener noreferrer"), + ], + [ + case external.description { + "" -> element.none() + _ -> element.none() + }, + html.div([attribute.attribute("class", "external-link-card__content")], [ + html.h4([attribute.attribute("class", "external-link-card__title")], [ + html.text(external.title), + ]), + html.p([attribute.attribute("class", "external-link-card__desc")], [ + html.text(external.description), + ]), + html.span([attribute.attribute("class", "external-link-card__domain")], [ + html.text(extract_domain(external.uri)), + ]), + ]), + ], + ) +} + +fn extract_domain(uri: String) -> String { + case string.split(uri, "://") { + [_, rest, ..] -> { + case string.split(rest, "/") { + [domain, ..] -> domain + _ -> uri + } + } + _ -> uri + } +} + +fn render_image_grid( + images: List(decoders.ImageJson), + pds: String, + did: String, +) -> Element(types.Msg) { + let count = list.length(images) + let grid_class = "image-grid image-grid--" <> int.to_string(count) + let image_elements = + images + |> list.map(fn(img) { + let src = + pds + <> "/xrpc/com.atproto.sync.getBlob?did=" + <> did + <> "&cid=" + <> img.ref + html.img([ + attribute.attribute("src", src), + attribute.attribute("alt", img.alt), + attribute.attribute("referrerpolicy", "no-referrer"), + ]) + }) + html.div([attribute.attribute("class", grid_class)], image_elements) +} + +fn render_quote_post(record: decoders.EmbedRecordJson) -> Element(types.Msg) { + 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)), + ]), + ]), + ]) +} + +fn render_record_with_media( + rwm: decoders.EmbedRecordWithMedia, +) -> Element(types.Msg) { + html.div([attribute.attribute("class", "record-with-media")], [ + render_quote_post(rwm.record), + render_external_link_card(rwm.media), + ]) +} + +fn extract_handle_from_uri(uri: String) -> String { + case string.split(uri, "/") { + [_, _, did_or_handle, ..] -> did_or_handle + _ -> uri + } +} + +fn render_post_embed( + embed: Option(decoders.Embed), + identity: Option(types.Identity), +) -> 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) + _, _ -> html.div([], []) + } +} + +fn render_feed_item( + post: Post, + index: Int, + identity: Option(types.Identity), +) -> 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) + } + html.div( + [ + attribute.attribute("class", "feed-item " <> stagger_class), + ], + [ + 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), + ]), + html.div([attribute.attribute("class", "feed-item__footer")], [ + html.span([attribute.attribute("class", "feed-item__timestamp")], [ + html.text(format_timestamp(post.created_at)), + ]), + ]), + ], + ) +} + +fn truncate_text(text: String, max_length: Int) -> String { + case string.length(text) > max_length { + True -> string.slice(text, 0, max_length - 3) <> "..." + False -> text + } +} + +fn feed_error_state(error: String) -> Element(types.Msg) { + html.div( + [ + attribute.attribute("class", "feed-container feed-item--error"), + attribute.attribute("role", "alert"), + ], + [ + html.div( + [ + attribute.attribute("class", "error-message"), + attribute.attribute("aria-live", "polite"), + ], + [html.text(error)], + ), + html.button( + [ + event.on_click(types.RetryFetch), + attribute.attribute("class", "btn-retry"), + attribute.attribute("aria-label", "Retry loading feed"), + ], + [html.text("Retry")], + ), + ], + ) +} + +fn format_timestamp(iso_timestamp: String) -> String { + case string.split(iso_timestamp, "T") { + [date_part, ..] -> date_part + _ -> iso_timestamp + } +} diff --git a/src/gpreview/profile/profile_actions.gleam b/src/gpreview/profile/profile_actions.gleam new file mode 100644 index 0000000..7d3ad80 --- /dev/null +++ b/src/gpreview/profile/profile_actions.gleam @@ -0,0 +1,69 @@ +import bsky/decoders +import gleam/dynamic/decode.{type Decoder, field, string, success} +import gleam/string +import gleam/uri +import gpreview/types +import lustre/effect.{type Effect} +import rsvp + +const slingshot_base = "https://slingshot.microcosm.blue" + +pub fn is_did(identifier: String) -> Bool { + string.starts_with(identifier, "did:") +} + +pub fn resolve_identity(identifier: String) -> Effect(types.Msg) { + rsvp.get( + slingshot_base + <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" + <> uri.percent_encode(identifier), + rsvp.expect_json(decoders.decode_mini_doc(), fn(result) { + case result { + Ok(mini_doc) -> { + types.IdentityResolved( + Ok(types.Identity( + did: mini_doc.did, + handle: mini_doc.handle, + pds: mini_doc.pds, + signing_key: mini_doc.signing_key, + )), + ) + } + Error(_e) -> { + types.IdentityResolved(Error("Failed to resolve identity")) + } + } + }), + ) +} + +pub fn fetch_profile(pds_host: String, did: String) -> Effect(types.Msg) { + rsvp.get( + pds_host + <> "/xrpc/com.atproto.repo.getRecord?" + <> construct_profile_uri(did), + rsvp.expect_json( + decode_get_record_response(decoders.decode_profile()), + types.ProfileFetched, + ), + ) +} + +pub fn construct_profile_uri(did: String) -> String { + get_record_query(did, "app.bsky.actor.profile", "self") +} + +fn get_record_query(did: String, collection: String, rkey: String) -> String { + uri.query_to_string([ + #("repo", did), + #("collection", collection), + #("rkey", rkey), + ]) +} + +fn decode_get_record_response(decoder: Decoder(a)) -> Decoder(a) { + use _uri <- field("uri", string) + use _cid <- field("cid", string) + use value <- field("value", decoder) + success(value) +} diff --git a/src/gpreview/profile/profile_view.gleam b/src/gpreview/profile/profile_view.gleam new file mode 100644 index 0000000..7fe2663 --- /dev/null +++ b/src/gpreview/profile/profile_view.gleam @@ -0,0 +1,247 @@ +import bsky/decoders.{type ProfileJson} +import gleam/option.{type Option, None, Some} +import gleam/string +import gpreview/types +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +// Re-export types from gpreview/types +pub type ProfileState = + types.ProfileState + +// -- View -- + +pub fn render_profile_card( + profile_state: ProfileState, + identity: Option(types.Identity), +) -> Element(types.Msg) { + case profile_state { + types.ProfileEmpty -> html.div([], []) + types.ProfileLoading -> profile_loading_skeleton() + types.ProfileLoaded(profile) -> render_full_profile_card(profile, identity) + types.ProfileFailed(error) -> profile_error_state(error) + } +} + +fn profile_loading_skeleton() -> Element(types.Msg) { + html.div( + [ + attribute.attribute("class", "profile-card profile-card--loading"), + attribute.attribute("role", "status"), + attribute.attribute("aria-label", "Loading profile"), + ], + [ + html.div( + [ + attribute.attribute( + "class", + "profile-banner profile-banner--skeleton", + ), + ], + [], + ), + html.div([attribute.attribute("class", "profile-card__content")], [ + html.div( + [ + attribute.attribute( + "class", + "profile-avatar profile-avatar--skeleton", + ), + ], + [], + ), + html.div([attribute.attribute("class", "profile-card__info")], [ + html.div( + [attribute.attribute("class", "skeleton-line skeleton-line-md")], + [], + ), + html.div( + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], + [], + ), + html.div([attribute.attribute("class", "loading-text")], [ + html.text("Loading profile..."), + ]), + ]), + ]), + ], + ) +} + +fn render_full_profile_card( + profile: ProfileJson, + identity: Option(types.Identity), +) -> Element(types.Msg) { + html.div([attribute.attribute("class", "profile-card")], [ + render_profile_banner(profile.banner, identity), + html.div([attribute.attribute("class", "profile-card__content")], [ + render_profile_avatar(profile.avatar, identity), + html.div([attribute.attribute("class", "profile-card__info")], [ + render_profile_display_name(profile.display_name), + render_profile_bio(profile.description), + render_profile_joined(profile.joined_at), + ]), + ]), + ]) +} + +fn blob_to_url( + identity: Option(types.Identity), + blob_cid: String, +) -> Option(String) { + case identity { + Some(id) -> + Some( + id.pds + <> "/xrpc/com.atproto.sync.getBlob?did=" + <> id.did + <> "&cid=" + <> blob_cid, + ) + None -> None + } +} + +fn render_profile_banner( + banner: Option(String), + identity: Option(types.Identity), +) -> Element(types.Msg) { + case banner { + Some(banner_cid) -> { + let src = blob_to_url(identity, banner_cid) + case src { + Some(url) -> + html.div([attribute.attribute("class", "profile-banner")], [ + html.img([ + attribute.attribute("src", url), + attribute.attribute("alt", "Profile banner"), + attribute.attribute("class", "profile-banner__image"), + attribute.attribute("referrerpolicy", "no-referrer"), + ]), + ]) + None -> + html.div( + [ + attribute.attribute( + "class", + "profile-banner profile-banner--empty", + ), + ], + [], + ) + } + } + None -> + html.div( + [attribute.attribute("class", "profile-banner profile-banner--empty")], + [], + ) + } +} + +fn render_profile_avatar( + avatar: Option(String), + identity: Option(types.Identity), +) -> Element(types.Msg) { + case avatar { + Some(avatar_cid) -> { + let src = blob_to_url(identity, avatar_cid) + case src { + Some(url) -> + html.img([ + attribute.attribute("src", url), + attribute.attribute("alt", "Profile avatar"), + attribute.attribute("class", "profile-avatar"), + attribute.attribute("referrerpolicy", "no-referrer"), + ]) + None -> + html.div( + [ + attribute.attribute( + "class", + "profile-avatar profile-avatar--fallback", + ), + ], + [], + ) + } + } + None -> + html.div( + [ + attribute.attribute( + "class", + "profile-avatar profile-avatar--fallback", + ), + ], + [], + ) + } +} + +fn render_profile_display_name( + display_name: Option(String), +) -> Element(types.Msg) { + case display_name { + Some(name) -> + html.h2([attribute.attribute("class", "profile-name")], [html.text(name)]) + None -> + html.h2( + [attribute.attribute("class", "profile-name profile-name--empty")], + [html.text("Unknown")], + ) + } +} + +fn render_profile_bio(description: Option(String)) -> Element(types.Msg) { + case description { + Some(bio) -> + html.p([attribute.attribute("class", "profile-bio")], [html.text(bio)]) + None -> element.none() + } +} + +fn render_profile_joined(joined_at: Option(String)) -> Element(types.Msg) { + case joined_at { + Some(date) -> + html.p([attribute.attribute("class", "profile-joined")], [ + html.text("Joined " <> format_timestamp(date)), + ]) + None -> element.none() + } +} + +fn profile_error_state(error: String) -> Element(types.Msg) { + html.div( + [ + attribute.attribute("class", "profile-card profile-card--error"), + attribute.attribute("role", "alert"), + ], + [ + html.div( + [ + attribute.attribute("class", "error-message"), + attribute.attribute("aria-live", "polite"), + ], + [html.text(error)], + ), + html.button( + [ + event.on_click(types.RetryFetch), + attribute.attribute("class", "btn-retry"), + attribute.attribute("aria-label", "Retry loading profile"), + ], + [html.text("Retry")], + ), + ], + ) +} + +fn format_timestamp(iso_timestamp: String) -> String { + case string.split(iso_timestamp, "T") { + [date_part, ..] -> date_part + _ -> iso_timestamp + } +} diff --git a/src/gpreview/search.gleam b/src/gpreview/search.gleam new file mode 100644 index 0000000..453a189 --- /dev/null +++ b/src/gpreview/search.gleam @@ -0,0 +1,36 @@ +import gpreview/types.{type Msg} +import lustre/attribute +import lustre/element +import lustre/element/html +import lustre/event + +// -- View -- +// Renders the search form, dispatching parent messages directly + +pub fn view(input: String) -> element.Element(Msg) { + html.form( + [ + event.on_submit(fn(_) { types.SubmitInput }) |> event.prevent_default, + attribute.attribute("class", "input-zone__row"), + ], + [ + html.input([ + event.on_input(types.InputChanged), + attribute.type_("text"), + attribute.value(input), + attribute.attribute( + "placeholder", + "Enter Bluesky DID or handle (e.g., did:plc:... or username.bsky.social)", + ), + attribute.attribute("class", "input-field"), + ]), + html.button( + [ + attribute.type_("submit"), + attribute.attribute("class", "btn-show"), + ], + [html.text("Show")], + ), + ], + ) +} diff --git a/src/gpreview/views.gleam b/src/gpreview/views.gleam index cfc5451..cf4d5a3 100644 --- a/src/gpreview/views.gleam +++ b/src/gpreview/views.gleam @@ -1,533 +1,43 @@ -import bsky/decoders.{type ProfileJson} -import gleam/int -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/string +import gleam/option.{type Option} +import gpreview/feed/feed_view +import gpreview/profile/profile_view +import gpreview/search import gpreview/types.{ - type FeedState, type Identity, type Model, type Msg, type Post, - type ProfileState, FeedEmpty, FeedFailed, FeedLoaded, FeedLoading, - InputChanged, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, - RetryFetch, SubmitInput, + type FeedState, type Identity, type Model, type Msg, type ProfileState, } import lustre/attribute import lustre/element.{type Element} import lustre/element/html -import lustre/event +// Main view - delegates to profile and feed modules pub fn view(model: Model) -> Element(Msg) { html.div([attribute.attribute("class", "app-shell")], [ render_input_zone(model), html.div([attribute.attribute("class", "main-content")], [ - render_profile_card(model.profile_state, model.identity), - render_feed(model.feed_state, model.identity), + profile_view.render_profile_card(model.profile_state, model.identity), + feed_view.render_feed(model.feed_state, model.identity), ]), ]) } +// Search input zone pub fn render_input_zone(model: Model) -> Element(Msg) { html.div([attribute.attribute("class", "input-zone")], [ - html.form( - [ - event.on_submit(fn(_) { SubmitInput }) - |> event.prevent_default, - attribute.attribute("class", "input-zone__row"), - ], - [ - html.input([ - event.on_input(InputChanged), - attribute.type_("text"), - attribute.value(model.input_text), - attribute.attribute( - "placeholder", - "Enter Bluesky DID or handle (e.g., did:plc:... or username.bsky.social)", - ), - attribute.attribute("class", "input-field"), - ]), - html.button( - [ - attribute.type_("submit"), - attribute.attribute("class", "btn-show"), - ], - [html.text("Show")], - ), - ], - ), + search.view(model.input_text), ]) } +// Wrapper functions for backward compatibility with tests pub fn render_profile_card( profile_state: ProfileState, identity: Option(Identity), ) -> Element(Msg) { - case profile_state { - ProfileEmpty -> html.div([], []) - ProfileLoading -> profile_loading_skeleton() - ProfileLoaded(profile) -> render_full_profile_card(profile, identity) - ProfileFailed(error) -> profile_error_state(error) - } -} - -fn profile_loading_skeleton() -> Element(Msg) { - html.div( - [ - attribute.attribute("class", "profile-card profile-card--loading"), - attribute.attribute("role", "status"), - attribute.attribute("aria-label", "Loading profile"), - ], - [ - html.div( - [ - attribute.attribute( - "class", - "profile-banner profile-banner--skeleton", - ), - ], - [], - ), - html.div([attribute.attribute("class", "profile-card__content")], [ - html.div( - [ - attribute.attribute( - "class", - "profile-avatar profile-avatar--skeleton", - ), - ], - [], - ), - html.div([attribute.attribute("class", "profile-card__info")], [ - html.div( - [attribute.attribute("class", "skeleton-line skeleton-line-md")], - [], - ), - html.div( - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], - [], - ), - html.div([attribute.attribute("class", "loading-text")], [ - html.text("Loading profile..."), - ]), - ]), - ]), - ], - ) -} - -fn render_full_profile_card( - profile: ProfileJson, - identity: Option(Identity), -) -> Element(Msg) { - html.div([attribute.attribute("class", "profile-card")], [ - render_profile_banner(profile.banner, identity), - html.div([attribute.attribute("class", "profile-card__content")], [ - render_profile_avatar(profile.avatar, identity), - html.div([attribute.attribute("class", "profile-card__info")], [ - render_profile_display_name(profile.display_name), - render_profile_bio(profile.description), - render_profile_joined(profile.joined_at), - ]), - ]), - ]) -} - -fn blob_to_url(identity: Option(Identity), blob_cid: String) -> Option(String) { - case identity { - Some(id) -> - Some( - id.pds - <> "/xrpc/com.atproto.sync.getBlob?did=" - <> id.did - <> "&cid=" - <> blob_cid, - ) - None -> None - } -} - -fn render_profile_banner( - banner: Option(String), - identity: Option(Identity), -) -> Element(Msg) { - case banner { - Some(banner_cid) -> { - let src = blob_to_url(identity, banner_cid) - case src { - Some(url) -> - html.div([attribute.attribute("class", "profile-banner")], [ - html.img([ - attribute.attribute("src", url), - attribute.attribute("alt", "Profile banner"), - attribute.attribute("class", "profile-banner__image"), - attribute.attribute("referrerpolicy", "no-referrer"), - ]), - ]) - None -> - html.div( - [ - attribute.attribute( - "class", - "profile-banner profile-banner--empty", - ), - ], - [], - ) - } - } - None -> - html.div( - [attribute.attribute("class", "profile-banner profile-banner--empty")], - [], - ) - } -} - -fn render_profile_avatar( - avatar: Option(String), - identity: Option(Identity), -) -> Element(Msg) { - case avatar { - Some(avatar_cid) -> { - let src = blob_to_url(identity, avatar_cid) - case src { - Some(url) -> - html.img([ - attribute.attribute("src", url), - attribute.attribute("alt", "Profile avatar"), - attribute.attribute("class", "profile-avatar"), - attribute.attribute("referrerpolicy", "no-referrer"), - ]) - None -> - html.div( - [ - attribute.attribute( - "class", - "profile-avatar profile-avatar--fallback", - ), - ], - [], - ) - } - } - None -> - html.div( - [ - attribute.attribute( - "class", - "profile-avatar profile-avatar--fallback", - ), - ], - [], - ) - } -} - -fn render_profile_display_name(display_name: Option(String)) -> Element(Msg) { - case display_name { - Some(name) -> - html.h2([attribute.attribute("class", "profile-name")], [html.text(name)]) - None -> - html.h2( - [attribute.attribute("class", "profile-name profile-name--empty")], - [html.text("Unknown")], - ) - } -} - -fn render_profile_bio(description: Option(String)) -> Element(Msg) { - case description { - Some(bio) -> - html.p([attribute.attribute("class", "profile-bio")], [html.text(bio)]) - None -> element.none() - } -} - -fn render_profile_joined(joined_at: Option(String)) -> Element(Msg) { - case joined_at { - Some(date) -> - html.p([attribute.attribute("class", "profile-joined")], [ - html.text("Joined " <> format_timestamp(date)), - ]) - None -> element.none() - } -} - -fn profile_error_state(error: String) -> Element(Msg) { - html.div( - [ - attribute.attribute("class", "profile-card profile-card--error"), - attribute.attribute("role", "alert"), - ], - [ - html.div( - [ - attribute.attribute("class", "error-message"), - attribute.attribute("aria-live", "polite"), - ], - [html.text(error)], - ), - html.button( - [ - event.on_click(RetryFetch), - attribute.attribute("class", "btn-retry"), - attribute.attribute("aria-label", "Retry loading profile"), - ], - [html.text("Retry")], - ), - ], - ) -} - -fn format_timestamp(iso_timestamp: String) -> String { - case string.split(iso_timestamp, "T") { - [date_part, ..] -> date_part - _ -> iso_timestamp - } + profile_view.render_profile_card(profile_state, identity) } pub fn render_feed( feed_state: FeedState, identity: Option(Identity), ) -> Element(Msg) { - case feed_state { - FeedEmpty -> html.div([attribute.attribute("class", "feed-container")], []) - FeedLoading -> feed_loading_skeleton() - FeedLoaded(posts) -> render_feed_loaded(posts, identity) - FeedFailed(error) -> feed_error_state(error) - } -} - -fn feed_loading_skeleton() -> Element(Msg) { - html.div([attribute.attribute("class", "feed-container")], [ - html.div([attribute.attribute("class", "feed-loading-header")], [ - html.div([attribute.attribute("class", "loading-text")], [ - html.text("Loading posts..."), - ]), - ]), - feed_item_skeleton(1), - feed_item_skeleton(2), - feed_item_skeleton(3), - feed_item_skeleton(4), - feed_item_skeleton(5), - ]) -} - -fn feed_item_skeleton(index: Int) -> Element(Msg) { - let stagger_class = "stagger-" <> int.to_string(index) - html.div( - [ - attribute.attribute( - "class", - "feed-item feed-item--loading " <> stagger_class, - ), - ], - [ - html.div([attribute.attribute("class", "feed-item__header")], [ - html.div([attribute.attribute("class", "skeleton-circle")], []), - html.div([attribute.attribute("class", "feed-item__header-info")], [ - html.div( - [attribute.attribute("class", "skeleton-line skeleton-line-md")], - [], - ), - html.div( - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], - [], - ), - ]), - ]), - html.div([attribute.attribute("class", "feed-item__body")], [ - html.div([attribute.attribute("class", "skeleton-line")], []), - html.div([attribute.attribute("class", "skeleton-line")], []), - ]), - html.div([attribute.attribute("class", "feed-item__footer")], [ - html.div( - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], - [], - ), - ]), - ], - ) -} - -fn render_feed_loaded( - posts: List(Post), - identity: Option(Identity), -) -> Element(Msg) { - let elements = - posts - |> list.index_map(fn(post, index) { - render_feed_item(post, index, identity) - }) - - html.div([attribute.attribute("class", "feed-container")], elements) -} - -fn render_external_link_card(external: decoders.ExternalJson) -> Element(Msg) { - html.a( - [ - attribute.attribute("href", external.uri), - attribute.attribute("class", "external-link-card"), - attribute.attribute("target", "_blank"), - attribute.attribute("rel", "noopener noreferrer"), - ], - [ - case external.description { - "" -> element.none() - _ -> element.none() - }, - html.div([attribute.attribute("class", "external-link-card__content")], [ - html.h4([attribute.attribute("class", "external-link-card__title")], [ - html.text(external.title), - ]), - html.p([attribute.attribute("class", "external-link-card__desc")], [ - html.text(external.description), - ]), - html.span([attribute.attribute("class", "external-link-card__domain")], [ - html.text(extract_domain(external.uri)), - ]), - ]), - ], - ) -} - -fn extract_domain(uri: String) -> String { - case string.split(uri, "://") { - [_, rest, ..] -> { - case string.split(rest, "/") { - [domain, ..] -> domain - _ -> uri - } - } - _ -> uri - } -} - -fn render_image_grid( - images: List(decoders.ImageJson), - pds: String, - did: String, -) -> Element(Msg) { - let count = list.length(images) - let grid_class = "image-grid image-grid--" <> int.to_string(count) - let image_elements = - images - |> list.map(fn(img) { - let src = - pds - <> "/xrpc/com.atproto.sync.getBlob?did=" - <> did - <> "&cid=" - <> img.ref - html.img([ - attribute.attribute("src", src), - attribute.attribute("alt", img.alt), - attribute.attribute("referrerpolicy", "no-referrer"), - ]) - }) - html.div([attribute.attribute("class", grid_class)], image_elements) -} - -fn render_quote_post(record: decoders.EmbedRecordJson) -> Element(Msg) { - 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)), - ]), - ]), - ]) -} - -fn render_record_with_media(rwm: decoders.EmbedRecordWithMedia) -> Element(Msg) { - html.div([attribute.attribute("class", "record-with-media")], [ - render_quote_post(rwm.record), - render_external_link_card(rwm.media), - ]) -} - -fn extract_handle_from_uri(uri: String) -> String { - // Extract handle from AT URI like at://did:plc:xxx/app.bsky.feed.post/yyy - // or from a handle-based URI - case string.split(uri, "/") { - [_, _, did_or_handle, ..] -> did_or_handle - _ -> uri - } -} - -fn render_post_embed( - embed: Option(decoders.Embed), - identity: Option(Identity), -) -> Element(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) - _, _ -> html.div([], []) - } -} - -fn render_feed_item( - post: Post, - index: Int, - identity: Option(Identity), -) -> Element(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) - } - html.div( - [ - attribute.attribute("class", "feed-item " <> stagger_class), - ], - [ - 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), - ]), - html.div([attribute.attribute("class", "feed-item__footer")], [ - html.span([attribute.attribute("class", "feed-item__timestamp")], [ - html.text(format_timestamp(post.created_at)), - ]), - ]), - ], - ) -} - -fn truncate_text(text: String, max_length: Int) -> String { - case string.length(text) > max_length { - True -> string.slice(text, 0, max_length - 3) <> "..." - False -> text - } -} - -fn feed_error_state(error: String) -> Element(Msg) { - html.div( - [ - attribute.attribute("class", "feed-container feed-item--error"), - attribute.attribute("role", "alert"), - ], - [ - html.div( - [ - attribute.attribute("class", "error-message"), - attribute.attribute("aria-live", "polite"), - ], - [html.text(error)], - ), - html.button( - [ - event.on_click(RetryFetch), - attribute.attribute("class", "btn-retry"), - attribute.attribute("aria-label", "Retry loading feed"), - ], - [html.text("Retry")], - ), - ], - ) + feed_view.render_feed(feed_state, identity) } -- 2.51.2