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.

gpreview: some more view work; images etc

karitham b6a8aef6 ee052cfa

+538 -209
+86 -58
src/gpreview.css
··· 297 297 /* ─── Image Grid ─── */ 298 298 .image-grid { 299 299 display: grid; 300 - grid-template-columns: repeat(2, 1fr); 301 300 gap: var(--space-2); 302 301 border-radius: var(--radius-md); 303 302 overflow: hidden; 304 - margin-top: var(--space-4); 305 - border: 1px solid var(--border); 303 + margin-top: var(--space-3); 306 304 } 307 - 305 + .image-grid--1 { grid-template-columns: 1fr; } 306 + .image-grid--2 { grid-template-columns: 1fr 1fr; } 307 + .image-grid--3 { 308 + grid-template-columns: 1fr 1fr; 309 + grid-template-rows: 1fr 1fr; 310 + } 311 + .image-grid--3 img:first-child { 312 + grid-row: 1 / 3; 313 + } 314 + .image-grid--4 { grid-template-columns: 1fr 1fr; } 308 315 .image-grid img { 309 316 width: 100%; 310 - height: auto; 317 + height: 100%; 311 318 object-fit: cover; 312 319 outline: 1px solid var(--surface1); 313 320 transition: transform var(--duration-normal) var(--ease-out), 314 321 filter var(--duration-normal) var(--ease-out); 315 322 } 316 - 317 323 .image-grid img:hover { 318 324 transform: scale(1.02); 319 325 filter: brightness(1.1); 320 326 } 321 327 322 - /* ─── Link Card ─── */ 323 - .link-card { 324 - display: block; 325 - background: var(--surface1); 328 + /* ─── External Link Card ─── */ 329 + .external-link-card { 330 + display: flex; 331 + flex-direction: column; 326 332 border: 1px solid var(--border); 327 - border-radius: var(--radius-md); 333 + border-radius: var(--radius-lg); 328 334 overflow: hidden; 329 335 text-decoration: none; 330 - transition: transform var(--duration-normal) var(--ease-spring), 331 - border-color var(--duration-fast) var(--ease-out), 332 - background-color var(--duration-fast) var(--ease-out); 336 + color: inherit; 337 + transition: background-color var(--duration-normal) var(--ease-out); 338 + margin-top: var(--space-3); 333 339 } 334 - 335 - .link-card:hover { 336 - transform: translateY(-2px); 337 - border-color: var(--blue); 338 - background-color: var(--surface2); 340 + .external-link-card:hover { 341 + background-color: var(--surface1); 339 342 } 340 - 341 - .external-link-preview { 342 - padding: var(--space-4); 343 + .external-link-card__thumb { 344 + width: 100%; 345 + height: 200px; 346 + object-fit: cover; 343 347 } 344 - 345 - .external-link-title { 348 + .external-link-card__content { 349 + padding: var(--space-3); 350 + } 351 + .external-link-card__title { 346 352 font-family: var(--font-display); 347 353 font-weight: 600; 348 - color: var(--text); 349 354 font-size: 0.95rem; 350 355 line-height: 1.4; 351 356 margin-bottom: var(--space-1); 352 - text-wrap: balance; 357 + overflow: hidden; 358 + text-overflow: ellipsis; 359 + white-space: nowrap; 353 360 } 354 - 355 - .external-link-desc { 356 - font-size: 0.85rem; 357 - color: var(--subtext1); 361 + .external-link-card__desc { 362 + font-size: 0.875rem; 363 + color: var(--text-muted); 358 364 line-height: 1.5; 365 + margin-bottom: var(--space-2); 359 366 display: -webkit-box; 360 367 -webkit-line-clamp: 2; 361 368 -webkit-box-orient: vertical; 362 369 overflow: hidden; 363 370 } 371 + .external-link-card__domain { 372 + font-size: 0.75rem; 373 + color: var(--text-muted); 374 + } 375 + 376 + /* ─── Quote Post ─── */ 377 + .quote-post { 378 + border: 1px solid var(--border); 379 + border-radius: var(--radius-lg); 380 + padding: var(--space-3); 381 + margin-top: var(--space-3); 382 + background: var(--surface1); 383 + } 384 + .quote-post--stub { 385 + opacity: 0.8; 386 + font-style: italic; 387 + } 388 + .quote-post__header { 389 + display: flex; 390 + gap: var(--space-2); 391 + margin-bottom: var(--space-2); 392 + align-items: baseline; 393 + flex-wrap: wrap; 394 + } 395 + .quote-post__author { 396 + font-family: var(--font-display); 397 + font-weight: 600; 398 + font-size: 0.9rem; 399 + } 400 + .quote-post__handle { 401 + font-size: 0.8rem; 402 + color: var(--text-muted); 403 + word-break: break-all; 404 + } 405 + .quote-post__text { 406 + font-size: 0.9rem; 407 + line-height: 1.5; 408 + color: var(--text); 409 + margin: 0; 410 + } 411 + 412 + /* ─── Record with Media ─── */ 413 + .record-with-media { 414 + display: flex; 415 + flex-direction: column; 416 + gap: var(--space-3); 417 + margin-top: var(--space-3); 418 + } 364 419 365 420 /* ─── Post Footer ─── */ 366 421 .post-footer { ··· 370 425 margin-top: var(--space-4); 371 426 padding: var(--space-3) var(--space-5); 372 427 border-top: 1px solid var(--border); 373 - } 374 - 375 - /* ─── Engagement Bar ─── */ 376 - .engagement-bar { 377 - display: flex; 378 - align-items: center; 379 - gap: var(--space-3); 380 - } 381 - 382 - .engagement-bar__likes { 383 - color: var(--mauve); 384 - } 385 - 386 - .engagement-bar__reposts { 387 - color: var(--green); 388 - } 389 - 390 - .engagement-bar__replies { 391 - color: var(--blue); 392 428 } 393 429 394 430 /* ─── Footer Badges & Timestamp ─── */ ··· 597 633 } 598 634 } 599 635 600 - /* ─── Utility ─── */ 601 - .tabular-nums { 602 - font-variant-numeric: tabular-nums; 603 - } 636 + 604 637 605 638 /* ─── Error Badge ─── */ 606 639 .error-badge { ··· 874 907 .feed-item__timestamp { 875 908 font-size: 0.8rem; 876 909 color: var(--subtext0); 877 - } 878 - 879 - .feed-item__engagement { 880 - font-size: 0.85rem; 881 - color: var(--subtext1); 882 910 } 883 911 884 912 /* ─── Error Message ─── */
+1 -5
src/gpreview.gleam
··· 85 85 cid: r.cid, 86 86 text: r.value.text, 87 87 created_at: r.value.created_at, 88 - reply_count: option.None, 89 - repost_count: option.None, 90 - like_count: option.None, 91 - quote_count: option.None, 92 88 embed: r.value.embed, 93 89 ) 94 90 }) ··· 159 155 rsvp.BadBody -> "Invalid response from server. Please try again." 160 156 rsvp.JsonError(details) -> 161 157 "Failed to parse post data: " 162 - <> types.json_decode_error_to_string(details) 158 + <> types.error_to_string(rsvp.JsonError(details)) 163 159 rsvp.UnhandledResponse(_) -> 164 160 "Service temporarily unavailable. Please try again." 165 161 }
-4
src/gpreview/types.gleam
··· 20 20 cid: String, 21 21 text: String, 22 22 created_at: String, 23 - reply_count: Option(Int), 24 - repost_count: Option(Int), 25 - like_count: Option(Int), 26 - quote_count: Option(Int), 27 23 embed: Option(decoders.Embed), 28 24 ) 29 25 }
+123 -94
src/gpreview/views.gleam
··· 19 19 render_input_zone(model), 20 20 html.div([attribute.attribute("class", "main-content")], [ 21 21 render_profile_card(model.profile_state, model.identity), 22 - render_feed(model.feed_state), 22 + render_feed(model.feed_state, model.identity), 23 23 ]), 24 24 ]) 25 25 } ··· 283 283 } 284 284 } 285 285 286 - pub fn render_feed(feed_state: FeedState) -> Element(Msg) { 286 + pub fn render_feed( 287 + feed_state: FeedState, 288 + identity: Option(Identity), 289 + ) -> Element(Msg) { 287 290 case feed_state { 288 291 FeedEmpty -> html.div([attribute.attribute("class", "feed-container")], []) 289 292 FeedLoading -> feed_loading_skeleton() 290 - FeedLoaded(posts) -> render_feed_loaded(posts) 293 + FeedLoaded(posts) -> render_feed_loaded(posts, identity) 291 294 FeedFailed(error) -> feed_error_state(error) 292 295 } 293 296 } ··· 344 347 ) 345 348 } 346 349 347 - fn render_feed_loaded(posts: List(Post)) -> Element(Msg) { 350 + fn render_feed_loaded( 351 + posts: List(Post), 352 + identity: Option(Identity), 353 + ) -> Element(Msg) { 348 354 let elements = 349 355 posts 350 - |> list.index_map(fn(post, index) { render_feed_item(post, index) }) 356 + |> list.index_map(fn(post, index) { 357 + render_feed_item(post, index, identity) 358 + }) 351 359 352 360 html.div([attribute.attribute("class", "feed-container")], elements) 353 361 } 354 362 355 - fn render_post_embed(embed: Option(decoders.Embed)) -> Element(Msg) { 356 - case embed { 357 - None -> html.div([], []) 358 - Some(decoders.Images(images)) -> { 359 - let image_elements = 360 - images 361 - |> list.map(fn(img) { 362 - html.img([ 363 - attribute.attribute("src", img.ref), 364 - attribute.attribute("alt", img.alt), 365 - attribute.attribute("class", "post-image"), 366 - ]) 367 - }) 368 - html.div( 369 - [attribute.attribute("class", "post-embed post-embed--images")], 370 - image_elements, 371 - ) 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 + } 372 398 } 373 - Some(decoders.ExternalLink(ext)) -> { 374 - html.div( 375 - [attribute.attribute("class", "post-embed post-embed--external")], 376 - [ 377 - html.a( 378 - [ 379 - attribute.attribute("href", ext.uri), 380 - attribute.attribute("class", "external-link"), 381 - attribute.attribute("target", "_blank"), 382 - attribute.attribute("rel", "noopener noreferrer"), 383 - ], 384 - [ 385 - html.h4([attribute.attribute("class", "external-link__title")], [ 386 - html.text(ext.title), 387 - ]), 388 - html.p( 389 - [attribute.attribute("class", "external-link__description")], 390 - [html.text(ext.description)], 391 - ), 392 - ], 393 - ), 394 - ], 395 - ) 396 - } 397 - Some(decoders.Record(record)) -> { 398 - html.div([attribute.attribute("class", "post-embed post-embed--record")], [ 399 - html.div([attribute.attribute("class", "record-embed")], [ 400 - html.p([attribute.attribute("class", "record-embed__uri")], [ 401 - html.text("Record: " <> record.record.uri), 402 - ]), 403 - ]), 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"), 404 423 ]) 405 - } 406 - Some(decoders.RecordWithMedia(media)) -> { 407 - html.div( 408 - [ 409 - attribute.attribute( 410 - "class", 411 - "post-embed post-embed--record-with-media", 412 - ), 413 - ], 414 - [ 415 - html.div([attribute.attribute("class", "record-embed")], [ 416 - html.p([attribute.attribute("class", "record-embed__uri")], [ 417 - html.text("Record: " <> media.record.record.uri), 418 - ]), 419 - ]), 420 - html.div([attribute.attribute("class", "external-link")], [ 421 - html.h4([attribute.attribute("class", "external-link__title")], [ 422 - html.text(media.media.title), 423 - ]), 424 - html.p( 425 - [attribute.attribute("class", "external-link__description")], 426 - [html.text(media.media.description)], 427 - ), 428 - ]), 429 - ], 430 - ) 431 - } 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([], []) 432 469 } 433 470 } 434 471 435 - fn render_feed_item(post: Post, index: Int) -> Element(Msg) { 472 + fn render_feed_item( 473 + post: Post, 474 + index: Int, 475 + identity: Option(Identity), 476 + ) -> Element(Msg) { 436 477 let stagger_class = "stagger-" <> int.to_string({ index % 5 } + 1) 437 478 let display_text = case string.is_empty(post.text) { 438 479 True -> "[No text]" ··· 447 488 html.p([attribute.attribute("class", "feed-item__text")], [ 448 489 html.text(display_text), 449 490 ]), 450 - render_post_embed(post.embed), 491 + render_post_embed(post.embed, identity), 451 492 ]), 452 493 html.div([attribute.attribute("class", "feed-item__footer")], [ 453 494 html.span([attribute.attribute("class", "feed-item__timestamp")], [ 454 495 html.text(format_timestamp(post.created_at)), 455 496 ]), 456 - html.span( 457 - [attribute.attribute("class", "feed-item__engagement tabular-nums")], 458 - [ 459 - html.text("♥ " <> int.to_string(option.unwrap(post.like_count, 0))), 460 - html.text( 461 - " ↗ " <> int.to_string(option.unwrap(post.repost_count, 0)), 462 - ), 463 - html.text( 464 - " 💬 " <> int.to_string(option.unwrap(post.reply_count, 0)), 465 - ), 466 - ], 467 - ), 468 497 ]), 469 498 ], 470 499 )
+1 -5
test/gpreview_test.gleam
··· 6 6 import gleeunit/should 7 7 import gpreview/effects 8 8 import gpreview/types.{ 9 - FeedFailed, FeedLoaded, FeedLoading, InputChanged, App, Post, ProfileFailed, 9 + App, FeedFailed, FeedLoaded, FeedLoading, InputChanged, Post, ProfileFailed, 10 10 ProfileLoaded, ProfileLoading, RetryFetch, SubmitInput, 11 11 } 12 12 ··· 77 77 cid: "cid1", 78 78 text: "Hello", 79 79 created_at: "2024-01-01T00:00:00Z", 80 - reply_count: option.Some(1), 81 - repost_count: option.Some(2), 82 - like_count: option.Some(3), 83 - quote_count: option.Some(0), 84 80 embed: option.None, 85 81 ), 86 82 ]
+327 -43
test/views_test.gleam
··· 5 5 import gleeunit/should 6 6 import gpreview/types.{ 7 7 type Model, type Post, App, FeedEmpty, FeedFailed, FeedLoaded, FeedLoading, 8 - Post, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, 8 + Identity, Post, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, 9 9 } 10 10 import gpreview/views.{render_feed, render_input_zone, render_profile_card, view} 11 11 import lustre/element.{to_string} ··· 30 30 cid: "cid123", 31 31 text: "Hello from Bluesky!", 32 32 created_at: "2024-01-15T10:30:00.000Z", 33 - reply_count: option.Some(5), 34 - repost_count: option.Some(3), 35 - like_count: option.Some(10), 36 - quote_count: option.Some(2), 37 33 embed: option.None, 38 34 ) 39 35 } ··· 104 100 // === Feed tests === 105 101 106 102 pub fn feed_empty_renders_nothing_test() { 107 - let html = render_feed(FeedEmpty) |> to_string 103 + let html = render_feed(FeedEmpty, option.None) |> to_string 108 104 string.contains(html, "feed-container") |> should.be_true() 109 105 string.contains(html, "feed-item") |> should.be_false() 110 106 } 111 107 112 108 pub fn feed_loading_renders_skeletons_test() { 113 - let html = render_feed(FeedLoading) |> to_string 109 + let html = render_feed(FeedLoading, option.None) |> to_string 114 110 string.contains(html, "feed-container") |> should.be_true() 115 111 string.contains(html, "skeleton") |> should.be_true() 116 112 string.contains(html, "Loading posts") |> should.be_true() ··· 118 114 119 115 pub fn feed_loaded_renders_posts_test() { 120 116 let posts = [sample_post()] 121 - let html = render_feed(FeedLoaded(posts)) |> to_string 117 + let html = render_feed(FeedLoaded(posts), option.None) |> to_string 122 118 string.contains(html, "Hello from Bluesky!") |> should.be_true() 123 - string.contains(html, "♥ 10") |> should.be_true() 124 - string.contains(html, "↗ 3") |> should.be_true() 125 - string.contains(html, "💬 5") |> should.be_true() 126 119 } 127 120 128 121 pub fn feed_loaded_renders_multiple_posts_test() { 129 122 let posts = [sample_post(), sample_post()] 130 - let html = render_feed(FeedLoaded(posts)) |> to_string 123 + let html = render_feed(FeedLoaded(posts), option.None) |> to_string 131 124 string.contains(html, "Hello from Bluesky!") |> should.be_true() 132 125 // Should have stagger classes for multiple posts 133 126 string.contains(html, "stagger-1") |> should.be_true() ··· 135 128 } 136 129 137 130 pub fn feed_error_renders_error_and_retry_test() { 138 - let html = render_feed(FeedFailed("Network error")) |> to_string 131 + let html = render_feed(FeedFailed("Network error"), option.None) |> to_string 139 132 string.contains(html, "Network error") |> should.be_true() 140 133 string.contains(html, "Retry") |> should.be_true() 141 134 } ··· 213 206 cid: "cid1", 214 207 text: "Post with image", 215 208 created_at: "2024-01-01T00:00:00Z", 216 - reply_count: option.None, 217 - repost_count: option.None, 218 - like_count: option.None, 219 - quote_count: option.None, 220 209 embed: option.Some( 221 210 decoders.Images([ 222 211 decoders.ImageJson( ··· 227 216 ]), 228 217 ), 229 218 ) 230 - let html = render_feed(FeedLoaded([post])) |> to_string 219 + let identity = 220 + option.Some(Identity( 221 + "did:plc:abc", 222 + "test.bsky.social", 223 + "https://bsky.social", 224 + "key123", 225 + )) 226 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 231 227 string.contains(html, "Post with image") |> should.be_true() 232 228 } 233 229 ··· 238 234 cid: "cid1", 239 235 text: "Check this link", 240 236 created_at: "2024-01-01T00:00:00Z", 241 - reply_count: option.None, 242 - repost_count: option.None, 243 - like_count: option.None, 244 - quote_count: option.None, 245 237 embed: option.Some( 246 238 decoders.ExternalLink(decoders.ExternalJson( 247 239 uri: "https://example.com", ··· 250 242 )), 251 243 ), 252 244 ) 253 - let html = render_feed(FeedLoaded([post])) |> to_string 245 + let html = render_feed(FeedLoaded([post]), option.None) |> to_string 254 246 string.contains(html, "Check this link") |> should.be_true() 255 247 } 256 248 ··· 261 253 cid: "cid1", 262 254 text: "Quoting a post", 263 255 created_at: "2024-01-01T00:00:00Z", 264 - reply_count: option.None, 265 - repost_count: option.None, 266 - like_count: option.None, 267 - quote_count: option.None, 268 256 embed: option.Some( 269 257 decoders.Record( 270 258 decoders.EmbedRecordJson(record: decoders.StrongRefJson( ··· 274 262 ), 275 263 ), 276 264 ) 277 - let html = render_feed(FeedLoaded([post])) |> to_string 265 + let identity = 266 + option.Some(Identity( 267 + "did:plc:abc", 268 + "test.bsky.social", 269 + "https://bsky.social", 270 + "key123", 271 + )) 272 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 278 273 string.contains(html, "Quoting a post") |> should.be_true() 279 274 } 280 275 ··· 285 280 cid: "cid1", 286 281 text: "Quote with media", 287 282 created_at: "2024-01-01T00:00:00Z", 288 - reply_count: option.None, 289 - repost_count: option.None, 290 - like_count: option.None, 291 - quote_count: option.None, 292 283 embed: option.Some( 293 284 decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( 294 285 record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( ··· 303 294 )), 304 295 ), 305 296 ) 306 - let html = render_feed(FeedLoaded([post])) |> to_string 297 + let identity = 298 + option.Some(Identity( 299 + "did:plc:abc", 300 + "test.bsky.social", 301 + "https://bsky.social", 302 + "key123", 303 + )) 304 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 307 305 string.contains(html, "Quote with media") |> should.be_true() 308 306 } 309 307 ··· 317 315 cid: "cid1", 318 316 text: long_text, 319 317 created_at: "2024-01-01T00:00:00Z", 320 - reply_count: option.None, 321 - repost_count: option.None, 322 - like_count: option.None, 323 - quote_count: option.None, 324 318 embed: option.None, 325 319 ) 326 - let html = render_feed(FeedLoaded([post])) |> to_string 320 + let html = render_feed(FeedLoaded([post]), option.None) |> to_string 327 321 string.contains(html, "...") |> should.be_true() 328 322 } 329 323 ··· 334 328 cid: "cid1", 335 329 text: "", 336 330 created_at: "2024-01-01T00:00:00Z", 337 - reply_count: option.None, 338 - repost_count: option.None, 339 - like_count: option.None, 340 - quote_count: option.None, 341 331 embed: option.None, 342 332 ) 343 - let html = render_feed(FeedLoaded([post])) |> to_string 333 + let html = render_feed(FeedLoaded([post]), option.None) |> to_string 344 334 string.contains(html, "[No text]") |> should.be_true() 345 335 } 336 + 337 + // === Embed rendering tests === 338 + 339 + pub fn external_link_card_renders_with_title_and_desc_test() { 340 + let post = 341 + Post( 342 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 343 + cid: "cid1", 344 + text: "Check this", 345 + created_at: "2024-01-01T00:00:00Z", 346 + embed: option.Some( 347 + decoders.ExternalLink(decoders.ExternalJson( 348 + uri: "https://example.com/page", 349 + title: "Example Page Title", 350 + description: "This is a description", 351 + )), 352 + ), 353 + ) 354 + let html = render_feed(FeedLoaded([post]), option.None) |> to_string 355 + string.contains(html, "external-link-card") |> should.be_true() 356 + string.contains(html, "Example Page Title") |> should.be_true() 357 + string.contains(html, "This is a description") |> should.be_true() 358 + string.contains(html, "example.com") |> should.be_true() 359 + } 360 + 361 + pub fn external_link_card_renders_domain_from_uri_test() { 362 + let post = 363 + Post( 364 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 365 + cid: "cid1", 366 + text: "Link", 367 + created_at: "2024-01-01T00:00:00Z", 368 + embed: option.Some( 369 + decoders.ExternalLink(decoders.ExternalJson( 370 + uri: "https://subdomain.example.com/path/to/page", 371 + title: "Title", 372 + description: "Desc", 373 + )), 374 + ), 375 + ) 376 + let html = render_feed(FeedLoaded([post]), option.None) |> to_string 377 + string.contains(html, "subdomain.example.com") |> should.be_true() 378 + } 379 + 380 + pub fn image_grid_with_one_image_test() { 381 + let identity = 382 + option.Some(Identity( 383 + "did:plc:abc", 384 + "test.bsky.social", 385 + "https://bsky.social", 386 + "key123", 387 + )) 388 + let post = 389 + Post( 390 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 391 + cid: "cid1", 392 + text: "One image", 393 + created_at: "2024-01-01T00:00:00Z", 394 + embed: option.Some( 395 + decoders.Images([ 396 + decoders.ImageJson( 397 + alt: "Single image", 398 + ref: "bafkrei123", 399 + aspect_ratio: option.None, 400 + ), 401 + ]), 402 + ), 403 + ) 404 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 405 + string.contains(html, "image-grid--1") |> should.be_true() 406 + string.contains(html, "bsky.social/xrpc/com.atproto.sync.getBlob") 407 + |> should.be_true() 408 + string.contains(html, "bafkrei123") |> should.be_true() 409 + } 410 + 411 + pub fn image_grid_with_two_images_test() { 412 + let identity = 413 + option.Some(Identity( 414 + "did:plc:abc", 415 + "test.bsky.social", 416 + "https://bsky.social", 417 + "key123", 418 + )) 419 + let post = 420 + Post( 421 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 422 + cid: "cid1", 423 + text: "Two images", 424 + created_at: "2024-01-01T00:00:00Z", 425 + embed: option.Some( 426 + decoders.Images([ 427 + decoders.ImageJson( 428 + alt: "First image", 429 + ref: "bafkrei111", 430 + aspect_ratio: option.None, 431 + ), 432 + decoders.ImageJson( 433 + alt: "Second image", 434 + ref: "bafkrei222", 435 + aspect_ratio: option.None, 436 + ), 437 + ]), 438 + ), 439 + ) 440 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 441 + string.contains(html, "image-grid--2") |> should.be_true() 442 + string.contains(html, "bafkrei111") |> should.be_true() 443 + string.contains(html, "bafkrei222") |> should.be_true() 444 + } 445 + 446 + pub fn image_grid_with_three_images_test() { 447 + let identity = 448 + option.Some(Identity( 449 + "did:plc:abc", 450 + "test.bsky.social", 451 + "https://bsky.social", 452 + "key123", 453 + )) 454 + let post = 455 + Post( 456 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 457 + cid: "cid1", 458 + text: "Three images", 459 + created_at: "2024-01-01T00:00:00Z", 460 + embed: option.Some( 461 + decoders.Images([ 462 + decoders.ImageJson( 463 + alt: "First", 464 + ref: "bafkrei1", 465 + aspect_ratio: option.None, 466 + ), 467 + decoders.ImageJson( 468 + alt: "Second", 469 + ref: "bafkrei2", 470 + aspect_ratio: option.None, 471 + ), 472 + decoders.ImageJson( 473 + alt: "Third", 474 + ref: "bafkrei3", 475 + aspect_ratio: option.None, 476 + ), 477 + ]), 478 + ), 479 + ) 480 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 481 + string.contains(html, "image-grid--3") |> should.be_true() 482 + } 483 + 484 + pub fn image_grid_with_four_images_test() { 485 + let identity = 486 + option.Some(Identity( 487 + "did:plc:abc", 488 + "test.bsky.social", 489 + "https://bsky.social", 490 + "key123", 491 + )) 492 + let post = 493 + Post( 494 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 495 + cid: "cid1", 496 + text: "Four images", 497 + created_at: "2024-01-01T00:00:00Z", 498 + embed: option.Some( 499 + decoders.Images([ 500 + decoders.ImageJson( 501 + alt: "First", 502 + ref: "bafkrei1", 503 + aspect_ratio: option.None, 504 + ), 505 + decoders.ImageJson( 506 + alt: "Second", 507 + ref: "bafkrei2", 508 + aspect_ratio: option.None, 509 + ), 510 + decoders.ImageJson( 511 + alt: "Third", 512 + ref: "bafkrei3", 513 + aspect_ratio: option.None, 514 + ), 515 + decoders.ImageJson( 516 + alt: "Fourth", 517 + ref: "bafkrei4", 518 + aspect_ratio: option.None, 519 + ), 520 + ]), 521 + ), 522 + ) 523 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 524 + string.contains(html, "image-grid--4") |> should.be_true() 525 + } 526 + 527 + pub fn image_grid_renders_with_referrerpolicy_test() { 528 + let identity = 529 + option.Some(Identity( 530 + "did:plc:abc", 531 + "test.bsky.social", 532 + "https://bsky.social", 533 + "key123", 534 + )) 535 + let post = 536 + Post( 537 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 538 + cid: "cid1", 539 + text: "Image", 540 + created_at: "2024-01-01T00:00:00Z", 541 + embed: option.Some( 542 + decoders.Images([ 543 + decoders.ImageJson( 544 + alt: "Test", 545 + ref: "bafkrei123", 546 + aspect_ratio: option.None, 547 + ), 548 + ]), 549 + ), 550 + ) 551 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 552 + string.contains(html, "referrerpolicy") |> should.be_true() 553 + string.contains(html, "no-referrer") |> should.be_true() 554 + } 555 + 556 + pub fn quote_post_renders_author_and_uri_test() { 557 + let identity = 558 + option.Some(Identity( 559 + "did:plc:abc", 560 + "test.bsky.social", 561 + "https://bsky.social", 562 + "key123", 563 + )) 564 + let post = 565 + Post( 566 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 567 + cid: "cid1", 568 + text: "Quoting", 569 + created_at: "2024-01-01T00:00:00Z", 570 + embed: option.Some( 571 + decoders.Record( 572 + decoders.EmbedRecordJson(record: decoders.StrongRefJson( 573 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc", 574 + cid: "bafyreixyz", 575 + )), 576 + ), 577 + ), 578 + ) 579 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 580 + string.contains(html, "quote-post") |> should.be_true() 581 + string.contains(html, "Quoted Post") |> should.be_true() 582 + string.contains(html, "did:plc:xyz") 583 + |> should.be_true() 584 + } 585 + 586 + pub fn record_with_media_renders_both_parts_test() { 587 + let identity = 588 + option.Some(Identity( 589 + "did:plc:abc", 590 + "test.bsky.social", 591 + "https://bsky.social", 592 + "key123", 593 + )) 594 + let post = 595 + Post( 596 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 597 + cid: "cid1", 598 + text: "Quote with media", 599 + created_at: "2024-01-01T00:00:00Z", 600 + embed: option.Some( 601 + decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( 602 + record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( 603 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc", 604 + cid: "bafyreixyz", 605 + )), 606 + media: decoders.ExternalJson( 607 + uri: "https://example.com", 608 + title: "Example", 609 + description: "A site", 610 + ), 611 + )), 612 + ), 613 + ) 614 + let html = render_feed(FeedLoaded([post]), identity) |> to_string 615 + string.contains(html, "record-with-media") |> should.be_true() 616 + string.contains(html, "quote-post") |> should.be_true() 617 + string.contains(html, "external-link-card") |> should.be_true() 618 + string.contains(html, "Example") |> should.be_true() 619 + } 620 + 621 + pub fn blob_url_construction_test() { 622 + let _pds = "https://bsky.social" 623 + let _did = "did:plc:abc123" 624 + let _cid = "bafkrei123456" 625 + let expected = 626 + "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:abc123&cid=bafkrei123456" 627 + // Test is implicit via image grid tests which check for blob URL pattern 628 + expected |> should.equal(expected) 629 + }