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.

proper profile & post display

karitham ee052cfa 8c0f1a50

+1824 -554
+63 -16
src/bsky/decoders.gleam
··· 4 4 optional_field, string, success, 5 5 } 6 6 import gleam/option.{type Option, None, Some} 7 + import gpreview/record 7 8 8 9 // === Simple decoders === 9 10 11 + pub type ListRecordsResponse(a) { 12 + ListRecordsResponse(cursor: Option(String), records: List(record.Record(a))) 13 + } 14 + 15 + pub fn decode_record( 16 + decoder: decode.Decoder(a), 17 + ) -> decode.Decoder(record.Record(a)) { 18 + use uri <- field("uri", string) 19 + use cid <- field("cid", string) 20 + use value <- field("value", decoder) 21 + success(record.Record(uri:, cid:, value:)) 22 + } 23 + 24 + pub fn decode_list_records_response( 25 + decoder: decode.Decoder(a), 26 + ) -> decode.Decoder(ListRecordsResponse(a)) { 27 + use cursor <- optional_field("cursor", None, map(string, Some)) 28 + use records <- field("records", list(decode_record(decoder))) 29 + success(ListRecordsResponse(cursor:, records:)) 30 + } 31 + 10 32 pub type StrongRefJson { 11 33 StrongRefJson(uri: String, cid: String) 12 34 } ··· 90 112 display_name: Option(String), 91 113 description: Option(String), 92 114 avatar: Option(String), 115 + banner: Option(String), 116 + joined_at: Option(String), 93 117 ) 94 118 } 95 119 ··· 97 121 use display_name <- optional_field("displayName", None, map(string, Some)) 98 122 use description <- optional_field("description", None, map(string, Some)) 99 123 use avatar <- optional_field("avatar", None, map(decode_avatar(), Some)) 100 - success(ProfileJson(display_name:, description:, avatar:)) 124 + use banner <- optional_field("banner", None, map(decode_avatar(), Some)) 125 + use created_at <- optional_field("createdAt", None, map(string, Some)) 126 + success(ProfileJson( 127 + display_name:, 128 + description:, 129 + avatar:, 130 + banner:, 131 + joined_at: created_at, 132 + )) 101 133 } 102 134 103 135 pub type ImageJson { ··· 171 203 pub fn decode_embed_record() -> decode.Decoder(EmbedRecordJson) { 172 204 use record <- field("record", decode_strong_ref()) 173 205 success(EmbedRecordJson(record:)) 206 + } 207 + 208 + pub type EmbedRecordWithMedia { 209 + EmbedRecordWithMedia(record: EmbedRecordJson, media: ExternalJson) 174 210 } 175 211 176 212 pub type ViewNotFoundJson { ··· 276 312 Images(List(ImageJson)) 277 313 ExternalLink(ExternalJson) 278 314 Record(EmbedRecordJson) 315 + RecordWithMedia(EmbedRecordWithMedia) 279 316 } 280 317 281 318 pub fn decode_embed() -> decode.Decoder(Embed) { ··· 290 327 success(ExternalLink(external)) 291 328 } 292 329 "app.bsky.embed.record" -> map(decode_embed_record(), Record) 293 - _ -> failure(Images([]), "unknown embed type") 330 + "app.bsky.embed.recordWithMedia" -> { 331 + use embed_record <- field("record", decode_embed_record()) 332 + use media_type <- field("media", at(["$type"], string)) 333 + case media_type { 334 + "app.bsky.embed.external" -> { 335 + use external <- field("media", at(["external"], decode_external())) 336 + success( 337 + RecordWithMedia(EmbedRecordWithMedia( 338 + record: embed_record, 339 + media: external, 340 + )), 341 + ) 342 + } 343 + _ -> 344 + success( 345 + RecordWithMedia(EmbedRecordWithMedia( 346 + record: EmbedRecordJson(record: StrongRefJson(uri: "", cid: "")), 347 + media: ExternalJson(uri: "", title: "", description: ""), 348 + )), 349 + ) 350 + } 351 + } 352 + // Skip unknown embed types instead of failing 353 + _ -> success(Images([])) 294 354 } 295 355 } 296 356 ··· 301 361 reply: Option(ReplyRefJson), 302 362 embed: Option(Embed), 303 363 langs: Option(List(String)), 304 - labels: Option(List(LabelJson)), 305 - tags: Option(List(String)), 306 364 created_at: String, 307 365 ) 308 366 } ··· 313 371 use reply <- optional_field("reply", None, map(decode_reply_ref(), Some)) 314 372 use embed <- optional_field("embed", None, map(decode_embed(), Some)) 315 373 use langs <- optional_field("langs", None, map(list(string), Some)) 316 - use labels <- optional_field("labels", None, map(list(decode_label()), Some)) 317 - use tags <- optional_field("tags", None, map(list(string), Some)) 318 374 use created_at <- field("createdAt", string) 319 - success(PostJson( 320 - text:, 321 - facets:, 322 - reply:, 323 - embed:, 324 - langs:, 325 - labels:, 326 - tags:, 327 - created_at:, 328 - )) 375 + success(PostJson(text:, facets:, reply:, embed:, langs:, created_at:)) 329 376 } 330 377 331 378 // === EmbedRecordView union ===
+383
src/gpreview.css
··· 625 625 gap: var(--space-1); 626 626 flex-wrap: wrap; 627 627 } 628 + 629 + /* ─── Profile Card ─── */ 630 + .profile-card { 631 + background: var(--surface0); 632 + border-radius: var(--radius-xl); 633 + box-shadow: var(--shadow-card); 634 + overflow: hidden; 635 + transition: transform var(--duration-normal) var(--ease-spring), 636 + box-shadow var(--duration-normal) var(--ease-out); 637 + border: 1px solid var(--border); 638 + animation: fadeInUp var(--duration-slow) var(--ease-out) both; 639 + margin-bottom: 2rem; 640 + } 641 + 642 + .profile-card--loading { 643 + background: var(--surface1); 644 + } 645 + 646 + .profile-card--error { 647 + background-color: rgba(243, 139, 168, 0.1); 648 + border-left: 4px solid var(--red); 649 + padding: var(--space-4); 650 + } 651 + 652 + .profile-card__content { 653 + padding: var(--space-5); 654 + display: flex; 655 + gap: var(--space-4); 656 + align-items: flex-start; 657 + } 658 + 659 + .profile-card__info { 660 + display: flex; 661 + flex-direction: column; 662 + gap: var(--space-2); 663 + min-width: 0; 664 + } 665 + 666 + /* ─── Profile Banner ─── */ 667 + .profile-banner { 668 + height: 120px; 669 + background: var(--surface1); 670 + position: relative; 671 + overflow: hidden; 672 + } 673 + 674 + .profile-banner--skeleton { 675 + background: linear-gradient(90deg, var(--surface1) 25%, var(--surface2) 50%, var(--surface1) 75%); 676 + background-size: 200px 100%; 677 + animation: shimmer 1.5s ease-in-out infinite; 678 + } 679 + 680 + .profile-banner--empty { 681 + background: var(--surface1); 682 + } 683 + 684 + .profile-banner__image { 685 + width: 100%; 686 + height: 100%; 687 + object-fit: cover; 688 + } 689 + 690 + /* ─── Profile Avatar ─── */ 691 + .profile-avatar { 692 + width: 5rem; 693 + height: 5rem; 694 + border-radius: 50%; 695 + object-fit: cover; 696 + flex-shrink: 0; 697 + border: 3px solid var(--surface0); 698 + background: var(--mauve); 699 + } 700 + 701 + .profile-avatar--skeleton { 702 + width: 5rem; 703 + height: 5rem; 704 + border-radius: 50%; 705 + background: linear-gradient(90deg, var(--surface1) 25%, var(--surface2) 50%, var(--surface1) 75%); 706 + background-size: 200px 100%; 707 + animation: shimmer 1.5s ease-in-out infinite; 708 + } 709 + 710 + .profile-avatar--fallback { 711 + width: 5rem; 712 + height: 5rem; 713 + border-radius: 50%; 714 + background: var(--mauve); 715 + display: flex; 716 + align-items: center; 717 + justify-content: center; 718 + color: var(--crust); 719 + font-family: var(--font-display); 720 + font-size: 1.5rem; 721 + font-weight: 800; 722 + } 723 + 724 + /* ─── Profile Name ─── */ 725 + .profile-name { 726 + font-family: var(--font-display); 727 + font-weight: 700; 728 + font-size: 1.5rem; 729 + line-height: 1.3; 730 + color: var(--text); 731 + margin: 0; 732 + } 733 + 734 + .profile-name--empty { 735 + color: var(--subtext0); 736 + } 737 + 738 + /* ─── Profile Bio ─── */ 739 + .profile-bio { 740 + font-size: 0.95rem; 741 + line-height: 1.6; 742 + color: var(--subtext1); 743 + margin: 0; 744 + text-wrap: pretty; 745 + } 746 + 747 + /* ─── Profile Joined ─── */ 748 + .profile-joined { 749 + font-size: 0.85rem; 750 + color: var(--subtext0); 751 + margin: 0; 752 + } 753 + 754 + /* ─── Loading Text ─── */ 755 + .loading-text { 756 + font-size: 0.9rem; 757 + color: var(--subtext0); 758 + text-align: center; 759 + margin-top: var(--space-3); 760 + } 761 + 762 + /* ─── Skeleton Lines ─── */ 763 + .skeleton-line { 764 + background: linear-gradient(90deg, var(--surface1) 25%, var(--surface2) 50%, var(--surface1) 75%); 765 + background-size: 200px 100%; 766 + animation: shimmer 1.5s ease-in-out infinite; 767 + border-radius: var(--radius-sm); 768 + height: 1rem; 769 + } 770 + 771 + .skeleton-line-md { 772 + width: 70%; 773 + height: 1.25rem; 774 + } 775 + 776 + .skeleton-line-sm { 777 + width: 50%; 778 + height: 0.875rem; 779 + } 780 + 781 + .skeleton-circle { 782 + width: 2.5rem; 783 + height: 2.5rem; 784 + border-radius: 50%; 785 + background: linear-gradient(90deg, var(--surface1) 25%, var(--surface2) 50%, var(--surface1) 75%); 786 + background-size: 200px 100%; 787 + animation: shimmer 1.5s ease-in-out infinite; 788 + flex-shrink: 0; 789 + } 790 + 791 + /* ─── Feed Container ─── */ 792 + .feed-container { 793 + display: flex; 794 + flex-direction: column; 795 + gap: var(--space-4); 796 + } 797 + 798 + .feed-loading-header { 799 + text-align: center; 800 + padding: var(--space-4); 801 + } 802 + 803 + /* ─── Feed Item ─── */ 804 + .feed-item { 805 + background: var(--surface0); 806 + border-radius: var(--radius-xl); 807 + box-shadow: var(--shadow-card); 808 + padding: var(--space-4) var(--space-5); 809 + border: 1px solid var(--border); 810 + transition: transform var(--duration-normal) var(--ease-spring), 811 + box-shadow var(--duration-normal) var(--ease-out); 812 + animation: fadeInUp var(--duration-slow) var(--ease-out) both; 813 + } 814 + 815 + .feed-item:hover { 816 + transform: translateY(-2px); 817 + box-shadow: var(--shadow-card-hover); 818 + } 819 + 820 + .feed-item--loading { 821 + background: var(--surface1); 822 + pointer-events: none; 823 + } 824 + 825 + .feed-item--error { 826 + background-color: rgba(243, 139, 168, 0.1); 827 + border-left: 4px solid var(--red); 828 + padding: var(--space-4); 829 + } 830 + 831 + .feed-item__content { 832 + display: flex; 833 + flex-direction: column; 834 + gap: var(--space-3); 835 + } 836 + 837 + .feed-item__header { 838 + display: flex; 839 + align-items: center; 840 + gap: var(--space-3); 841 + margin-bottom: var(--space-3); 842 + } 843 + 844 + .feed-item__header-info { 845 + display: flex; 846 + flex-direction: column; 847 + gap: var(--space-1); 848 + min-width: 0; 849 + } 850 + 851 + .feed-item__body { 852 + display: flex; 853 + flex-direction: column; 854 + gap: var(--space-2); 855 + margin-bottom: var(--space-3); 856 + } 857 + 858 + .feed-item__text { 859 + font-size: 1rem; 860 + line-height: 1.6; 861 + color: var(--text); 862 + text-wrap: pretty; 863 + word-break: break-word; 864 + } 865 + 866 + .feed-item__footer { 867 + display: flex; 868 + flex-direction: column; 869 + gap: var(--space-2); 870 + padding-top: var(--space-3); 871 + border-top: 1px solid var(--surface1); 872 + } 873 + 874 + .feed-item__timestamp { 875 + font-size: 0.8rem; 876 + color: var(--subtext0); 877 + } 878 + 879 + .feed-item__engagement { 880 + font-size: 0.85rem; 881 + color: var(--subtext1); 882 + } 883 + 884 + /* ─── Error Message ─── */ 885 + .error-message { 886 + color: var(--red); 887 + font-weight: 500; 888 + margin-bottom: var(--space-3); 889 + } 890 + 891 + /* ─── Retry Button ─── */ 892 + .btn-retry { 893 + padding: var(--space-3) var(--space-5); 894 + font-family: var(--font-display); 895 + font-size: 0.9rem; 896 + font-weight: 600; 897 + color: var(--crust); 898 + background: var(--red); 899 + border: none; 900 + border-radius: var(--radius-md); 901 + cursor: pointer; 902 + transition: background-color var(--duration-fast) var(--ease-out), 903 + transform var(--duration-fast) var(--ease-spring); 904 + } 905 + 906 + .btn-retry:hover { 907 + background: var(--maroon); 908 + transform: translateY(-1px); 909 + } 910 + 911 + .btn-retry:active { 912 + transform: translateY(0); 913 + } 914 + 915 + /* ─── Fade In Up Animation ─── */ 916 + @keyframes fadeInUp { 917 + from { 918 + opacity: 0; 919 + transform: translateY(10px); 920 + } 921 + to { 922 + opacity: 1; 923 + transform: translateY(0); 924 + } 925 + } 926 + 927 + /* ─── Stagger Animation Delays ─── */ 928 + .stagger-1 { 929 + animation-delay: 0ms; 930 + } 931 + 932 + .stagger-2 { 933 + animation-delay: 60ms; 934 + } 935 + 936 + .stagger-3 { 937 + animation-delay: 120ms; 938 + } 939 + 940 + .stagger-4 { 941 + animation-delay: 180ms; 942 + } 943 + 944 + .stagger-5 { 945 + animation-delay: 240ms; 946 + } 947 + 948 + /* ─── Responsive Design ─── */ 949 + @media (max-width: 640px) { 950 + .app-shell { 951 + padding: var(--space-4) var(--space-3); 952 + } 953 + 954 + .profile-card__content { 955 + padding: var(--space-4); 956 + flex-direction: column; 957 + align-items: center; 958 + text-align: center; 959 + } 960 + 961 + .profile-card__info { 962 + align-items: center; 963 + } 964 + 965 + .profile-name { 966 + font-size: 1.25rem; 967 + } 968 + 969 + .profile-avatar, 970 + .profile-avatar--skeleton, 971 + .profile-avatar--fallback { 972 + width: 4rem; 973 + height: 4rem; 974 + } 975 + 976 + .input-zone__row { 977 + flex-direction: column; 978 + gap: var(--space-3); 979 + } 980 + 981 + .input-field { 982 + width: 100%; 983 + } 984 + 985 + .btn-show, 986 + .btn-retry { 987 + width: 100%; 988 + justify-content: center; 989 + } 990 + 991 + .feed-item { 992 + padding: var(--space-3) var(--space-4); 993 + } 994 + 995 + .feed-item__text { 996 + font-size: 0.95rem; 997 + } 998 + } 999 + 1000 + /* ─── Accessibility ─── */ 1001 + @media (prefers-reduced-motion: reduce) { 1002 + *, 1003 + *::before, 1004 + *::after { 1005 + animation-duration: 0.01ms !important; 1006 + animation-iteration-count: 1 !important; 1007 + transition-duration: 0.01ms !important; 1008 + scroll-behavior: auto !important; 1009 + } 1010 + }
+107 -56
src/gpreview.gleam
··· 1 - import gleam/option.{None, Some} 1 + import gleam/list 2 + import gleam/option 2 3 import gpreview/effects 3 - import gpreview/record.{Record} 4 4 import gpreview/types.{ 5 - type Model, type Msg, App, LinkWasSet, MiniDocWasResolved, PostWasFetched, 6 - ProfileWasFetched, ThreadWasFetched, UserClickedShow, 5 + type Model, type Msg, App, FeedFailed, FeedLoaded, FeedLoading, 6 + IdentityResolved, InputChanged, Post, PostsFetched, ProfileFailed, 7 + ProfileFetched, ProfileLoaded, ProfileLoading, RetryFetch, SubmitInput, 7 8 } 8 9 import gpreview/views 9 10 import lustre ··· 37 38 fn init(_args: Nil) -> #(Model, Effect(Msg)) { 38 39 #( 39 40 App( 40 - "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c", 41 - None, 42 - None, 43 - None, 44 - None, 41 + input_text: "", 42 + identity: option.None, 43 + profile_state: types.ProfileEmpty, 44 + feed_state: types.FeedEmpty, 45 + retry_count: 0, 45 46 ), 46 47 effect.none(), 47 48 ) ··· 49 50 50 51 pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 51 52 case msg { 52 - LinkWasSet(url) -> #(App(..model, at_url: url), effect.none()) 53 - UserClickedShow -> 54 - case effects.extract_did_from_uri(model.at_url) { 55 - Ok(did) -> #(model, effects.resolve_mini_doc(did)) 56 - Error(_) -> #( 57 - App(..model, post: Some(Error("Invalid AT-URI"))), 58 - effect.none(), 59 - ) 60 - } 61 - MiniDocWasResolved(Ok(mini_doc)) -> #( 62 - App(..model, did_doc: Some(Ok(mini_doc))), 63 - effects.get_record(mini_doc.pds, model.at_url), 53 + IdentityResolved(Ok(identity)) -> #( 54 + App(..model, identity: option.Some(identity)), 55 + effect.batch([ 56 + effects.fetch_profile(identity.pds, identity.did), 57 + effects.fetch_posts(identity.pds, identity.did, 10), 58 + ]), 64 59 ) 65 - MiniDocWasResolved(Error(e)) -> #( 60 + IdentityResolved(Error(_e)) -> #( 66 61 App( 67 62 ..model, 68 - post: Some(Error( 69 - "Failed to resolve identity: " <> types.error_to_string(e), 70 - )), 63 + profile_state: ProfileFailed("Failed to resolve identity"), 64 + feed_state: FeedFailed("Failed to resolve identity"), 71 65 ), 72 66 effect.none(), 73 67 ) 74 - PostWasFetched(Ok(p)) -> { 75 - case model.did_doc { 76 - Some(Ok(doc)) -> #( 77 - App(..model, post: Some(Ok(p))), 78 - effects.fetch_profile(doc.pds, doc.did), 79 - ) 80 - _ -> #(App(..model, post: Some(Ok(p))), effect.none()) 81 - } 82 - } 83 - PostWasFetched(Error(e)) -> #( 84 - App(..model, post: Some(Error(types.error_to_string(e)))), 68 + ProfileFetched(Ok(profile)) -> #( 69 + App(..model, profile_state: ProfileLoaded(profile)), 85 70 effect.none(), 86 71 ) 87 - ProfileWasFetched(Ok(p)) -> { 88 - case model.post { 89 - Some(Ok(Record(uri: uri, ..))) -> 90 - case model.did_doc { 91 - Some(Ok(doc)) -> #( 92 - App(..model, profile: Some(Ok(p.value))), 93 - effects.fetch_thread_counts(doc.pds, uri), 94 - ) 95 - _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) 96 - } 97 - _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) 98 - } 99 - } 100 - ProfileWasFetched(Error(e)) -> #( 72 + ProfileFetched(Error(e)) -> #( 101 73 App( 102 74 ..model, 103 - profile: Some(Error( 104 - "Failed to fetch profile: " <> types.error_to_string(e), 105 - )), 75 + profile_state: ProfileFailed(friendly_error_message(e, "profile")), 106 76 ), 107 77 effect.none(), 108 78 ) 109 - ThreadWasFetched(Ok(counts)) -> #( 110 - App(..model, thread_counts: Some(counts)), 79 + PostsFetched(Ok(posts)) -> { 80 + let post_records = 81 + posts 82 + |> list.map(fn(r) { 83 + Post( 84 + uri: r.uri, 85 + cid: r.cid, 86 + text: r.value.text, 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 + embed: r.value.embed, 93 + ) 94 + }) 95 + #(App(..model, feed_state: FeedLoaded(post_records)), effect.none()) 96 + } 97 + PostsFetched(Error(e)) -> #( 98 + App(..model, feed_state: FeedFailed(friendly_error_message(e, "posts"))), 111 99 effect.none(), 112 100 ) 113 - ThreadWasFetched(Error(_e)) -> #(model, effect.none()) 101 + InputChanged(text) -> #(App(..model, input_text: text), effect.none()) 102 + SubmitInput -> 103 + case model.input_text { 104 + "" -> #(model, effect.none()) 105 + input -> 106 + case effects.is_did(input) { 107 + True -> #( 108 + App( 109 + ..model, 110 + profile_state: ProfileLoading, 111 + feed_state: FeedLoading, 112 + retry_count: 0, 113 + ), 114 + effects.resolve_identity(input), 115 + ) 116 + False -> #( 117 + App( 118 + ..model, 119 + profile_state: ProfileLoading, 120 + feed_state: FeedLoading, 121 + retry_count: 0, 122 + ), 123 + effects.resolve_identity(input), 124 + ) 125 + } 126 + } 127 + RetryFetch -> { 128 + let last_input = model.input_text 129 + case last_input { 130 + "" -> #(model, effect.none()) 131 + input -> #( 132 + App( 133 + ..model, 134 + profile_state: ProfileLoading, 135 + feed_state: FeedLoading, 136 + retry_count: model.retry_count + 1, 137 + ), 138 + effects.resolve_identity(input), 139 + ) 140 + } 141 + } 142 + } 143 + } 144 + 145 + fn friendly_error_message(e: rsvp.Error, context: String) -> String { 146 + case e { 147 + rsvp.NetworkError -> "Could not connect. Check your internet connection." 148 + rsvp.BadUrl(_) -> "Invalid identifier. Please check and try again." 149 + rsvp.HttpError(resp) -> 150 + case resp.status { 151 + 404 -> 152 + case context { 153 + "profile" -> "Profile not found. Please check the identifier." 154 + "posts" -> "No posts found for this user." 155 + _ -> "Not found. Please check the identifier." 156 + } 157 + _ -> "Service temporarily unavailable. Please try again." 158 + } 159 + rsvp.BadBody -> "Invalid response from server. Please try again." 160 + rsvp.JsonError(details) -> 161 + "Failed to parse post data: " 162 + <> types.json_decode_error_to_string(details) 163 + rsvp.UnhandledResponse(_) -> 164 + "Service temporarily unavailable. Please try again." 114 165 } 115 166 }
+53 -45
src/gpreview/effects.gleam
··· 1 1 import bsky/decoders 2 2 import gleam/dynamic/decode.{type Decoder, field, string, success} 3 - import gleam/option.{None, Some} 3 + import gleam/int 4 4 import gleam/string 5 5 import gleam/uri 6 - import gpreview/record.{type Record, Record} 7 6 import gpreview/types.{ 8 - type Msg, MiniDocWasResolved, PostWasFetched, ProfileWasFetched, 9 - ThreadWasFetched, 7 + type Msg, Identity, IdentityResolved, PostsFetched, ProfileFetched, 10 8 } 11 9 import lustre/effect.{type Effect} 12 10 import rsvp 13 11 14 12 const slingshot_base = "https://slingshot.microcosm.blue" 15 13 16 - pub fn resolve_mini_doc(identifier: String) -> Effect(Msg) { 14 + pub fn is_did(identifier: String) -> Bool { 15 + string.starts_with(identifier, "did:") 16 + } 17 + 18 + pub fn resolve_identity(identifier: String) -> Effect(Msg) { 17 19 rsvp.get( 18 20 slingshot_base 19 21 <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 20 - <> identifier, 21 - rsvp.expect_json(decoders.decode_mini_doc(), MiniDocWasResolved), 22 + <> uri.percent_encode(identifier), 23 + rsvp.expect_json(decoders.decode_mini_doc(), fn(result) { 24 + case result { 25 + Ok(mini_doc) -> { 26 + IdentityResolved( 27 + Ok(Identity( 28 + did: mini_doc.did, 29 + handle: mini_doc.handle, 30 + pds: mini_doc.pds, 31 + signing_key: mini_doc.signing_key, 32 + )), 33 + ) 34 + } 35 + Error(_e) -> { 36 + IdentityResolved(Error("Failed to resolve identity")) 37 + } 38 + } 39 + }), 22 40 ) 23 41 } 24 42 25 - pub fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { 26 - case query_from_at_uri(at_url) { 27 - Error(Nil) -> { 28 - use dispatch <- effect.from 29 - dispatch(PostWasFetched(Error(rsvp.BadBody))) 30 - } 31 - Ok(query) -> { 32 - let url = pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> query 33 - rsvp.get( 34 - url, 35 - rsvp.expect_json( 36 - decode_get_record_response(decoders.decode_post()), 37 - PostWasFetched, 38 - ), 39 - ) 40 - } 41 - } 42 - } 43 - 44 43 pub fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { 45 44 rsvp.get( 46 45 pds_host ··· 48 47 <> construct_profile_uri(did), 49 48 rsvp.expect_json( 50 49 decode_get_record_response(decoders.decode_profile()), 51 - ProfileWasFetched, 50 + ProfileFetched, 52 51 ), 53 52 ) 54 53 } 55 54 56 - pub fn fetch_thread_counts(_pds_host: String, url: String) -> Effect(Msg) { 57 - let url = 58 - "https://public.api.bsky.app" 59 - <> "/xrpc/app.bsky.feed.getPostThread?uri=" 60 - <> url |> uri.percent_encode 61 - <> "&depth=0" 62 - rsvp.get(url, rsvp.expect_json(decode_thread_response(), ThreadWasFetched)) 63 - } 55 + pub fn fetch_posts(pds: String, did: String, limit: Int) -> Effect(Msg) { 56 + let query = 57 + uri.query_to_string([ 58 + #("repo", did), 59 + #("collection", "app.bsky.feed.post"), 60 + #("limit", int.to_string(limit)), 61 + ]) 64 62 65 - fn decode_thread_response() -> Decoder(decoders.ThreadCountsJson) { 66 - use thread <- field("thread", decoders.decode_thread_view()) 67 - case thread.post { 68 - Some(post_view) -> success(post_view.counts) 69 - None -> success(decoders.ThreadCountsJson(None, None, None, None)) 70 - } 63 + rsvp.get( 64 + pds <> "/xrpc/com.atproto.repo.listRecords?" <> query, 65 + rsvp.expect_json( 66 + decoders.decode_list_records_response(decoders.decode_post()), 67 + fn(result) { 68 + case result { 69 + Ok(response) -> { 70 + PostsFetched(Ok(response.records)) 71 + } 72 + Error(e) -> { 73 + PostsFetched(Error(e)) 74 + } 75 + } 76 + }, 77 + ), 78 + ) 71 79 } 72 80 73 81 pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { ··· 106 114 get_record_query(did, "app.bsky.actor.profile", "self") 107 115 } 108 116 109 - fn decode_get_record_response(decoder: Decoder(a)) -> Decoder(Record(a)) { 110 - use uri <- field("uri", string) 111 - use cid <- field("cid", string) 117 + fn decode_get_record_response(decoder: Decoder(a)) -> Decoder(a) { 118 + use _uri <- field("uri", string) 119 + use _cid <- field("cid", string) 112 120 use value <- field("value", decoder) 113 - success(Record(uri:, cid:, value:)) 121 + success(value) 114 122 }
+73 -12
src/gpreview/types.gleam
··· 1 1 import bsky/decoders 2 + import gleam/dynamic/decode 2 3 import gleam/int 4 + import gleam/json 3 5 import gleam/option.{type Option} 6 + import gleam/string 4 7 import gpreview/record.{type Record} 5 8 import rsvp 6 9 10 + pub type ProfileState { 11 + ProfileEmpty 12 + ProfileLoading 13 + ProfileLoaded(decoders.ProfileJson) 14 + ProfileFailed(String) 15 + } 16 + 17 + pub type Post { 18 + Post( 19 + uri: String, 20 + cid: String, 21 + text: String, 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 + embed: Option(decoders.Embed), 28 + ) 29 + } 30 + 31 + pub type FeedState { 32 + FeedEmpty 33 + FeedLoading 34 + FeedLoaded(List(Post)) 35 + FeedFailed(String) 36 + } 37 + 7 38 pub type Model { 8 39 App( 9 - at_url: String, 10 - did_doc: Option(Result(decoders.MiniDocJson, String)), 11 - post: Option(Result(Record(decoders.PostJson), String)), 12 - profile: Option(Result(decoders.ProfileJson, String)), 13 - thread_counts: Option(decoders.ThreadCountsJson), 40 + input_text: String, 41 + identity: Option(Identity), 42 + profile_state: ProfileState, 43 + feed_state: FeedState, 44 + retry_count: Int, 14 45 ) 15 46 } 16 47 48 + pub type Identity { 49 + Identity(did: String, handle: String, pds: String, signing_key: String) 50 + } 51 + 17 52 pub type Msg { 18 - LinkWasSet(String) 19 - UserClickedShow 20 - MiniDocWasResolved(Result(decoders.MiniDocJson, rsvp.Error)) 21 - PostWasFetched(Result(Record(decoders.PostJson), rsvp.Error)) 22 - ProfileWasFetched(Result(Record(decoders.ProfileJson), rsvp.Error)) 23 - ThreadWasFetched(Result(decoders.ThreadCountsJson, rsvp.Error)) 53 + IdentityResolved(Result(Identity, String)) 54 + ProfileFetched(Result(decoders.ProfileJson, rsvp.Error)) 55 + PostsFetched(Result(List(Record(decoders.PostJson)), rsvp.Error)) 56 + InputChanged(String) 57 + SubmitInput 58 + RetryFetch 59 + } 60 + 61 + pub fn json_decode_error_to_string(e: json.DecodeError) -> String { 62 + case e { 63 + json.UnexpectedEndOfInput -> "Unexpected end of input" 64 + json.UnexpectedByte(found) -> "Unexpected byte: " <> found 65 + json.UnexpectedSequence(found) -> "Unexpected sequence: " <> found 66 + json.UnableToDecode(errors) -> 67 + case errors { 68 + [] -> "Unable to decode" 69 + [first, ..] -> decode_error_to_string(first) 70 + } 71 + } 72 + } 73 + 74 + fn decode_error_to_string(e: decode.DecodeError) -> String { 75 + case e { 76 + decode.DecodeError(expected:, found:, path:) -> { 77 + let path_str = case path { 78 + [] -> "" 79 + _ -> " at " <> string.join(path, ".") 80 + } 81 + "Expected " <> expected <> " but found " <> found <> path_str 82 + } 83 + } 24 84 } 25 85 26 86 pub fn error_to_string(e: rsvp.Error) -> String { ··· 28 88 rsvp.BadBody -> "Invalid response body" 29 89 rsvp.BadUrl(url) -> "Invalid URL: " <> url 30 90 rsvp.HttpError(resp) -> "HTTP error: " <> int.to_string(resp.status) 31 - rsvp.JsonError(_) -> "Failed to parse JSON response" 91 + rsvp.JsonError(details) -> 92 + "Failed to parse JSON: " <> json_decode_error_to_string(details) 32 93 rsvp.NetworkError -> "Network error - check your connection" 33 94 rsvp.UnhandledResponse(resp) -> 34 95 "Unexpected response: " <> int.to_string(resp.status)
+412 -425
src/gpreview/views.gleam
··· 1 - import bsky/decoders 1 + import bsky/decoders.{type ProfileJson} 2 2 import gleam/int 3 3 import gleam/list 4 4 import gleam/option.{type Option, None, Some} 5 - import gleam/result 6 5 import gleam/string 7 - import gpreview/effects 8 - import gpreview/record.{Record} 9 - import gpreview/types.{type Model, type Msg, LinkWasSet, UserClickedShow} 6 + 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, 11 + } 10 12 import lustre/attribute 11 13 import lustre/element.{type Element} 12 14 import lustre/element/html ··· 14 16 15 17 pub fn view(model: Model) -> Element(Msg) { 16 18 html.div([attribute.attribute("class", "app-shell")], [ 17 - input_zone(model), 18 - content_area(model), 19 + render_input_zone(model), 20 + html.div([attribute.attribute("class", "main-content")], [ 21 + render_profile_card(model.profile_state, model.identity), 22 + render_feed(model.feed_state), 23 + ]), 19 24 ]) 20 25 } 21 26 22 - fn input_zone(model: Model) -> Element(Msg) { 27 + pub fn render_input_zone(model: Model) -> Element(Msg) { 23 28 html.div([attribute.attribute("class", "input-zone")], [ 24 - html.div([attribute.attribute("class", "input-zone__row")], [ 25 - html.input([ 26 - event.on_change(LinkWasSet), 27 - attribute.inputmode("text"), 28 - attribute.value(model.at_url), 29 - attribute.attribute( 30 - "placeholder", 31 - "at://did:plc:.../app.bsky.feed.post/...", 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")], 32 52 ), 33 - attribute.attribute("class", "input-field"), 34 - ]), 35 - html.button( 36 - [ 37 - event.on_click(UserClickedShow), 38 - attribute.attribute("class", "btn-show"), 39 - ], 40 - [html.text("Show")], 41 - ), 42 - ]), 43 - elem_or_none( 44 - case model.post { 45 - Some(Error(e)) -> Some(e) 46 - _ -> None 47 - }, 48 - error_badge, 53 + ], 49 54 ), 50 55 ]) 51 56 } 52 57 53 - pub fn content_area(model: Model) -> Element(Msg) { 54 - case model.post { 55 - None -> empty_state() 56 - Some(Ok(_)) -> 57 - case model.profile { 58 - Some(Ok(_)) -> render_post_card(model) 59 - _ -> loading_skeleton() 60 - } 61 - Some(Error(e)) -> error_state(e) 62 - } 63 - |> with_profile_error(model) 64 - } 65 - 66 - fn with_profile_error(el: Element(Msg), model: Model) -> Element(Msg) { 67 - case model.profile { 68 - Some(Error(e)) -> error_state(e) 69 - _ -> el 58 + pub fn render_profile_card( 59 + profile_state: ProfileState, 60 + identity: Option(Identity), 61 + ) -> 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) 70 67 } 71 68 } 72 69 73 - pub fn empty_state() -> Element(Msg) { 74 - html.div([attribute.attribute("class", "empty-state")], [ 75 - html.h2([attribute.attribute("class", "empty-state__title")], [ 76 - html.text("Preview a Bluesky post"), 77 - ]), 78 - html.p([attribute.attribute("class", "empty-state__desc")], [ 79 - html.text("Paste an AT-URI above to see it rendered here"), 80 - ]), 81 - ]) 82 - } 83 - 84 - pub fn loading_skeleton() -> Element(Msg) { 85 - html.div([attribute.attribute("class", "loading-skeleton")], [ 86 - html.div([attribute.attribute("class", "post-header")], [ 87 - html.div([attribute.attribute("class", "skeleton-circle")], []), 88 - html.div([attribute.attribute("class", "post-header__info")], [ 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")], [ 89 88 html.div( 90 - [attribute.attribute("class", "skeleton-line skeleton-line-md")], 89 + [ 90 + attribute.attribute( 91 + "class", 92 + "profile-avatar profile-avatar--skeleton", 93 + ), 94 + ], 91 95 [], 92 96 ), 93 - html.div( 94 - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 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 + ]), 97 110 ]), 98 - ]), 99 - html.div([attribute.attribute("class", "post-body")], [ 100 - html.div([attribute.attribute("class", "skeleton-line")], []), 101 - html.div([attribute.attribute("class", "skeleton-line")], []), 102 - html.div( 103 - [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 104 - [], 105 - ), 106 - ]), 107 - ]) 108 - } 109 - 110 - pub fn error_state(msg: String) -> Element(Msg) { 111 - html.div([attribute.attribute("class", "error-state")], [ 112 - html.text("Error: " <> msg), 113 - ]) 114 - } 115 - 116 - fn error_badge(msg: String) -> Element(Msg) { 117 - html.p( 118 - [ 119 - attribute.attribute("class", "error-badge"), 120 111 ], 121 - [html.text(msg)], 122 112 ) 123 113 } 124 114 125 - fn render_post_card(model: Model) -> Element(Msg) { 126 - case model.post, model.profile, model.did_doc { 127 - Some(Ok(Record(value: post, ..))), Some(Ok(profile)), Some(Ok(doc)) -> 128 - post_card(post, profile, doc, model.thread_counts) 129 - _, _, _ -> element.none() 130 - } 131 - } 132 - 133 - fn post_card( 134 - post: decoders.PostJson, 135 - profile: decoders.ProfileJson, 136 - doc: decoders.MiniDocJson, 137 - thread_counts: Option(decoders.ThreadCountsJson), 115 + fn render_full_profile_card( 116 + profile: ProfileJson, 117 + identity: Option(Identity), 138 118 ) -> Element(Msg) { 139 - html.div([attribute.attribute("class", "post-card stagger-1")], [ 140 - post_header(profile, doc.handle, doc.did, doc.pds), 141 - html.div([attribute.attribute("class", "post-body stagger-2")], [ 142 - reply_context(post.reply), 143 - html.p([], render_post_with_facets(post.text, post.facets, doc.pds)), 144 - post_embed(post.embed, doc.pds, doc.did), 145 - post_footer(post.labels, post.tags, post.created_at, thread_counts), 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 + ]), 146 128 ]), 147 129 ]) 148 130 } 149 131 150 - fn elem_or_none( 151 - value: Option(a), 152 - f: fn(a) -> element.Element(b), 153 - ) -> element.Element(b) { 154 - value |> option.map(f) |> option.unwrap(element.none()) 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 + } 155 144 } 156 145 157 - fn reply_context(reply: Option(decoders.ReplyRefJson)) -> Element(Msg) { 158 - elem_or_none(reply, fn(reply_ref) { 159 - let parent_did = 160 - reply_ref.parent.uri 161 - |> effects.extract_did_from_uri 162 - |> result.unwrap("unknown") 163 - 164 - html.div([attribute.attribute("class", "reply-context")], [ 165 - html.span([attribute.attribute("class", "reply-context__label")], [ 166 - html.text("Replying to " <> string.slice(parent_did, 0, 20) <> "..."), 167 - ]), 168 - ]) 169 - }) 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 + } 170 181 } 171 182 172 - fn post_header( 173 - profile: decoders.ProfileJson, 174 - handle: String, 175 - did: String, 176 - pds_host: String, 183 + fn render_profile_avatar( 184 + avatar: Option(String), 185 + identity: Option(Identity), 177 186 ) -> Element(Msg) { 178 - html.div([attribute.attribute("class", "post-header")], [ 179 - avatar_element(profile, pds_host, did), 180 - html.div([attribute.attribute("class", "post-header__info")], [ 181 - display_name(profile.display_name), 182 - html.span( 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( 183 212 [ 184 - attribute.attribute("class", "post-header__handle"), 213 + attribute.attribute( 214 + "class", 215 + "profile-avatar profile-avatar--fallback", 216 + ), 185 217 ], 186 - [html.text("@" <> handle)], 187 - ), 188 - description_line(profile.description), 189 - ]), 190 - ]) 218 + [], 219 + ) 220 + } 191 221 } 192 222 193 - fn avatar_element( 194 - profile: decoders.ProfileJson, 195 - pds_host: String, 196 - did: String, 197 - ) -> Element(Msg) { 198 - case profile.avatar { 199 - Some(blob) -> 200 - html.img([ 201 - attribute.attribute("src", blob_ref_to_url(pds_host, did, blob)), 202 - attribute.attribute("alt", "Avatar"), 203 - attribute.attribute("class", "avatar-ring"), 204 - attribute.attribute("referrerpolicy", "no-referrer"), 205 - ]) 206 - None -> { 207 - html.div([attribute.attribute("class", "avatar-fallback")], [ 208 - html.text( 209 - profile.display_name 210 - |> option.map(fn(n: String) { string.slice(n, 0, 1) }) 211 - |> option.unwrap("@"), 212 - ), 213 - ]) 214 - } 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 + ) 215 232 } 216 233 } 217 234 218 - fn display_name(name: Option(String)) -> Element(Msg) { 219 - elem_or_none(name, fn(n) { 220 - html.span( 221 - [ 222 - attribute.attribute("class", "post-header__name"), 223 - ], 224 - [html.text(n)], 225 - ) 226 - }) 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 + } 227 241 } 228 242 229 - fn description_line(desc: Option(String)) -> Element(Msg) { 230 - elem_or_none(desc, fn(d) { 231 - html.p( 232 - [ 233 - attribute.attribute("class", "post-header__bio"), 234 - ], 235 - [html.text(d)], 236 - ) 237 - }) 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 + } 238 251 } 239 252 240 - pub fn blob_ref_to_url(pds_host: String, did: String, blob: String) -> String { 241 - pds_host <> "/xrpc/com.atproto.sync.getBlob?did=" <> did <> "&cid=" <> blob 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 + ) 242 277 } 243 278 244 - pub fn render_post_with_facets( 245 - text: String, 246 - facets: Option(List(decoders.FacetJson)), 247 - pds_host: String, 248 - ) -> List(Element(Msg)) { 249 - case facets { 250 - None -> [html.text(text)] 251 - Some(f) -> { 252 - let sorted = 253 - list.sort(f, fn(a, b) { 254 - int.compare(a.index.byte_start, b.index.byte_start) 255 - }) 256 - build_facet_elements(text, sorted, 0, pds_host) 257 - } 279 + fn format_timestamp(iso_timestamp: String) -> String { 280 + case string.split(iso_timestamp, "T") { 281 + [date_part, ..] -> date_part 282 + _ -> iso_timestamp 258 283 } 259 284 } 260 285 261 - fn build_facet_elements( 262 - text: String, 263 - facets: List(decoders.FacetJson), 264 - byte_offset: Int, 265 - pds_host: String, 266 - ) -> List(Element(Msg)) { 267 - case facets { 268 - [] -> { 269 - let remaining = 270 - string.slice(text, byte_offset, string.length(text) - byte_offset) 271 - case string.is_empty(remaining) { 272 - True -> [] 273 - False -> [html.text(remaining)] 274 - } 275 - } 276 - [facet, ..rest] -> { 277 - let start = facet.index.byte_start 278 - let end = facet.index.byte_end 279 - 280 - let before = case start > byte_offset { 281 - True -> { 282 - let segment = string.slice(text, byte_offset, start - byte_offset) 283 - case string.is_empty(segment) { 284 - True -> [] 285 - False -> [html.text(segment)] 286 - } 287 - } 288 - False -> [] 289 - } 290 - 291 - let facet_text = string.slice(text, start, end - start) 292 - let facet_elements = 293 - build_facet_element(facet.features, facet_text, pds_host) 294 - 295 - let after = build_facet_elements(text, rest, end, pds_host) 296 - 297 - list.append(before, list.append(facet_elements, after)) 298 - } 286 + pub fn render_feed(feed_state: FeedState) -> Element(Msg) { 287 + case feed_state { 288 + FeedEmpty -> html.div([attribute.attribute("class", "feed-container")], []) 289 + FeedLoading -> feed_loading_skeleton() 290 + FeedLoaded(posts) -> render_feed_loaded(posts) 291 + FeedFailed(error) -> feed_error_state(error) 299 292 } 300 293 } 301 294 302 - fn build_facet_element( 303 - features: List(decoders.FacetFeature), 304 - text: String, 305 - _pds_host: String, 306 - ) -> List(Element(Msg)) { 307 - case features { 308 - [] -> [html.text(text)] 309 - [feature, ..] -> { 310 - case feature { 311 - decoders.Mention(did) -> { 312 - let profile_url = "https://bsky.app/profile/" <> did 313 - [ 314 - html.a( 315 - [ 316 - attribute.attribute("href", profile_url), 317 - attribute.attribute("target", "_blank"), 318 - attribute.attribute("rel", "noopener noreferrer"), 319 - attribute.attribute("class", "mention-link"), 320 - ], 321 - [html.text(text)], 322 - ), 323 - ] 324 - } 325 - decoders.Link(uri) -> [ 326 - html.a( 327 - [ 328 - attribute.attribute("href", uri), 329 - attribute.attribute("target", "_blank"), 330 - attribute.attribute("rel", "noopener noreferrer"), 331 - attribute.attribute("class", "link-facet"), 332 - ], 333 - [html.text(text)], 334 - ), 335 - ] 336 - decoders.Tag(tag) -> { 337 - let tag_url = "https://bsky.app/hashtag/" <> tag 338 - [ 339 - html.span( 340 - [ 341 - attribute.attribute("class", "tag-link"), 342 - ], 343 - [ 344 - html.a( 345 - [ 346 - attribute.attribute("href", tag_url), 347 - attribute.attribute("target", "_blank"), 348 - attribute.attribute("rel", "noopener noreferrer"), 349 - ], 350 - [html.text("#" <> tag)], 351 - ), 352 - ], 353 - ), 354 - ] 355 - } 356 - } 357 - } 358 - } 295 + fn feed_loading_skeleton() -> Element(Msg) { 296 + html.div([attribute.attribute("class", "feed-container")], [ 297 + html.div([attribute.attribute("class", "feed-loading-header")], [ 298 + html.div([attribute.attribute("class", "loading-text")], [ 299 + html.text("Loading posts..."), 300 + ]), 301 + ]), 302 + feed_item_skeleton(1), 303 + feed_item_skeleton(2), 304 + feed_item_skeleton(3), 305 + feed_item_skeleton(4), 306 + feed_item_skeleton(5), 307 + ]) 359 308 } 360 309 361 - fn post_embed( 362 - embed: Option(decoders.Embed), 363 - pds_host: String, 364 - did: String, 365 - ) -> Element(Msg) { 366 - elem_or_none(embed, fn(embed_obj) { 367 - case embed_obj { 368 - decoders.Images(images) -> 369 - html.div([attribute.attribute("class", "post-embed")], [ 310 + fn feed_item_skeleton(index: Int) -> Element(Msg) { 311 + let stagger_class = "stagger-" <> int.to_string(index) 312 + html.div( 313 + [ 314 + attribute.attribute( 315 + "class", 316 + "feed-item feed-item--loading " <> stagger_class, 317 + ), 318 + ], 319 + [ 320 + html.div([attribute.attribute("class", "feed-item__header")], [ 321 + html.div([attribute.attribute("class", "skeleton-circle")], []), 322 + html.div([attribute.attribute("class", "feed-item__header-info")], [ 370 323 html.div( 371 - [attribute.attribute("class", "image-grid")], 372 - list.map(images, fn(img) { 373 - html.img([ 374 - attribute.attribute( 375 - "src", 376 - blob_ref_to_url(pds_host, did, img.ref), 377 - ), 378 - attribute.attribute("alt", img.alt), 379 - attribute.attribute("class", "image-cover"), 380 - attribute.attribute("referrerpolicy", "no-referrer"), 381 - ]) 382 - }), 324 + [attribute.attribute("class", "skeleton-line skeleton-line-md")], 325 + [], 326 + ), 327 + html.div( 328 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 329 + [], 383 330 ), 384 - ]) 385 - decoders.ExternalLink(external) -> 386 - html.div([attribute.attribute("class", "post-embed")], [ 331 + ]), 332 + ]), 333 + html.div([attribute.attribute("class", "feed-item__body")], [ 334 + html.div([attribute.attribute("class", "skeleton-line")], []), 335 + html.div([attribute.attribute("class", "skeleton-line")], []), 336 + ]), 337 + html.div([attribute.attribute("class", "feed-item__footer")], [ 338 + html.div( 339 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 340 + [], 341 + ), 342 + ]), 343 + ], 344 + ) 345 + } 346 + 347 + fn render_feed_loaded(posts: List(Post)) -> Element(Msg) { 348 + let elements = 349 + posts 350 + |> list.index_map(fn(post, index) { render_feed_item(post, index) }) 351 + 352 + html.div([attribute.attribute("class", "feed-container")], elements) 353 + } 354 + 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 + ) 372 + } 373 + Some(decoders.ExternalLink(ext)) -> { 374 + html.div( 375 + [attribute.attribute("class", "post-embed post-embed--external")], 376 + [ 387 377 html.a( 388 378 [ 389 - attribute.attribute("href", external.uri), 379 + attribute.attribute("href", ext.uri), 380 + attribute.attribute("class", "external-link"), 390 381 attribute.attribute("target", "_blank"), 391 382 attribute.attribute("rel", "noopener noreferrer"), 392 - attribute.attribute("class", "link-card"), 393 383 ], 394 384 [ 395 - html.div( 396 - [ 397 - attribute.attribute("class", "external-link-preview"), 398 - ], 399 - [ 400 - html.h3( 401 - [ 402 - attribute.attribute("class", "external-link-title"), 403 - ], 404 - [html.text(external.title)], 405 - ), 406 - html.p( 407 - [ 408 - attribute.attribute("class", "external-link-desc"), 409 - ], 410 - [html.text(external.description)], 411 - ), 412 - ], 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)], 413 391 ), 414 392 ], 415 393 ), 416 - ]) 417 - decoders.Record(r) -> html.text(r.record.uri) 394 + ], 395 + ) 418 396 } 419 - }) 420 - } 421 - 422 - fn post_footer( 423 - labels: Option(List(decoders.LabelJson)), 424 - tags: Option(List(String)), 425 - created_at: String, 426 - thread_counts: Option(decoders.ThreadCountsJson), 427 - ) -> Element(Msg) { 428 - let badges = 429 - labels 430 - |> option.unwrap([]) 431 - |> list.map(fn(label) { 432 - html.span([attribute.attribute("class", "badge")], [ 433 - html.text(label.val), 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 + ]), 434 404 ]) 435 - }) 436 - 437 - let tag_badges = 438 - tags 439 - |> option.unwrap([]) 440 - |> list.map(fn(tag) { 441 - html.a( 405 + } 406 + Some(decoders.RecordWithMedia(media)) -> { 407 + html.div( 442 408 [ 443 - attribute.attribute("href", "https://bsky.app/hashtag/" <> tag), 444 - attribute.attribute("target", "_blank"), 445 - attribute.attribute("rel", "noopener noreferrer"), 446 - attribute.attribute("class", "tag-badge"), 409 + attribute.attribute( 410 + "class", 411 + "post-embed post-embed--record-with-media", 412 + ), 447 413 ], 448 - [html.text("#" <> tag)], 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 + ], 449 430 ) 450 - }) 451 - 452 - let all_badges = list.append(badges, tag_badges) 453 - 454 - html.div([attribute.attribute("class", "post-footer stagger-3")], [ 455 - engagement_bar(thread_counts), 456 - html.div([attribute.attribute("class", "post-footer__badges")], [ 457 - badge_group(all_badges), 458 - timestamp(created_at), 459 - ]), 460 - ]) 431 + } 432 + } 461 433 } 462 434 463 - fn timestamp(iso_timestamp: String) -> Element(Msg) { 464 - html.span( 435 + fn render_feed_item(post: Post, index: Int) -> Element(Msg) { 436 + let stagger_class = "stagger-" <> int.to_string({ index % 5 } + 1) 437 + let display_text = case string.is_empty(post.text) { 438 + True -> "[No text]" 439 + False -> truncate_text(post.text, 280) 440 + } 441 + html.div( 465 442 [ 466 - attribute.attribute("class", "post-footer__timestamp"), 443 + attribute.attribute("class", "feed-item " <> stagger_class), 467 444 ], 468 - [html.text(format_timestamp(iso_timestamp))], 445 + [ 446 + html.div([attribute.attribute("class", "feed-item__content")], [ 447 + html.p([attribute.attribute("class", "feed-item__text")], [ 448 + html.text(display_text), 449 + ]), 450 + render_post_embed(post.embed), 451 + ]), 452 + html.div([attribute.attribute("class", "feed-item__footer")], [ 453 + html.span([attribute.attribute("class", "feed-item__timestamp")], [ 454 + html.text(format_timestamp(post.created_at)), 455 + ]), 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 + ]), 469 + ], 469 470 ) 470 471 } 471 472 472 - fn engagement_bar( 473 - thread_counts: Option(decoders.ThreadCountsJson), 474 - ) -> Element(Msg) { 475 - elem_or_none(thread_counts, fn(counts) { 476 - html.div([attribute.attribute("class", "engagement-bar")], [ 477 - html.span( 478 - [attribute.attribute("class", "engagement-bar__likes tabular-nums")], 479 - [ 480 - html.text( 481 - "♥ " <> counts.like_count |> option.unwrap(0) |> int.to_string, 482 - ), 483 - ], 484 - ), 485 - html.span( 486 - [attribute.attribute("class", "engagement-bar__reposts tabular-nums")], 473 + fn truncate_text(text: String, max_length: Int) -> String { 474 + case string.length(text) > max_length { 475 + True -> string.slice(text, 0, max_length - 3) <> "..." 476 + False -> text 477 + } 478 + } 479 + 480 + fn feed_error_state(error: String) -> Element(Msg) { 481 + html.div( 482 + [ 483 + attribute.attribute("class", "feed-container feed-item--error"), 484 + attribute.attribute("role", "alert"), 485 + ], 486 + [ 487 + html.div( 487 488 [ 488 - html.text( 489 - "↗ " <> counts.repost_count |> option.unwrap(0) |> int.to_string, 490 - ), 489 + attribute.attribute("class", "error-message"), 490 + attribute.attribute("aria-live", "polite"), 491 491 ], 492 + [html.text(error)], 492 493 ), 493 - html.span( 494 - [attribute.attribute("class", "engagement-bar__replies tabular-nums")], 494 + html.button( 495 495 [ 496 - html.text( 497 - "💬 " <> counts.reply_count |> option.unwrap(0) |> int.to_string, 498 - ), 496 + event.on_click(RetryFetch), 497 + attribute.attribute("class", "btn-retry"), 498 + attribute.attribute("aria-label", "Retry loading feed"), 499 499 ], 500 + [html.text("Retry")], 500 501 ), 501 - ]) 502 - }) 503 - } 504 - 505 - fn badge_group(badges: List(Element(Msg))) -> Element(Msg) { 506 - case badges { 507 - [] -> element.none() 508 - _ -> html.div([attribute.attribute("class", "badge-group")], badges) 509 - } 510 - } 511 - 512 - fn format_timestamp(iso_timestamp: String) -> String { 513 - case string.split(iso_timestamp, "T") { 514 - [date_part, ..] -> date_part 515 - _ -> iso_timestamp 516 - } 502 + ], 503 + ) 517 504 }
+194
test/bsky_test.gleam
··· 617 617 counts.quote_count |> should.equal(Some(0)) 618 618 } 619 619 } 620 + 621 + // === ListRecords Tests === 622 + 623 + pub fn decode_list_records_response_test() { 624 + let json_string = 625 + "{\"cursor\":\"3jwuk2orc3e2m\",\"records\":[{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3jwnu5c7vfc2p\",\"cid\":\"bafyreigsbfey3jzn5bz5qlhjwrvvkjfhsnl6bofkpykgdjkjwl4vo25zdi\",\"value\":{\"text\":\"you're so toxic\",\"$type\":\"app.bsky.feed.post\",\"reply\":{\"root\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"},\"parent\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"}},\"createdAt\":\"2023-05-26T20:24:41.052Z\"}}]}" 626 + 627 + from_json( 628 + json_string, 629 + decoders.decode_list_records_response(decoders.decode_post()), 630 + ) 631 + |> should.be_ok() 632 + |> fn(response) { 633 + response.cursor |> should.equal(Some("3jwuk2orc3e2m")) 634 + list.length(response.records) |> should.equal(1) 635 + let assert [first_post, ..] = response.records 636 + first_post.uri 637 + |> should.equal( 638 + "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3jwnu5c7vfc2p", 639 + ) 640 + first_post.value.text |> should.equal("you're so toxic") 641 + } 642 + } 643 + 644 + pub fn decode_list_records_response_no_cursor_test() { 645 + let json_string = 646 + "{\"records\":[{\"uri\":\"at://did:plc:abc/app.bsky.feed.post/123\",\"cid\":\"bafy123\",\"value\":{\"text\":\"Test post\",\"$type\":\"app.bsky.feed.post\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}}]}" 647 + 648 + from_json( 649 + json_string, 650 + decoders.decode_list_records_response(decoders.decode_post()), 651 + ) 652 + |> should.be_ok() 653 + |> fn(response) { 654 + response.cursor |> should.equal(None) 655 + list.length(response.records) |> should.equal(1) 656 + } 657 + } 658 + 659 + pub fn decode_list_records_response_empty_test() { 660 + let json_string = "{\"records\":[]}" 661 + 662 + from_json( 663 + json_string, 664 + decoders.decode_list_records_response(decoders.decode_post()), 665 + ) 666 + |> should.be_ok() 667 + |> fn(response) { 668 + response.cursor |> should.equal(None) 669 + list.length(response.records) |> should.equal(0) 670 + } 671 + } 672 + 673 + pub fn decode_record_test() { 674 + let json_string = 675 + "{\"uri\":\"at://did:plc:abc/app.bsky.feed.post/123\",\"cid\":\"bafyre123\",\"value\":{\"text\":\"Test\",\"$type\":\"app.bsky.feed.post\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}}" 676 + 677 + from_json(json_string, decoders.decode_record(decoders.decode_post())) 678 + |> should.be_ok() 679 + |> fn(record) { 680 + record.uri |> should.equal("at://did:plc:abc/app.bsky.feed.post/123") 681 + record.cid |> should.equal("bafyre123") 682 + record.value.text |> should.equal("Test") 683 + } 684 + } 685 + 686 + // === Real listRecords fixture from karitham.dev === 687 + 688 + pub fn real_list_records_karitham_test() { 689 + let json_string = 690 + "{\"records\":[{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3jwnu5c7vfc2p\",\"cid\":\"bafyreigsbfey3jzn5bz5qlhjwrvvkjfhsnl6bofkpykgdjkjwl4vo25zdi\",\"value\":{\"text\":\"you're so toxic\",\"$type\":\"app.bsky.feed.post\",\"reply\":{\"root\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"},\"parent\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"}},\"createdAt\":\"2023-05-26T20:24:41.052Z\"}},{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3jwnu5sdkek2d\",\"cid\":\"bafyreia4de27xbcq5bfqzom57grrl46bsnl42ywjf7syfovtp2gszdk3na\",\"value\":{\"text\":\"man got a .net what is this 1998\",\"$type\":\"app.bsky.feed.post\",\"reply\":{\"root\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"},\"parent\":{\"cid\":\"bafyreid2kf5c5kmzffm2dz4l56lobl67uqjqjfs7ga6psblud5bcjznkti\",\"uri\":\"at://did:plc:riz34menmdv5zcqsiytb23xx/app.bsky.feed.post/3jwnsbhngzk2x\"}},\"createdAt\":\"2023-05-26T20:24:57.962Z\"}},{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3jwuk2orc3e2m\",\"cid\":\"bafyreietgor7brlvlzxunurdubhtvp4m5czwyjwhlstmeho64foch4wtga\",\"value\":{\"text\":\"Salut c'est greg\",\"$type\":\"app.bsky.feed.post\",\"createdAt\":\"2023-05-29T12:12:48.405Z\"}}],\"cursor\":\"3jwuk2orc3e2m\"}" 691 + 692 + from_json( 693 + json_string, 694 + decoders.decode_list_records_response(decoders.decode_post()), 695 + ) 696 + |> should.be_ok() 697 + |> fn(response) { 698 + response.cursor |> should.equal(Some("3jwuk2orc3e2m")) 699 + list.length(response.records) |> should.equal(3) 700 + let assert [first_post, ..] = response.records 701 + first_post.value.text |> should.equal("you're so toxic") 702 + first_post.value.created_at |> should.equal("2023-05-26T20:24:41.052Z") 703 + } 704 + } 705 + 706 + // === Extended Profile Tests (with banner and joined_at) === 707 + 708 + pub fn decode_profile_with_banner_test() { 709 + let json_string = 710 + "{\"displayName\":\"Test User\",\"description\":\"A test profile\",\"avatar\":{\"ref\":{\"$link\":\"bafkrei123\"},\"size\":12345,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"banner\":{\"ref\":{\"$link\":\"bafkrei456\"},\"size\":67890,\"$type\":\"blob\",\"mimeType\":\"image/png\"}}" 711 + 712 + from_json(json_string, decoders.decode_profile()) 713 + |> should.be_ok() 714 + |> fn(p) { 715 + p.display_name |> should.equal(Some("Test User")) 716 + p.description |> should.equal(Some("A test profile")) 717 + p.avatar |> should.equal(Some("bafkrei123")) 718 + p.banner |> should.equal(Some("bafkrei456")) 719 + } 720 + } 721 + 722 + pub fn decode_profile_with_joined_at_test() { 723 + let json_string = 724 + "{\"displayName\":\"Test User\",\"createdAt\":\"2023-01-15T10:30:00.000Z\"}" 725 + 726 + from_json(json_string, decoders.decode_profile()) 727 + |> should.be_ok() 728 + |> fn(p) { 729 + p.display_name |> should.equal(Some("Test User")) 730 + p.joined_at |> should.equal(Some("2023-01-15T10:30:00.000Z")) 731 + } 732 + } 733 + 734 + pub fn decode_profile_with_all_fields_test() { 735 + let json_string = 736 + "{\"displayName\":\"Full Profile\",\"description\":\"Complete bio\",\"avatar\":{\"ref\":{\"$link\":\"bafkrei123\"},\"size\":12345,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"banner\":{\"ref\":{\"$link\":\"bafkrei456\"},\"size\":67890,\"$type\":\"blob\",\"mimeType\":\"image/png\"},\"website\":\"https://example.com\",\"pronouns\":\"they/them\",\"createdAt\":\"2023-01-15T10:30:00.000Z\"}" 737 + 738 + from_json(json_string, decoders.decode_profile()) 739 + |> should.be_ok() 740 + |> fn(p) { 741 + p.display_name |> should.equal(Some("Full Profile")) 742 + p.description |> should.equal(Some("Complete bio")) 743 + p.avatar |> should.equal(Some("bafkrei123")) 744 + p.banner |> should.equal(Some("bafkrei456")) 745 + p.joined_at |> should.equal(Some("2023-01-15T10:30:00.000Z")) 746 + } 747 + } 748 + 749 + // === Real profile fixture from karitham.dev with banner === 750 + 751 + pub fn real_profile_with_banner_karitham_test() { 752 + let json_string = 753 + "{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.actor.profile/self\",\"cid\":\"bafyreigr55oyjy2a7kxdnoohbsak4n2ztlvnflyo2g4oqtjqatp3zwiidu\",\"value\":{\"$type\":\"app.bsky.actor.profile\",\"avatar\":{\"ref\":{\"$link\":\"bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje\"},\"size\":257963,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"banner\":{\"ref\":{\"$link\":\"bafkreifbfgrbbwtaopcjz7fnmkfujpdjuhg32moyt7cy6irsvkjsx22pty\"},\"size\":281269,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"website\":\"https://karitham.dev\",\"pronouns\":\"they/them\",\"pinnedPost\":{\"cid\":\"bafyreicxeldxbe56fg7kpkshqmoehpqcqqr5wgltxjymqn7wftxjxyl57m\",\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3lqc6qonwns2f\"},\"description\":\"23 | they/them | computer science (nix, go, k8s), jazz, brutalism, impressionism, anime, science and the people.\\nIf you couldn't tell, my main goals are to Mussolini hang the rich and write a million k8s operators\",\"displayName\":\"karitham\",\"createdAt\":\"2023-05-18T06:42:21.452Z\"}}" 754 + 755 + from_json(json_string, decode_get_record(decoders.decode_profile())) 756 + |> should.be_ok() 757 + |> fn(p) { 758 + p.value.display_name |> should.equal(Some("karitham")) 759 + p.value.description 760 + |> should.equal(Some( 761 + "23 | they/them | computer science (nix, go, k8s), jazz, brutalism, impressionism, anime, science and the people.\nIf you couldn't tell, my main goals are to Mussolini hang the rich and write a million k8s operators", 762 + )) 763 + p.value.avatar 764 + |> should.equal(Some( 765 + "bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje", 766 + )) 767 + p.value.banner 768 + |> should.equal(Some( 769 + "bafkreifbfgrbbwtaopcjz7fnmkfujpdjuhg32moyt7cy6irsvkjsx22pty", 770 + )) 771 + p.value.joined_at |> should.equal(Some("2023-05-18T06:42:21.452Z")) 772 + } 773 + } 774 + 775 + pub fn real_list_records_current_test() { 776 + // Current posts from eurosky.social with modern embed types 777 + let json_string = 778 + "{\"records\":[{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mjpin5txos2f\",\"cid\":\"bafyreidraijkue6bfhlzfv7ridokefqcq44hgz6dntrnnhtbl3faekn6sq\",\"value\":{\"text\":\"Need to get to 67 🤲\",\"$type\":\"app.bsky.feed.post\",\"langs\":[\"en\"],\"reply\":{\"root\":{\"cid\":\"bafyreicpsf4aoulidepilp5ljpb5mjy263pyscyfidbfmsj6kibkxzxfc4\",\"uri\":\"at://did:plc:xbtmt2zjwlrfegqvch7fboei/app.bsky.feed.post/3mjpewqhkok25\"},\"parent\":{\"cid\":\"bafyreie5ndp4pnkuxnsycogo4fk6fypkiedlijkmvfdmcruphy247ahuxm\",\"uri\":\"at://did:plc:xbtmt2zjwlrfegqvch7fboei/app.bsky.feed.post/3mjpiiwgxgs23\"}},\"createdAt\":\"2026-04-17T17:55:07.280Z\"}},{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mjphdlw26c2f\",\"cid\":\"bafyreidzipcuus63vjd676gnviivxa34pait7yq5wlxpxswrqipqs64fla\",\"value\":{\"text\":\"Nice\",\"$type\":\"app.bsky.feed.post\",\"embed\":{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"69 - karitham.dev\",\"image\":{\"ref\":{\"$link\":\"bafkreigqc67vlmx76oxcmpnxpjplvy2gxgpfayn6axpc4d2m5uo2yrmnri\"},\"size\":363456,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"aspectRatio\":{\"width\":1080,\"height\":430}}]},\"langs\":[\"en\"],\"reply\":{\"root\":{\"cid\":\"bafyreicpsf4aoulidepilp5ljpb5mjy263pyscyfidbfmsj6kibkxzxfc4\",\"uri\":\"at://did:plc:xbtmt2zjwlrfegqvch7fboei/app.bsky.feed.post/3mjpewqhkok25\"},\"parent\":{\"cid\":\"bafyreicpsf4aoulidepilp5ljpb5mjy263pyscyfidbfmsj6kibkxzxfc4\",\"uri\":\"at://did:plc:xbtmt2zjwlrfegqvch7fboei/app.bsky.feed.post/3mjpewqhkok25\"}},\"createdAt\":\"2026-04-17T17:31:52.731Z\"}}]}" 779 + 780 + from_json( 781 + json_string, 782 + decoders.decode_list_records_response(decoders.decode_post()), 783 + ) 784 + |> should.be_ok() 785 + |> fn(response) { list.length(response.records) |> should.equal(2) } 786 + } 787 + 788 + pub fn record_with_media_embed_test() { 789 + // Post with recordWithMedia embed from eurosky.social 790 + // Note: The record field contains an app.bsky.embed.record with a nested record field 791 + let json_string = 792 + "{\"records\":[{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mjamyv6slk24\",\"cid\":\"bafyreia3yyhqc4h62i4lhyrvlusiekms3mecbfkb7o6zsy46q4k54wtzzy\",\"value\":{\"text\":\"Test post\",\"$type\":\"app.bsky.feed.post\",\"embed\":{\"$type\":\"app.bsky.embed.recordWithMedia\",\"media\":{\"$type\":\"app.bsky.embed.external\",\"external\":{\"uri\":\"https://example.com\",\"title\":\"Example Title\",\"description\":\"Example description\"}},\"record\":{\"$type\":\"app.bsky.embed.record\",\"record\":{\"cid\":\"bafyreieewnmgydfgtpaofyyxvhg7lljbr56eemqevvmgh4utm7ivq77jlu\",\"uri\":\"at://did:plc:6uqqv7asv2xsxwn224tyexng/app.bsky.feed.post/3mj5kwhz6ys25\"}}},\"langs\":[\"en\"],\"createdAt\":\"2026-04-11T20:03:19.742Z\"}}]}" 793 + 794 + from_json( 795 + json_string, 796 + decoders.decode_list_records_response(decoders.decode_post()), 797 + ) 798 + |> should.be_ok() 799 + |> fn(response) { 800 + list.length(response.records) |> should.equal(1) 801 + let assert [post, ..] = response.records 802 + case post.value.embed { 803 + Some(decoders.RecordWithMedia(rwm)) -> { 804 + rwm.media.title |> should.equal("Example Title") 805 + rwm.record.record.uri 806 + |> should.equal( 807 + "at://did:plc:6uqqv7asv2xsxwn224tyexng/app.bsky.feed.post/3mj5kwhz6ys25", 808 + ) 809 + } 810 + _ -> should.fail() 811 + } 812 + } 813 + }
+194
test/gpreview_test.gleam
··· 1 + import bsky/decoders 2 + import gleam/option 3 + import gleam/string 4 + import gleam/uri 1 5 import gleeunit 6 + import gleeunit/should 7 + import gpreview/effects 8 + import gpreview/types.{ 9 + FeedFailed, FeedLoaded, FeedLoading, InputChanged, App, Post, ProfileFailed, 10 + ProfileLoaded, ProfileLoading, RetryFetch, SubmitInput, 11 + } 2 12 3 13 pub fn main() { 4 14 gleeunit.main() 5 15 } 16 + 17 + // === User flow tests === 18 + 19 + pub fn user_flow_did_load_test() { 20 + let model = 21 + App( 22 + input_text: "did:plc:kcgwlowulc3rac43lregdawo", 23 + identity: option.None, 24 + profile_state: ProfileLoading, 25 + feed_state: FeedLoading, 26 + retry_count: 0, 27 + ) 28 + model.input_text |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") 29 + model.profile_state |> should.equal(ProfileLoading) 30 + } 31 + 32 + pub fn user_flow_handle_resolve_test() { 33 + effects.is_did("karitham.dev") |> should.be_false() 34 + } 35 + 36 + pub fn user_flow_invalid_input_error_test() { 37 + ProfileFailed("Invalid identifier") 38 + |> should.equal(ProfileFailed("Invalid identifier")) 39 + } 40 + 41 + pub fn user_flow_retry_on_error_test() { 42 + RetryFetch |> should.equal(RetryFetch) 43 + } 44 + 45 + // === State management tests === 46 + 47 + pub fn state_management_loading_transitions_test() { 48 + ProfileLoading |> should.equal(ProfileLoading) 49 + FeedLoading |> should.equal(FeedLoading) 50 + } 51 + 52 + pub fn state_management_error_preserves_input_test() { 53 + let model = 54 + App( 55 + input_text: "test input", 56 + identity: option.None, 57 + profile_state: ProfileFailed("Error"), 58 + feed_state: FeedFailed("Error"), 59 + retry_count: 1, 60 + ) 61 + model.input_text |> should.equal("test input") 62 + model.retry_count |> should.equal(1) 63 + } 64 + 65 + pub fn state_management_loaded_contains_data_test() { 66 + let profile = 67 + decoders.ProfileJson( 68 + display_name: option.Some("Test User"), 69 + description: option.Some("A test profile"), 70 + avatar: option.None, 71 + banner: option.None, 72 + joined_at: option.Some("2024-01-01T00:00:00Z"), 73 + ) 74 + let posts = [ 75 + Post( 76 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 77 + cid: "cid1", 78 + text: "Hello", 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 + embed: option.None, 85 + ), 86 + ] 87 + let model = 88 + App( 89 + input_text: "", 90 + identity: option.None, 91 + profile_state: ProfileLoaded(profile), 92 + feed_state: FeedLoaded(posts), 93 + retry_count: 0, 94 + ) 95 + case model.profile_state { 96 + ProfileLoaded(p) -> p.display_name |> should.equal(option.Some("Test User")) 97 + _ -> should.fail() 98 + } 99 + } 100 + 101 + // === Identity resolution tests === 102 + 103 + pub fn identity_is_did_valid_test() { 104 + effects.is_did("did:plc:kcgwlowulc3rac43lregdawo") |> should.be_true() 105 + } 106 + 107 + pub fn identity_is_did_handle_test() { 108 + effects.is_did("karitham.dev") |> should.be_false() 109 + } 110 + 111 + pub fn identity_is_did_web_test() { 112 + effects.is_did("did:web:example.com") |> should.be_true() 113 + } 114 + 115 + pub fn identity_is_did_empty_test() { 116 + effects.is_did("") |> should.be_false() 117 + } 118 + 119 + pub fn identity_url_encoding_test() { 120 + let identifier = "test@example.com" 121 + let encoded = uri.percent_encode(identifier) 122 + encoded |> should.equal("test%40example.com") 123 + } 124 + 125 + // === URI construction tests === 126 + 127 + pub fn uri_fetch_posts_query_test() { 128 + let did = "did:plc:kcgwlowulc3rac43lregdawo" 129 + 130 + let query = 131 + [ 132 + #("repo", did), 133 + #("collection", "app.bsky.feed.post"), 134 + #("limit", "10"), 135 + ] 136 + |> uri.query_to_string() 137 + 138 + string.contains(query, "repo=") |> should.be_true() 139 + string.contains(query, "collection=app.bsky.feed.post") |> should.be_true() 140 + string.contains(query, "limit=10") |> should.be_true() 141 + } 142 + 143 + pub fn uri_fetch_posts_url_test() { 144 + let pds = "https://eurosky.social" 145 + let did = "did:plc:kcgwlowulc3rac43lregdawo" 146 + 147 + let query = 148 + [ 149 + #("repo", did), 150 + #("collection", "app.bsky.feed.post"), 151 + #("limit", "3"), 152 + ] 153 + |> uri.query_to_string() 154 + 155 + let expected_url = pds <> "/xrpc/com.atproto.repo.listRecords?" <> query 156 + string.contains(expected_url, "/xrpc/com.atproto.repo.listRecords?") 157 + |> should.be_true() 158 + } 159 + 160 + pub fn uri_fetch_profile_construction_test() { 161 + let did = "did:plc:kcgwlowulc3rac43lregdawo" 162 + let query = effects.construct_profile_uri(did) 163 + 164 + string.contains(query, "repo=") |> should.be_true() 165 + string.contains(query, "collection=app.bsky.actor.profile") 166 + |> should.be_true() 167 + string.contains(query, "rkey=self") |> should.be_true() 168 + } 169 + 170 + pub fn uri_extract_did_from_at_uri_test() { 171 + let at_uri = "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/abc123" 172 + case effects.extract_did_from_uri(at_uri) { 173 + Ok(did) -> did |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") 174 + Error(_) -> should.fail() 175 + } 176 + } 177 + 178 + pub fn uri_extract_did_without_prefix_test() { 179 + let uri = "did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/abc123" 180 + case effects.extract_did_from_uri(uri) { 181 + Ok(did) -> did |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") 182 + Error(_) -> should.fail() 183 + } 184 + } 185 + 186 + // === Message types tests === 187 + 188 + pub fn msg_input_changed_test() { 189 + InputChanged("user typed this") 190 + |> should.equal(InputChanged("user typed this")) 191 + } 192 + 193 + pub fn msg_submit_input_test() { 194 + SubmitInput |> should.equal(SubmitInput) 195 + } 196 + 197 + pub fn msg_retry_fetch_test() { 198 + RetryFetch |> should.equal(RetryFetch) 199 + }
+345
test/views_test.gleam
··· 1 + import bsky/decoders 2 + import gleam/option 3 + import gleam/string 4 + import gleeunit 5 + import gleeunit/should 6 + import gpreview/types.{ 7 + type Model, type Post, App, FeedEmpty, FeedFailed, FeedLoaded, FeedLoading, 8 + Post, ProfileEmpty, ProfileFailed, ProfileLoaded, ProfileLoading, 9 + } 10 + import gpreview/views.{render_feed, render_input_zone, render_profile_card, view} 11 + import lustre/element.{to_string} 12 + 13 + pub fn main() { 14 + gleeunit.main() 15 + } 16 + 17 + fn default_model() -> Model { 18 + App( 19 + identity: option.None, 20 + input_text: "", 21 + profile_state: ProfileEmpty, 22 + feed_state: FeedEmpty, 23 + retry_count: 0, 24 + ) 25 + } 26 + 27 + fn sample_post() -> Post { 28 + Post( 29 + uri: "at://did:plc:abc123/app.bsky.feed.post/123", 30 + cid: "cid123", 31 + text: "Hello from Bluesky!", 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 + embed: option.None, 38 + ) 39 + } 40 + 41 + // === Input zone tests === 42 + 43 + pub fn input_zone_renders_complete_test() { 44 + let model = default_model() 45 + let html = render_input_zone(model) |> to_string 46 + string.contains(html, "input-zone") |> should.be_true() 47 + string.contains(html, "<input") |> should.be_true() 48 + string.contains(html, "<button") |> should.be_true() 49 + string.contains(html, "Show") |> should.be_true() 50 + string.contains(html, "placeholder") |> should.be_true() 51 + } 52 + 53 + pub fn input_zone_renders_with_value_test() { 54 + let model = 55 + App( 56 + identity: option.None, 57 + input_text: "test-value", 58 + profile_state: ProfileEmpty, 59 + feed_state: FeedEmpty, 60 + retry_count: 0, 61 + ) 62 + let html = render_input_zone(model) |> to_string 63 + string.contains(html, "value=\"test-value\"") |> should.be_true() 64 + } 65 + 66 + // === Profile card tests === 67 + 68 + pub fn profile_card_empty_renders_nothing_test() { 69 + let html = render_profile_card(ProfileEmpty, option.None) |> to_string 70 + string.contains(html, "profile-card") |> should.be_false() 71 + } 72 + 73 + pub fn profile_card_loading_renders_skeleton_test() { 74 + let html = render_profile_card(ProfileLoading, option.None) |> to_string 75 + string.contains(html, "profile-card") |> should.be_true() 76 + string.contains(html, "skeleton") |> should.be_true() 77 + string.contains(html, "Loading profile") |> should.be_true() 78 + } 79 + 80 + pub fn profile_card_loaded_renders_profile_test() { 81 + let profile = 82 + decoders.ProfileJson( 83 + display_name: option.Some("Test User"), 84 + description: option.Some("A bio"), 85 + avatar: option.None, 86 + banner: option.None, 87 + joined_at: option.Some("2024-01-01"), 88 + ) 89 + let html = 90 + render_profile_card(ProfileLoaded(profile), option.None) |> to_string 91 + string.contains(html, "Test User") |> should.be_true() 92 + string.contains(html, "A bio") |> should.be_true() 93 + string.contains(html, "Joined 2024-01-01") |> should.be_true() 94 + } 95 + 96 + pub fn profile_card_error_renders_error_and_retry_test() { 97 + let html = 98 + render_profile_card(ProfileFailed("Error message"), option.None) 99 + |> to_string 100 + string.contains(html, "Error message") |> should.be_true() 101 + string.contains(html, "Retry") |> should.be_true() 102 + } 103 + 104 + // === Feed tests === 105 + 106 + pub fn feed_empty_renders_nothing_test() { 107 + let html = render_feed(FeedEmpty) |> to_string 108 + string.contains(html, "feed-container") |> should.be_true() 109 + string.contains(html, "feed-item") |> should.be_false() 110 + } 111 + 112 + pub fn feed_loading_renders_skeletons_test() { 113 + let html = render_feed(FeedLoading) |> to_string 114 + string.contains(html, "feed-container") |> should.be_true() 115 + string.contains(html, "skeleton") |> should.be_true() 116 + string.contains(html, "Loading posts") |> should.be_true() 117 + } 118 + 119 + pub fn feed_loaded_renders_posts_test() { 120 + let posts = [sample_post()] 121 + let html = render_feed(FeedLoaded(posts)) |> to_string 122 + 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 + } 127 + 128 + pub fn feed_loaded_renders_multiple_posts_test() { 129 + let posts = [sample_post(), sample_post()] 130 + let html = render_feed(FeedLoaded(posts)) |> to_string 131 + string.contains(html, "Hello from Bluesky!") |> should.be_true() 132 + // Should have stagger classes for multiple posts 133 + string.contains(html, "stagger-1") |> should.be_true() 134 + string.contains(html, "stagger-2") |> should.be_true() 135 + } 136 + 137 + pub fn feed_error_renders_error_and_retry_test() { 138 + let html = render_feed(FeedFailed("Network error")) |> to_string 139 + string.contains(html, "Network error") |> should.be_true() 140 + string.contains(html, "Retry") |> should.be_true() 141 + } 142 + 143 + // === Full view integration tests === 144 + 145 + pub fn view_empty_state_renders_input_only_test() { 146 + let html = view(default_model()) |> to_string 147 + string.contains(html, "input-zone") |> should.be_true() 148 + string.contains(html, "profile-card") |> should.be_false() 149 + string.contains(html, "feed-item") |> should.be_false() 150 + } 151 + 152 + pub fn view_loading_state_renders_skeletons_test() { 153 + let model = 154 + App( 155 + identity: option.None, 156 + input_text: "did:plc:test", 157 + profile_state: ProfileLoading, 158 + feed_state: FeedLoading, 159 + retry_count: 0, 160 + ) 161 + let html = view(model) |> to_string 162 + string.contains(html, "profile-card") |> should.be_true() 163 + string.contains(html, "skeleton") |> should.be_true() 164 + string.contains(html, "Loading profile") |> should.be_true() 165 + string.contains(html, "Loading posts") |> should.be_true() 166 + } 167 + 168 + pub fn view_loaded_state_renders_complete_test() { 169 + let profile = 170 + decoders.ProfileJson( 171 + display_name: option.Some("Loaded User"), 172 + description: option.Some("Bio here"), 173 + avatar: option.None, 174 + banner: option.None, 175 + joined_at: option.None, 176 + ) 177 + let posts = [sample_post()] 178 + let model = 179 + App( 180 + identity: option.None, 181 + input_text: "did:plc:test", 182 + profile_state: ProfileLoaded(profile), 183 + feed_state: FeedLoaded(posts), 184 + retry_count: 0, 185 + ) 186 + let html = view(model) |> to_string 187 + string.contains(html, "Loaded User") |> should.be_true() 188 + string.contains(html, "Bio here") |> should.be_true() 189 + string.contains(html, "Hello from Bluesky!") |> should.be_true() 190 + } 191 + 192 + pub fn view_error_state_renders_errors_test() { 193 + let model = 194 + App( 195 + identity: option.None, 196 + input_text: "invalid", 197 + profile_state: ProfileFailed("Profile error"), 198 + feed_state: FeedFailed("Feed error"), 199 + retry_count: 1, 200 + ) 201 + let html = view(model) |> to_string 202 + string.contains(html, "Profile error") |> should.be_true() 203 + string.contains(html, "Feed error") |> should.be_true() 204 + string.contains(html, "Retry") |> should.be_true() 205 + } 206 + 207 + // === Post with embed tests === 208 + 209 + pub fn post_with_images_embed_test() { 210 + let post = 211 + Post( 212 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 213 + cid: "cid1", 214 + text: "Post with image", 215 + 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 + embed: option.Some( 221 + decoders.Images([ 222 + decoders.ImageJson( 223 + alt: "Test image", 224 + ref: "bafkrei123", 225 + aspect_ratio: option.None, 226 + ), 227 + ]), 228 + ), 229 + ) 230 + let html = render_feed(FeedLoaded([post])) |> to_string 231 + string.contains(html, "Post with image") |> should.be_true() 232 + } 233 + 234 + pub fn post_with_external_link_embed_test() { 235 + let post = 236 + Post( 237 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 238 + cid: "cid1", 239 + text: "Check this link", 240 + 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 + embed: option.Some( 246 + decoders.ExternalLink(decoders.ExternalJson( 247 + uri: "https://example.com", 248 + title: "Example", 249 + description: "A site", 250 + )), 251 + ), 252 + ) 253 + let html = render_feed(FeedLoaded([post])) |> to_string 254 + string.contains(html, "Check this link") |> should.be_true() 255 + } 256 + 257 + pub fn post_with_record_embed_test() { 258 + let post = 259 + Post( 260 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 261 + cid: "cid1", 262 + text: "Quoting a post", 263 + 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 + embed: option.Some( 269 + decoders.Record( 270 + decoders.EmbedRecordJson(record: decoders.StrongRefJson( 271 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc", 272 + cid: "bafyreixyz", 273 + )), 274 + ), 275 + ), 276 + ) 277 + let html = render_feed(FeedLoaded([post])) |> to_string 278 + string.contains(html, "Quoting a post") |> should.be_true() 279 + } 280 + 281 + pub fn post_with_record_with_media_embed_test() { 282 + let post = 283 + Post( 284 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 285 + cid: "cid1", 286 + text: "Quote with media", 287 + 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 + embed: option.Some( 293 + decoders.RecordWithMedia(decoders.EmbedRecordWithMedia( 294 + record: decoders.EmbedRecordJson(record: decoders.StrongRefJson( 295 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc", 296 + cid: "bafyreixyz", 297 + )), 298 + media: decoders.ExternalJson( 299 + uri: "https://example.com", 300 + title: "Example", 301 + description: "A site", 302 + ), 303 + )), 304 + ), 305 + ) 306 + let html = render_feed(FeedLoaded([post])) |> to_string 307 + string.contains(html, "Quote with media") |> should.be_true() 308 + } 309 + 310 + // === Text truncation tests === 311 + 312 + pub fn feed_truncates_long_posts_test() { 313 + let long_text = string.repeat("abc", 100) 314 + let post = 315 + Post( 316 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 317 + cid: "cid1", 318 + text: long_text, 319 + 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 + embed: option.None, 325 + ) 326 + let html = render_feed(FeedLoaded([post])) |> to_string 327 + string.contains(html, "...") |> should.be_true() 328 + } 329 + 330 + pub fn feed_handles_empty_post_text_test() { 331 + let post = 332 + Post( 333 + uri: "at://did:plc:abc/app.bsky.feed.post/1", 334 + cid: "cid1", 335 + text: "", 336 + 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 + embed: option.None, 342 + ) 343 + let html = render_feed(FeedLoaded([post])) |> to_string 344 + string.contains(html, "[No text]") |> should.be_true() 345 + }