small constellation + pds based little profile viewer karitham.tngl.io/gpreview?user=karitham.dev
gleam bsky-profile
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)

+683 -509
+8 -6
src/gpreview.gleam
··· 1 1 import gleam/list 2 2 import gleam/option 3 3 import gpreview/effects 4 + import gpreview/feed/feed_actions 5 + import gpreview/profile/profile_actions 4 6 import gpreview/types.{ 5 7 type Model, type Msg, App, FeedFailed, FeedLoaded, FeedLoading, 6 8 IdentityResolved, InputChanged, Post, PostsFetched, ProfileFailed, ··· 53 55 IdentityResolved(Ok(identity)) -> #( 54 56 App(..model, identity: option.Some(identity)), 55 57 effect.batch([ 56 - effects.fetch_profile(identity.pds, identity.did), 57 - effects.fetch_posts(identity.pds, identity.did, 10), 58 + profile_actions.fetch_profile(identity.pds, identity.did), 59 + feed_actions.fetch_posts(identity.pds, identity.did, 10), 58 60 ]), 59 61 ) 60 62 IdentityResolved(Error(_e)) -> #( ··· 99 101 case model.input_text { 100 102 "" -> #(model, effect.none()) 101 103 input -> 102 - case effects.is_did(input) { 104 + case profile_actions.is_did(input) { 103 105 True -> #( 104 106 App( 105 107 ..model, ··· 107 109 feed_state: FeedLoading, 108 110 retry_count: 0, 109 111 ), 110 - effects.resolve_identity(input), 112 + profile_actions.resolve_identity(input), 111 113 ) 112 114 False -> #( 113 115 App( ··· 116 118 feed_state: FeedLoading, 117 119 retry_count: 0, 118 120 ), 119 - effects.resolve_identity(input), 121 + profile_actions.resolve_identity(input), 120 122 ) 121 123 } 122 124 } ··· 131 133 feed_state: FeedLoading, 132 134 retry_count: model.retry_count + 1, 133 135 ), 134 - effects.resolve_identity(input), 136 + profile_actions.resolve_identity(input), 135 137 ) 136 138 } 137 139 }
+32
src/gpreview/feed/feed_actions.gleam
··· 1 + import bsky/decoders 2 + import gleam/int 3 + import gleam/uri 4 + import gpreview/types 5 + import lustre/effect.{type Effect} 6 + import rsvp 7 + 8 + pub fn fetch_posts(pds: String, did: String, limit: Int) -> Effect(types.Msg) { 9 + let query = 10 + uri.query_to_string([ 11 + #("repo", did), 12 + #("collection", "app.bsky.feed.post"), 13 + #("limit", int.to_string(limit)), 14 + ]) 15 + 16 + rsvp.get( 17 + pds <> "/xrpc/com.atproto.repo.listRecords?" <> query, 18 + rsvp.expect_json( 19 + decoders.decode_list_records_response(decoders.decode_post()), 20 + fn(result) { 21 + case result { 22 + Ok(response) -> { 23 + types.PostsFetched(Ok(response.records)) 24 + } 25 + Error(e) -> { 26 + types.PostsFetched(Error(e)) 27 + } 28 + } 29 + }, 30 + ), 31 + ) 32 + }
+278
src/gpreview/feed/feed_view.gleam
··· 1 + import bsky/decoders 2 + import gleam/int 3 + import gleam/list 4 + import gleam/option.{type Option, None, Some} 5 + import gleam/string 6 + import gpreview/types 7 + import lustre/attribute 8 + import lustre/element.{type Element} 9 + import lustre/element/html 10 + import lustre/event 11 + 12 + // Re-export types from gpreview/types 13 + pub type Post = 14 + types.Post 15 + 16 + pub type FeedState = 17 + types.FeedState 18 + 19 + // -- View -- 20 + 21 + pub fn render_feed( 22 + feed_state: FeedState, 23 + identity: Option(types.Identity), 24 + ) -> Element(types.Msg) { 25 + case feed_state { 26 + types.FeedEmpty -> 27 + html.div([attribute.attribute("class", "feed-container")], []) 28 + types.FeedLoading -> feed_loading_skeleton() 29 + types.FeedLoaded(posts) -> render_feed_loaded(posts, identity) 30 + types.FeedFailed(error) -> feed_error_state(error) 31 + } 32 + } 33 + 34 + fn feed_loading_skeleton() -> Element(types.Msg) { 35 + html.div([attribute.attribute("class", "feed-container")], [ 36 + html.div([attribute.attribute("class", "feed-loading-header")], [ 37 + html.div([attribute.attribute("class", "loading-text")], [ 38 + html.text("Loading posts..."), 39 + ]), 40 + ]), 41 + feed_item_skeleton(1), 42 + feed_item_skeleton(2), 43 + feed_item_skeleton(3), 44 + feed_item_skeleton(4), 45 + feed_item_skeleton(5), 46 + ]) 47 + } 48 + 49 + fn feed_item_skeleton(index: Int) -> Element(types.Msg) { 50 + let stagger_class = "stagger-" <> int.to_string(index) 51 + html.div( 52 + [ 53 + attribute.attribute( 54 + "class", 55 + "feed-item feed-item--loading " <> stagger_class, 56 + ), 57 + ], 58 + [ 59 + html.div([attribute.attribute("class", "feed-item__header")], [ 60 + html.div([attribute.attribute("class", "skeleton-circle")], []), 61 + html.div([attribute.attribute("class", "feed-item__header-info")], [ 62 + html.div( 63 + [attribute.attribute("class", "skeleton-line skeleton-line-md")], 64 + [], 65 + ), 66 + html.div( 67 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 68 + [], 69 + ), 70 + ]), 71 + ]), 72 + html.div([attribute.attribute("class", "feed-item__body")], [ 73 + html.div([attribute.attribute("class", "skeleton-line")], []), 74 + html.div([attribute.attribute("class", "skeleton-line")], []), 75 + ]), 76 + html.div([attribute.attribute("class", "feed-item__footer")], [ 77 + html.div( 78 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 79 + [], 80 + ), 81 + ]), 82 + ], 83 + ) 84 + } 85 + 86 + fn render_feed_loaded( 87 + posts: List(Post), 88 + identity: Option(types.Identity), 89 + ) -> Element(types.Msg) { 90 + let elements = 91 + posts 92 + |> list.index_map(fn(post, index) { 93 + render_feed_item(post, index, identity) 94 + }) 95 + 96 + html.div([attribute.attribute("class", "feed-container")], elements) 97 + } 98 + 99 + fn render_external_link_card( 100 + external: decoders.ExternalJson, 101 + ) -> Element(types.Msg) { 102 + html.a( 103 + [ 104 + attribute.attribute("href", external.uri), 105 + attribute.attribute("class", "external-link-card"), 106 + attribute.attribute("target", "_blank"), 107 + attribute.attribute("rel", "noopener noreferrer"), 108 + ], 109 + [ 110 + case external.description { 111 + "" -> element.none() 112 + _ -> element.none() 113 + }, 114 + html.div([attribute.attribute("class", "external-link-card__content")], [ 115 + html.h4([attribute.attribute("class", "external-link-card__title")], [ 116 + html.text(external.title), 117 + ]), 118 + html.p([attribute.attribute("class", "external-link-card__desc")], [ 119 + html.text(external.description), 120 + ]), 121 + html.span([attribute.attribute("class", "external-link-card__domain")], [ 122 + html.text(extract_domain(external.uri)), 123 + ]), 124 + ]), 125 + ], 126 + ) 127 + } 128 + 129 + fn extract_domain(uri: String) -> String { 130 + case string.split(uri, "://") { 131 + [_, rest, ..] -> { 132 + case string.split(rest, "/") { 133 + [domain, ..] -> domain 134 + _ -> uri 135 + } 136 + } 137 + _ -> uri 138 + } 139 + } 140 + 141 + fn render_image_grid( 142 + images: List(decoders.ImageJson), 143 + pds: String, 144 + did: String, 145 + ) -> Element(types.Msg) { 146 + let count = list.length(images) 147 + let grid_class = "image-grid image-grid--" <> int.to_string(count) 148 + let image_elements = 149 + images 150 + |> list.map(fn(img) { 151 + let src = 152 + pds 153 + <> "/xrpc/com.atproto.sync.getBlob?did=" 154 + <> did 155 + <> "&cid=" 156 + <> img.ref 157 + html.img([ 158 + attribute.attribute("src", src), 159 + attribute.attribute("alt", img.alt), 160 + attribute.attribute("referrerpolicy", "no-referrer"), 161 + ]) 162 + }) 163 + html.div([attribute.attribute("class", grid_class)], image_elements) 164 + } 165 + 166 + fn render_quote_post(record: decoders.EmbedRecordJson) -> Element(types.Msg) { 167 + html.div([attribute.attribute("class", "quote-post")], [ 168 + html.div([attribute.attribute("class", "quote-post__header")], [ 169 + html.span([attribute.attribute("class", "quote-post__author")], [ 170 + html.text("Quoted Post"), 171 + ]), 172 + html.span([attribute.attribute("class", "quote-post__handle")], [ 173 + html.text(extract_handle_from_uri(record.record.uri)), 174 + ]), 175 + ]), 176 + ]) 177 + } 178 + 179 + fn render_record_with_media( 180 + rwm: decoders.EmbedRecordWithMedia, 181 + ) -> Element(types.Msg) { 182 + html.div([attribute.attribute("class", "record-with-media")], [ 183 + render_quote_post(rwm.record), 184 + render_external_link_card(rwm.media), 185 + ]) 186 + } 187 + 188 + fn extract_handle_from_uri(uri: String) -> String { 189 + case string.split(uri, "/") { 190 + [_, _, did_or_handle, ..] -> did_or_handle 191 + _ -> uri 192 + } 193 + } 194 + 195 + fn render_post_embed( 196 + embed: Option(decoders.Embed), 197 + identity: Option(types.Identity), 198 + ) -> Element(types.Msg) { 199 + case embed, identity { 200 + Some(decoders.Images(images)), Some(identity) -> 201 + render_image_grid(images, identity.pds, identity.did) 202 + Some(decoders.ExternalLink(external)), _ -> 203 + render_external_link_card(external) 204 + Some(decoders.Record(record)), _ -> render_quote_post(record) 205 + Some(decoders.RecordWithMedia(rwm)), _ -> render_record_with_media(rwm) 206 + _, _ -> html.div([], []) 207 + } 208 + } 209 + 210 + fn render_feed_item( 211 + post: Post, 212 + index: Int, 213 + identity: Option(types.Identity), 214 + ) -> Element(types.Msg) { 215 + let stagger_class = "stagger-" <> int.to_string({ index % 5 } + 1) 216 + let display_text = case string.is_empty(post.text) { 217 + True -> "[No text]" 218 + False -> truncate_text(post.text, 280) 219 + } 220 + html.div( 221 + [ 222 + attribute.attribute("class", "feed-item " <> stagger_class), 223 + ], 224 + [ 225 + html.div([attribute.attribute("class", "feed-item__content")], [ 226 + html.p([attribute.attribute("class", "feed-item__text")], [ 227 + html.text(display_text), 228 + ]), 229 + render_post_embed(post.embed, identity), 230 + ]), 231 + html.div([attribute.attribute("class", "feed-item__footer")], [ 232 + html.span([attribute.attribute("class", "feed-item__timestamp")], [ 233 + html.text(format_timestamp(post.created_at)), 234 + ]), 235 + ]), 236 + ], 237 + ) 238 + } 239 + 240 + fn truncate_text(text: String, max_length: Int) -> String { 241 + case string.length(text) > max_length { 242 + True -> string.slice(text, 0, max_length - 3) <> "..." 243 + False -> text 244 + } 245 + } 246 + 247 + fn feed_error_state(error: String) -> Element(types.Msg) { 248 + html.div( 249 + [ 250 + attribute.attribute("class", "feed-container feed-item--error"), 251 + attribute.attribute("role", "alert"), 252 + ], 253 + [ 254 + html.div( 255 + [ 256 + attribute.attribute("class", "error-message"), 257 + attribute.attribute("aria-live", "polite"), 258 + ], 259 + [html.text(error)], 260 + ), 261 + html.button( 262 + [ 263 + event.on_click(types.RetryFetch), 264 + attribute.attribute("class", "btn-retry"), 265 + attribute.attribute("aria-label", "Retry loading feed"), 266 + ], 267 + [html.text("Retry")], 268 + ), 269 + ], 270 + ) 271 + } 272 + 273 + fn format_timestamp(iso_timestamp: String) -> String { 274 + case string.split(iso_timestamp, "T") { 275 + [date_part, ..] -> date_part 276 + _ -> iso_timestamp 277 + } 278 + }
+69
src/gpreview/profile/profile_actions.gleam
··· 1 + import bsky/decoders 2 + import gleam/dynamic/decode.{type Decoder, field, string, success} 3 + import gleam/string 4 + import gleam/uri 5 + import gpreview/types 6 + import lustre/effect.{type Effect} 7 + import rsvp 8 + 9 + const slingshot_base = "https://slingshot.microcosm.blue" 10 + 11 + pub fn is_did(identifier: String) -> Bool { 12 + string.starts_with(identifier, "did:") 13 + } 14 + 15 + pub fn resolve_identity(identifier: String) -> Effect(types.Msg) { 16 + rsvp.get( 17 + slingshot_base 18 + <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 19 + <> uri.percent_encode(identifier), 20 + rsvp.expect_json(decoders.decode_mini_doc(), fn(result) { 21 + case result { 22 + Ok(mini_doc) -> { 23 + types.IdentityResolved( 24 + Ok(types.Identity( 25 + did: mini_doc.did, 26 + handle: mini_doc.handle, 27 + pds: mini_doc.pds, 28 + signing_key: mini_doc.signing_key, 29 + )), 30 + ) 31 + } 32 + Error(_e) -> { 33 + types.IdentityResolved(Error("Failed to resolve identity")) 34 + } 35 + } 36 + }), 37 + ) 38 + } 39 + 40 + pub fn fetch_profile(pds_host: String, did: String) -> Effect(types.Msg) { 41 + rsvp.get( 42 + pds_host 43 + <> "/xrpc/com.atproto.repo.getRecord?" 44 + <> construct_profile_uri(did), 45 + rsvp.expect_json( 46 + decode_get_record_response(decoders.decode_profile()), 47 + types.ProfileFetched, 48 + ), 49 + ) 50 + } 51 + 52 + pub fn construct_profile_uri(did: String) -> String { 53 + get_record_query(did, "app.bsky.actor.profile", "self") 54 + } 55 + 56 + fn get_record_query(did: String, collection: String, rkey: String) -> String { 57 + uri.query_to_string([ 58 + #("repo", did), 59 + #("collection", collection), 60 + #("rkey", rkey), 61 + ]) 62 + } 63 + 64 + fn decode_get_record_response(decoder: Decoder(a)) -> Decoder(a) { 65 + use _uri <- field("uri", string) 66 + use _cid <- field("cid", string) 67 + use value <- field("value", decoder) 68 + success(value) 69 + }
+247
src/gpreview/profile/profile_view.gleam
··· 1 + import bsky/decoders.{type ProfileJson} 2 + import gleam/option.{type Option, None, Some} 3 + import gleam/string 4 + import gpreview/types 5 + import lustre/attribute 6 + import lustre/element.{type Element} 7 + import lustre/element/html 8 + import lustre/event 9 + 10 + // Re-export types from gpreview/types 11 + pub type ProfileState = 12 + types.ProfileState 13 + 14 + // -- View -- 15 + 16 + pub fn render_profile_card( 17 + profile_state: ProfileState, 18 + identity: Option(types.Identity), 19 + ) -> Element(types.Msg) { 20 + case profile_state { 21 + types.ProfileEmpty -> html.div([], []) 22 + types.ProfileLoading -> profile_loading_skeleton() 23 + types.ProfileLoaded(profile) -> render_full_profile_card(profile, identity) 24 + types.ProfileFailed(error) -> profile_error_state(error) 25 + } 26 + } 27 + 28 + fn profile_loading_skeleton() -> Element(types.Msg) { 29 + html.div( 30 + [ 31 + attribute.attribute("class", "profile-card profile-card--loading"), 32 + attribute.attribute("role", "status"), 33 + attribute.attribute("aria-label", "Loading profile"), 34 + ], 35 + [ 36 + html.div( 37 + [ 38 + attribute.attribute( 39 + "class", 40 + "profile-banner profile-banner--skeleton", 41 + ), 42 + ], 43 + [], 44 + ), 45 + html.div([attribute.attribute("class", "profile-card__content")], [ 46 + html.div( 47 + [ 48 + attribute.attribute( 49 + "class", 50 + "profile-avatar profile-avatar--skeleton", 51 + ), 52 + ], 53 + [], 54 + ), 55 + html.div([attribute.attribute("class", "profile-card__info")], [ 56 + html.div( 57 + [attribute.attribute("class", "skeleton-line skeleton-line-md")], 58 + [], 59 + ), 60 + html.div( 61 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 62 + [], 63 + ), 64 + html.div([attribute.attribute("class", "loading-text")], [ 65 + html.text("Loading profile..."), 66 + ]), 67 + ]), 68 + ]), 69 + ], 70 + ) 71 + } 72 + 73 + fn render_full_profile_card( 74 + profile: ProfileJson, 75 + identity: Option(types.Identity), 76 + ) -> Element(types.Msg) { 77 + html.div([attribute.attribute("class", "profile-card")], [ 78 + render_profile_banner(profile.banner, identity), 79 + html.div([attribute.attribute("class", "profile-card__content")], [ 80 + render_profile_avatar(profile.avatar, identity), 81 + html.div([attribute.attribute("class", "profile-card__info")], [ 82 + render_profile_display_name(profile.display_name), 83 + render_profile_bio(profile.description), 84 + render_profile_joined(profile.joined_at), 85 + ]), 86 + ]), 87 + ]) 88 + } 89 + 90 + fn blob_to_url( 91 + identity: Option(types.Identity), 92 + blob_cid: String, 93 + ) -> Option(String) { 94 + case identity { 95 + Some(id) -> 96 + Some( 97 + id.pds 98 + <> "/xrpc/com.atproto.sync.getBlob?did=" 99 + <> id.did 100 + <> "&cid=" 101 + <> blob_cid, 102 + ) 103 + None -> None 104 + } 105 + } 106 + 107 + fn render_profile_banner( 108 + banner: Option(String), 109 + identity: Option(types.Identity), 110 + ) -> Element(types.Msg) { 111 + case banner { 112 + Some(banner_cid) -> { 113 + let src = blob_to_url(identity, banner_cid) 114 + case src { 115 + Some(url) -> 116 + html.div([attribute.attribute("class", "profile-banner")], [ 117 + html.img([ 118 + attribute.attribute("src", url), 119 + attribute.attribute("alt", "Profile banner"), 120 + attribute.attribute("class", "profile-banner__image"), 121 + attribute.attribute("referrerpolicy", "no-referrer"), 122 + ]), 123 + ]) 124 + None -> 125 + html.div( 126 + [ 127 + attribute.attribute( 128 + "class", 129 + "profile-banner profile-banner--empty", 130 + ), 131 + ], 132 + [], 133 + ) 134 + } 135 + } 136 + None -> 137 + html.div( 138 + [attribute.attribute("class", "profile-banner profile-banner--empty")], 139 + [], 140 + ) 141 + } 142 + } 143 + 144 + fn render_profile_avatar( 145 + avatar: Option(String), 146 + identity: Option(types.Identity), 147 + ) -> Element(types.Msg) { 148 + case avatar { 149 + Some(avatar_cid) -> { 150 + let src = blob_to_url(identity, avatar_cid) 151 + case src { 152 + Some(url) -> 153 + html.img([ 154 + attribute.attribute("src", url), 155 + attribute.attribute("alt", "Profile avatar"), 156 + attribute.attribute("class", "profile-avatar"), 157 + attribute.attribute("referrerpolicy", "no-referrer"), 158 + ]) 159 + None -> 160 + html.div( 161 + [ 162 + attribute.attribute( 163 + "class", 164 + "profile-avatar profile-avatar--fallback", 165 + ), 166 + ], 167 + [], 168 + ) 169 + } 170 + } 171 + None -> 172 + html.div( 173 + [ 174 + attribute.attribute( 175 + "class", 176 + "profile-avatar profile-avatar--fallback", 177 + ), 178 + ], 179 + [], 180 + ) 181 + } 182 + } 183 + 184 + fn render_profile_display_name( 185 + display_name: Option(String), 186 + ) -> Element(types.Msg) { 187 + case display_name { 188 + Some(name) -> 189 + html.h2([attribute.attribute("class", "profile-name")], [html.text(name)]) 190 + None -> 191 + html.h2( 192 + [attribute.attribute("class", "profile-name profile-name--empty")], 193 + [html.text("Unknown")], 194 + ) 195 + } 196 + } 197 + 198 + fn render_profile_bio(description: Option(String)) -> Element(types.Msg) { 199 + case description { 200 + Some(bio) -> 201 + html.p([attribute.attribute("class", "profile-bio")], [html.text(bio)]) 202 + None -> element.none() 203 + } 204 + } 205 + 206 + fn render_profile_joined(joined_at: Option(String)) -> Element(types.Msg) { 207 + case joined_at { 208 + Some(date) -> 209 + html.p([attribute.attribute("class", "profile-joined")], [ 210 + html.text("Joined " <> format_timestamp(date)), 211 + ]) 212 + None -> element.none() 213 + } 214 + } 215 + 216 + fn profile_error_state(error: String) -> Element(types.Msg) { 217 + html.div( 218 + [ 219 + attribute.attribute("class", "profile-card profile-card--error"), 220 + attribute.attribute("role", "alert"), 221 + ], 222 + [ 223 + html.div( 224 + [ 225 + attribute.attribute("class", "error-message"), 226 + attribute.attribute("aria-live", "polite"), 227 + ], 228 + [html.text(error)], 229 + ), 230 + html.button( 231 + [ 232 + event.on_click(types.RetryFetch), 233 + attribute.attribute("class", "btn-retry"), 234 + attribute.attribute("aria-label", "Retry loading profile"), 235 + ], 236 + [html.text("Retry")], 237 + ), 238 + ], 239 + ) 240 + } 241 + 242 + fn format_timestamp(iso_timestamp: String) -> String { 243 + case string.split(iso_timestamp, "T") { 244 + [date_part, ..] -> date_part 245 + _ -> iso_timestamp 246 + } 247 + }
+36
src/gpreview/search.gleam
··· 1 + import gpreview/types.{type Msg} 2 + import lustre/attribute 3 + import lustre/element 4 + import lustre/element/html 5 + import lustre/event 6 + 7 + // -- View -- 8 + // Renders the search form, dispatching parent messages directly 9 + 10 + pub fn view(input: String) -> element.Element(Msg) { 11 + html.form( 12 + [ 13 + event.on_submit(fn(_) { types.SubmitInput }) |> event.prevent_default, 14 + attribute.attribute("class", "input-zone__row"), 15 + ], 16 + [ 17 + html.input([ 18 + event.on_input(types.InputChanged), 19 + attribute.type_("text"), 20 + attribute.value(input), 21 + attribute.attribute( 22 + "placeholder", 23 + "Enter Bluesky DID or handle (e.g., did:plc:... or username.bsky.social)", 24 + ), 25 + attribute.attribute("class", "input-field"), 26 + ]), 27 + html.button( 28 + [ 29 + attribute.type_("submit"), 30 + attribute.attribute("class", "btn-show"), 31 + ], 32 + [html.text("Show")], 33 + ), 34 + ], 35 + ) 36 + }
+13 -503
src/gpreview/views.gleam
··· 1 - import bsky/decoders.{type ProfileJson} 2 - import gleam/int 3 - import gleam/list 4 - import gleam/option.{type Option, None, Some} 5 - import gleam/string 1 + import gleam/option.{type Option} 2 + import gpreview/feed/feed_view 3 + import gpreview/profile/profile_view 4 + import gpreview/search 6 5 import gpreview/types.{ 7 - type FeedState, type Identity, type Model, type Msg, type Post, 8 - type ProfileState, FeedEmpty, FeedFailed, FeedLoaded, FeedLoading, 9 - InputChanged, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, 10 - RetryFetch, SubmitInput, 6 + type FeedState, type Identity, type Model, type Msg, type ProfileState, 11 7 } 12 8 import lustre/attribute 13 9 import lustre/element.{type Element} 14 10 import lustre/element/html 15 - import lustre/event 16 11 12 + // Main view - delegates to profile and feed modules 17 13 pub fn view(model: Model) -> Element(Msg) { 18 14 html.div([attribute.attribute("class", "app-shell")], [ 19 15 render_input_zone(model), 20 16 html.div([attribute.attribute("class", "main-content")], [ 21 - render_profile_card(model.profile_state, model.identity), 22 - render_feed(model.feed_state, model.identity), 17 + profile_view.render_profile_card(model.profile_state, model.identity), 18 + feed_view.render_feed(model.feed_state, model.identity), 23 19 ]), 24 20 ]) 25 21 } 26 22 23 + // Search input zone 27 24 pub fn render_input_zone(model: Model) -> Element(Msg) { 28 25 html.div([attribute.attribute("class", "input-zone")], [ 29 - html.form( 30 - [ 31 - event.on_submit(fn(_) { SubmitInput }) 32 - |> event.prevent_default, 33 - attribute.attribute("class", "input-zone__row"), 34 - ], 35 - [ 36 - html.input([ 37 - event.on_input(InputChanged), 38 - attribute.type_("text"), 39 - attribute.value(model.input_text), 40 - attribute.attribute( 41 - "placeholder", 42 - "Enter Bluesky DID or handle (e.g., did:plc:... or username.bsky.social)", 43 - ), 44 - attribute.attribute("class", "input-field"), 45 - ]), 46 - html.button( 47 - [ 48 - attribute.type_("submit"), 49 - attribute.attribute("class", "btn-show"), 50 - ], 51 - [html.text("Show")], 52 - ), 53 - ], 54 - ), 26 + search.view(model.input_text), 55 27 ]) 56 28 } 57 29 30 + // Wrapper functions for backward compatibility with tests 58 31 pub fn render_profile_card( 59 32 profile_state: ProfileState, 60 33 identity: Option(Identity), 61 34 ) -> Element(Msg) { 62 - case profile_state { 63 - ProfileEmpty -> html.div([], []) 64 - ProfileLoading -> profile_loading_skeleton() 65 - ProfileLoaded(profile) -> render_full_profile_card(profile, identity) 66 - ProfileFailed(error) -> profile_error_state(error) 67 - } 68 - } 69 - 70 - fn profile_loading_skeleton() -> Element(Msg) { 71 - html.div( 72 - [ 73 - attribute.attribute("class", "profile-card profile-card--loading"), 74 - attribute.attribute("role", "status"), 75 - attribute.attribute("aria-label", "Loading profile"), 76 - ], 77 - [ 78 - html.div( 79 - [ 80 - attribute.attribute( 81 - "class", 82 - "profile-banner profile-banner--skeleton", 83 - ), 84 - ], 85 - [], 86 - ), 87 - html.div([attribute.attribute("class", "profile-card__content")], [ 88 - html.div( 89 - [ 90 - attribute.attribute( 91 - "class", 92 - "profile-avatar profile-avatar--skeleton", 93 - ), 94 - ], 95 - [], 96 - ), 97 - html.div([attribute.attribute("class", "profile-card__info")], [ 98 - html.div( 99 - [attribute.attribute("class", "skeleton-line skeleton-line-md")], 100 - [], 101 - ), 102 - html.div( 103 - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 104 - [], 105 - ), 106 - html.div([attribute.attribute("class", "loading-text")], [ 107 - html.text("Loading profile..."), 108 - ]), 109 - ]), 110 - ]), 111 - ], 112 - ) 113 - } 114 - 115 - fn render_full_profile_card( 116 - profile: ProfileJson, 117 - identity: Option(Identity), 118 - ) -> Element(Msg) { 119 - html.div([attribute.attribute("class", "profile-card")], [ 120 - render_profile_banner(profile.banner, identity), 121 - html.div([attribute.attribute("class", "profile-card__content")], [ 122 - render_profile_avatar(profile.avatar, identity), 123 - html.div([attribute.attribute("class", "profile-card__info")], [ 124 - render_profile_display_name(profile.display_name), 125 - render_profile_bio(profile.description), 126 - render_profile_joined(profile.joined_at), 127 - ]), 128 - ]), 129 - ]) 130 - } 131 - 132 - fn blob_to_url(identity: Option(Identity), blob_cid: String) -> Option(String) { 133 - case identity { 134 - Some(id) -> 135 - Some( 136 - id.pds 137 - <> "/xrpc/com.atproto.sync.getBlob?did=" 138 - <> id.did 139 - <> "&cid=" 140 - <> blob_cid, 141 - ) 142 - None -> None 143 - } 144 - } 145 - 146 - fn render_profile_banner( 147 - banner: Option(String), 148 - identity: Option(Identity), 149 - ) -> Element(Msg) { 150 - case banner { 151 - Some(banner_cid) -> { 152 - let src = blob_to_url(identity, banner_cid) 153 - case src { 154 - Some(url) -> 155 - html.div([attribute.attribute("class", "profile-banner")], [ 156 - html.img([ 157 - attribute.attribute("src", url), 158 - attribute.attribute("alt", "Profile banner"), 159 - attribute.attribute("class", "profile-banner__image"), 160 - attribute.attribute("referrerpolicy", "no-referrer"), 161 - ]), 162 - ]) 163 - None -> 164 - html.div( 165 - [ 166 - attribute.attribute( 167 - "class", 168 - "profile-banner profile-banner--empty", 169 - ), 170 - ], 171 - [], 172 - ) 173 - } 174 - } 175 - None -> 176 - html.div( 177 - [attribute.attribute("class", "profile-banner profile-banner--empty")], 178 - [], 179 - ) 180 - } 181 - } 182 - 183 - fn render_profile_avatar( 184 - avatar: Option(String), 185 - identity: Option(Identity), 186 - ) -> Element(Msg) { 187 - case avatar { 188 - Some(avatar_cid) -> { 189 - let src = blob_to_url(identity, avatar_cid) 190 - case src { 191 - Some(url) -> 192 - html.img([ 193 - attribute.attribute("src", url), 194 - attribute.attribute("alt", "Profile avatar"), 195 - attribute.attribute("class", "profile-avatar"), 196 - attribute.attribute("referrerpolicy", "no-referrer"), 197 - ]) 198 - None -> 199 - html.div( 200 - [ 201 - attribute.attribute( 202 - "class", 203 - "profile-avatar profile-avatar--fallback", 204 - ), 205 - ], 206 - [], 207 - ) 208 - } 209 - } 210 - None -> 211 - html.div( 212 - [ 213 - attribute.attribute( 214 - "class", 215 - "profile-avatar profile-avatar--fallback", 216 - ), 217 - ], 218 - [], 219 - ) 220 - } 221 - } 222 - 223 - fn render_profile_display_name(display_name: Option(String)) -> Element(Msg) { 224 - case display_name { 225 - Some(name) -> 226 - html.h2([attribute.attribute("class", "profile-name")], [html.text(name)]) 227 - None -> 228 - html.h2( 229 - [attribute.attribute("class", "profile-name profile-name--empty")], 230 - [html.text("Unknown")], 231 - ) 232 - } 233 - } 234 - 235 - fn render_profile_bio(description: Option(String)) -> Element(Msg) { 236 - case description { 237 - Some(bio) -> 238 - html.p([attribute.attribute("class", "profile-bio")], [html.text(bio)]) 239 - None -> element.none() 240 - } 241 - } 242 - 243 - fn render_profile_joined(joined_at: Option(String)) -> Element(Msg) { 244 - case joined_at { 245 - Some(date) -> 246 - html.p([attribute.attribute("class", "profile-joined")], [ 247 - html.text("Joined " <> format_timestamp(date)), 248 - ]) 249 - None -> element.none() 250 - } 251 - } 252 - 253 - fn profile_error_state(error: String) -> Element(Msg) { 254 - html.div( 255 - [ 256 - attribute.attribute("class", "profile-card profile-card--error"), 257 - attribute.attribute("role", "alert"), 258 - ], 259 - [ 260 - html.div( 261 - [ 262 - attribute.attribute("class", "error-message"), 263 - attribute.attribute("aria-live", "polite"), 264 - ], 265 - [html.text(error)], 266 - ), 267 - html.button( 268 - [ 269 - event.on_click(RetryFetch), 270 - attribute.attribute("class", "btn-retry"), 271 - attribute.attribute("aria-label", "Retry loading profile"), 272 - ], 273 - [html.text("Retry")], 274 - ), 275 - ], 276 - ) 277 - } 278 - 279 - fn format_timestamp(iso_timestamp: String) -> String { 280 - case string.split(iso_timestamp, "T") { 281 - [date_part, ..] -> date_part 282 - _ -> iso_timestamp 283 - } 35 + profile_view.render_profile_card(profile_state, identity) 284 36 } 285 37 286 38 pub fn render_feed( 287 39 feed_state: FeedState, 288 40 identity: Option(Identity), 289 41 ) -> Element(Msg) { 290 - case feed_state { 291 - FeedEmpty -> html.div([attribute.attribute("class", "feed-container")], []) 292 - FeedLoading -> feed_loading_skeleton() 293 - FeedLoaded(posts) -> render_feed_loaded(posts, identity) 294 - FeedFailed(error) -> feed_error_state(error) 295 - } 296 - } 297 - 298 - fn feed_loading_skeleton() -> Element(Msg) { 299 - html.div([attribute.attribute("class", "feed-container")], [ 300 - html.div([attribute.attribute("class", "feed-loading-header")], [ 301 - html.div([attribute.attribute("class", "loading-text")], [ 302 - html.text("Loading posts..."), 303 - ]), 304 - ]), 305 - feed_item_skeleton(1), 306 - feed_item_skeleton(2), 307 - feed_item_skeleton(3), 308 - feed_item_skeleton(4), 309 - feed_item_skeleton(5), 310 - ]) 311 - } 312 - 313 - fn feed_item_skeleton(index: Int) -> Element(Msg) { 314 - let stagger_class = "stagger-" <> int.to_string(index) 315 - html.div( 316 - [ 317 - attribute.attribute( 318 - "class", 319 - "feed-item feed-item--loading " <> stagger_class, 320 - ), 321 - ], 322 - [ 323 - html.div([attribute.attribute("class", "feed-item__header")], [ 324 - html.div([attribute.attribute("class", "skeleton-circle")], []), 325 - html.div([attribute.attribute("class", "feed-item__header-info")], [ 326 - html.div( 327 - [attribute.attribute("class", "skeleton-line skeleton-line-md")], 328 - [], 329 - ), 330 - html.div( 331 - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 332 - [], 333 - ), 334 - ]), 335 - ]), 336 - html.div([attribute.attribute("class", "feed-item__body")], [ 337 - html.div([attribute.attribute("class", "skeleton-line")], []), 338 - html.div([attribute.attribute("class", "skeleton-line")], []), 339 - ]), 340 - html.div([attribute.attribute("class", "feed-item__footer")], [ 341 - html.div( 342 - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 343 - [], 344 - ), 345 - ]), 346 - ], 347 - ) 348 - } 349 - 350 - fn render_feed_loaded( 351 - posts: List(Post), 352 - identity: Option(Identity), 353 - ) -> Element(Msg) { 354 - let elements = 355 - posts 356 - |> list.index_map(fn(post, index) { 357 - render_feed_item(post, index, identity) 358 - }) 359 - 360 - html.div([attribute.attribute("class", "feed-container")], elements) 361 - } 362 - 363 - fn render_external_link_card(external: decoders.ExternalJson) -> Element(Msg) { 364 - html.a( 365 - [ 366 - attribute.attribute("href", external.uri), 367 - attribute.attribute("class", "external-link-card"), 368 - attribute.attribute("target", "_blank"), 369 - attribute.attribute("rel", "noopener noreferrer"), 370 - ], 371 - [ 372 - case external.description { 373 - "" -> element.none() 374 - _ -> element.none() 375 - }, 376 - html.div([attribute.attribute("class", "external-link-card__content")], [ 377 - html.h4([attribute.attribute("class", "external-link-card__title")], [ 378 - html.text(external.title), 379 - ]), 380 - html.p([attribute.attribute("class", "external-link-card__desc")], [ 381 - html.text(external.description), 382 - ]), 383 - html.span([attribute.attribute("class", "external-link-card__domain")], [ 384 - html.text(extract_domain(external.uri)), 385 - ]), 386 - ]), 387 - ], 388 - ) 389 - } 390 - 391 - fn extract_domain(uri: String) -> String { 392 - case string.split(uri, "://") { 393 - [_, rest, ..] -> { 394 - case string.split(rest, "/") { 395 - [domain, ..] -> domain 396 - _ -> uri 397 - } 398 - } 399 - _ -> uri 400 - } 401 - } 402 - 403 - fn render_image_grid( 404 - images: List(decoders.ImageJson), 405 - pds: String, 406 - did: String, 407 - ) -> Element(Msg) { 408 - let count = list.length(images) 409 - let grid_class = "image-grid image-grid--" <> int.to_string(count) 410 - let image_elements = 411 - images 412 - |> list.map(fn(img) { 413 - let src = 414 - pds 415 - <> "/xrpc/com.atproto.sync.getBlob?did=" 416 - <> did 417 - <> "&cid=" 418 - <> img.ref 419 - html.img([ 420 - attribute.attribute("src", src), 421 - attribute.attribute("alt", img.alt), 422 - attribute.attribute("referrerpolicy", "no-referrer"), 423 - ]) 424 - }) 425 - html.div([attribute.attribute("class", grid_class)], image_elements) 426 - } 427 - 428 - fn render_quote_post(record: decoders.EmbedRecordJson) -> Element(Msg) { 429 - html.div([attribute.attribute("class", "quote-post")], [ 430 - html.div([attribute.attribute("class", "quote-post__header")], [ 431 - html.span([attribute.attribute("class", "quote-post__author")], [ 432 - html.text("Quoted Post"), 433 - ]), 434 - html.span([attribute.attribute("class", "quote-post__handle")], [ 435 - html.text(extract_handle_from_uri(record.record.uri)), 436 - ]), 437 - ]), 438 - ]) 439 - } 440 - 441 - fn render_record_with_media(rwm: decoders.EmbedRecordWithMedia) -> Element(Msg) { 442 - html.div([attribute.attribute("class", "record-with-media")], [ 443 - render_quote_post(rwm.record), 444 - render_external_link_card(rwm.media), 445 - ]) 446 - } 447 - 448 - fn extract_handle_from_uri(uri: String) -> String { 449 - // Extract handle from AT URI like at://did:plc:xxx/app.bsky.feed.post/yyy 450 - // or from a handle-based URI 451 - case string.split(uri, "/") { 452 - [_, _, did_or_handle, ..] -> did_or_handle 453 - _ -> uri 454 - } 455 - } 456 - 457 - fn render_post_embed( 458 - embed: Option(decoders.Embed), 459 - identity: Option(Identity), 460 - ) -> Element(Msg) { 461 - case embed, identity { 462 - Some(decoders.Images(images)), Some(identity) -> 463 - render_image_grid(images, identity.pds, identity.did) 464 - Some(decoders.ExternalLink(external)), _ -> 465 - render_external_link_card(external) 466 - Some(decoders.Record(record)), _ -> render_quote_post(record) 467 - Some(decoders.RecordWithMedia(rwm)), _ -> render_record_with_media(rwm) 468 - _, _ -> html.div([], []) 469 - } 470 - } 471 - 472 - fn render_feed_item( 473 - post: Post, 474 - index: Int, 475 - identity: Option(Identity), 476 - ) -> Element(Msg) { 477 - let stagger_class = "stagger-" <> int.to_string({ index % 5 } + 1) 478 - let display_text = case string.is_empty(post.text) { 479 - True -> "[No text]" 480 - False -> truncate_text(post.text, 280) 481 - } 482 - html.div( 483 - [ 484 - attribute.attribute("class", "feed-item " <> stagger_class), 485 - ], 486 - [ 487 - html.div([attribute.attribute("class", "feed-item__content")], [ 488 - html.p([attribute.attribute("class", "feed-item__text")], [ 489 - html.text(display_text), 490 - ]), 491 - render_post_embed(post.embed, identity), 492 - ]), 493 - html.div([attribute.attribute("class", "feed-item__footer")], [ 494 - html.span([attribute.attribute("class", "feed-item__timestamp")], [ 495 - html.text(format_timestamp(post.created_at)), 496 - ]), 497 - ]), 498 - ], 499 - ) 500 - } 501 - 502 - fn truncate_text(text: String, max_length: Int) -> String { 503 - case string.length(text) > max_length { 504 - True -> string.slice(text, 0, max_length - 3) <> "..." 505 - False -> text 506 - } 507 - } 508 - 509 - fn feed_error_state(error: String) -> Element(Msg) { 510 - html.div( 511 - [ 512 - attribute.attribute("class", "feed-container feed-item--error"), 513 - attribute.attribute("role", "alert"), 514 - ], 515 - [ 516 - html.div( 517 - [ 518 - attribute.attribute("class", "error-message"), 519 - attribute.attribute("aria-live", "polite"), 520 - ], 521 - [html.text(error)], 522 - ), 523 - html.button( 524 - [ 525 - event.on_click(RetryFetch), 526 - attribute.attribute("class", "btn-retry"), 527 - attribute.attribute("aria-label", "Retry loading feed"), 528 - ], 529 - [html.text("Retry")], 530 - ), 531 - ], 532 - ) 42 + feed_view.render_feed(feed_state, identity) 533 43 }