diff --git a/web/css/20-feed.css b/web/css/20-feed.css new file mode 100644 index 0000000..2cd32c4 --- /dev/null +++ b/web/css/20-feed.css @@ -0,0 +1,86 @@ +/* --- feed page --------------------------------------------------------- */ + +/* Day separator: a quieter mono label than .section-label. */ +.feed-day { + font: 700 11px/1.2 var(--mono); + letter-spacing: 0.5px; + color: var(--ink-muted); + margin: 18px 0 8px; +} +.feed-day:first-child { + margin-top: 0; +} + +.feed-row { + padding: 12px 0; +} +.feed-row + .feed-row { + border-top: 1px solid var(--line); +} + +/* The "@handle did X" header line above each reason's content. */ +.feed-row__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.feed-row__label { + font: 400 12px/1.3 var(--mono); + color: var(--ink); + min-width: 0; +} + +/* A small square avatar tile; mirrors .avatar--lg's modifier pattern. */ +.avatar--sm { + width: 24px; + height: 24px; + font-size: 12px; +} + +/* Overlapping avatar cluster for the subject-converge reason: square tiles, + later ones pulled left over their neighbour (no radius, per the app). */ +.feed-avatar-cluster { + display: flex; + flex-shrink: 0; +} +.feed-avatar-cluster .avatar + .avatar { + margin-left: -8px; +} + +/* Horizontal, scrollable strip of small cover thumbs. */ +.feed-strip { + display: flex; + gap: 8px; + overflow-x: auto; +} +.feed-strip__thumb { + flex-shrink: 0; + line-height: 0; +} + +/* Skeleton placeholders while a row's refs hydrate. */ +.feed-row--skeleton { + display: flex; + align-items: center; + gap: 12px; +} +.feed-skeleton-cover { + width: 48px; + height: 48px; + flex-shrink: 0; + border: 2px dashed var(--ink); + background: var(--bg); +} +.feed-skeleton-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} +.feed-skeleton-thumb { + width: 44px; + height: 44px; + border: 2px dashed var(--ink); + background: var(--bg); +} diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 0949c1d..6edffdf 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -69,6 +69,9 @@ fn init(_flags) -> #(Model, Effect(Msg)) { public_entry: model.PublicEntryLoading, overlap: None, pressing: model.PressingLoading, + feed: model.FeedLoading, + feed_entries: dict.new(), + feed_loading_more: False, nav_depth: 0, ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. @@ -79,6 +82,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { let route_effects = case active_route { model.Add -> [effects.discogs_status()] model.Browse -> [effects.load_browse()] + model.Feed -> [effects.load_feed(None)] model.EditInbox -> [effects.load_edit_inbox()] model.PublicCrate(handle) -> [effects.load_public_shelf(handle)] model.PublicRecord(handle, entry_id) -> [ diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 5c17a84..0566b2d 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -6,6 +6,7 @@ import at_record/gen/discogs/import_collection as discogs_import_collection import at_record/gen/discogs/resolve_barcode as discogs_resolve_barcode import at_record/gen/discogs/search_artists as discogs_search_artists import at_record/gen/discogs/search_releases as discogs_search_releases +import at_record/gen/feed/get_feed_skeleton import at_record/gen/shelf/entry.{shelf_entry_decoder} import at_record_web/appview import at_record_web/browser @@ -18,14 +19,16 @@ import at_record_web/model.{ } import at_record_web/money import at_record_web/msg.{ - type Msg, type ShelfData, AppliedProposal, BarcodeDetected, CameraUnsupported, - CoverUploaded, CrateOverlapData, GotAction, GotAdd, GotAmend, GotApplyProposal, + type FeedSkeletonData, type Msg, type ShelfData, AppliedProposal, + BarcodeDetected, CameraUnsupported, CoverUploaded, CrateOverlapData, + FeedEntryData, FeedSkeletonData, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotCrateOverlap, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, - GotDiscogsStatus, GotEditInbox, GotFollow, GotHandleSuggestions, GotLogout, - GotPressing, GotPublicEntry, GotPublicShelf, GotScanResult, GotScanSeen, - GotShelf, GotShelfMore, GotTimeline, GotUnfollow, LinkCopied, PublicEntryData, - PublicShelfData, ScanLookup, ShelfData, TimelineData, + GotDiscogsStatus, GotEditInbox, GotFeedEntry, GotFeedMore, GotFeedSkeleton, + GotFollow, GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, + GotPublicShelf, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, + GotTimeline, GotUnfollow, LinkCopied, PublicEntryData, PublicShelfData, + ScanLookup, ShelfData, TimelineData, } import at_record_web/prefs import gleam/dict @@ -874,6 +877,66 @@ pub fn load_public_entry(handle: String, entry_id: String) -> Effect(Msg) { rsvp.get(url, rsvp.expect_json(decoder, GotPublicEntry)) } +/// One page of the network activity feed. Dispatches `GotFeedSkeleton` for a +/// fresh load (`cursor` is `None`) and `GotFeedMore` for an infinite-scroll +/// page (`cursor` is `Some`); the query shape is identical either way. +pub fn load_feed(cursor: Option(String)) -> Effect(Msg) { + let to_msg = case cursor { + None -> GotFeedSkeleton + Some(_) -> GotFeedMore + } + let cursor_param = case cursor { + Some(c) -> "&cursor=" <> uri.percent_encode(c) + None -> "" + } + let url = + xrpc("feed.getFeedSkeleton") + <> "?limit=" + <> int.to_string(model.feed_page_limit) + <> cursor_param + rsvp.get(url, rsvp.expect_json(feed_skeleton_decoder(), to_msg)) +} + +fn feed_skeleton_decoder() -> decode.Decoder(FeedSkeletonData) { + use items <- decode.field( + "items", + decode.list(get_feed_skeleton.feed_item_decoder()), + ) + use fallback <- decode.optional_field("fallback", False, decode.bool) + use cursor <- decode.optional_field( + "cursor", + None, + decode.optional(decode.string), + ) + decode.success(FeedSkeletonData(items:, fallback:, cursor:)) +} + +/// Hydrate one feed entry ref through `getPublicEntry`. `actor` is the ref's +/// DID (the endpoint resolves either a DID or a handle), so it's passed +/// straight through. The `updatedAt` timestamp is pulled as a sibling of the +/// full `Entry` so day-grouping needn't touch the shared `Entry` type. +pub fn hydrate_feed_entry(actor: String, entry_id: String) -> Effect(Msg) { + let decoder = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use entry <- decode.field("entry", entry_decoder()) + use updated_at <- decode.subfield(["entry", "updatedAt"], decode.string) + use release <- decode.optional_field( + "release", + None, + decode.optional(release_info_decoder()), + ) + decode.success(FeedEntryData(did:, handle:, entry:, release:, updated_at:)) + } + let url = + xrpc("shelf.getPublicEntry") + <> "?actor=" + <> uri.percent_encode(actor) + <> "&entryId=" + <> uri.percent_encode(entry_id) + rsvp.get(url, rsvp.expect_json(decoder, GotFeedEntry(actor, entry_id, _))) +} + pub fn load_crate_overlap(handle: String) -> Effect(Msg) { let decoder = { use common_count <- decode.field("commonCount", decode.int) diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 5fe1f20..46ba0a6 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -1,9 +1,11 @@ import at_record/gen/defs.{type Price, type Snapshot} +import at_record/gen/feed/get_feed_skeleton.{type FeedItem} import at_record/gen/shelf/entry.{type ShelfEntry} import gleam/dict import gleam/int import gleam/list -import gleam/option.{type Option, None} +import gleam/option.{type Option, None, Some} +import gleam/result import gleam/set import gleam/string import gleam/uri @@ -52,6 +54,10 @@ pub const crate_window_size = 30 /// network fetch. pub const crate_page_limit = 60 +/// How many feed items to request per `feed.getFeedSkeleton` fetch (initial +/// load and each infinite-scroll page). +pub const feed_page_limit = 30 + /// How long an armed two-tap confirm (REMOVE, LOG OUT) stays armed before /// auto-disarming if the second tap never comes. pub const confirm_timeout_ms = 4000 @@ -178,6 +184,54 @@ pub type PublicEntryState { PublicEntryFailed } +/// The network activity feed's load state, mirroring `Shelf`. `FeedLoaded` +/// carries the raw skeleton items; each item's entry refs are hydrated +/// separately into `Model.feed_entries`. +pub type FeedState { + FeedLoading + FeedLoaded(items: List(FeedItem), cursor: Option(String), fallback: Bool) + FeedFailed +} + +/// One hydrated feed entry ref: mirrors `PublicEntryLoaded`'s fields plus the +/// entry's `updatedAt` timestamp (a sibling of the `Entry` on the wire, kept +/// here so day-grouping needn't touch the shared `Entry` type). +pub type FeedEntry { + FeedEntry( + did: String, + handle: String, + entry: Entry, + release_info: Option(ReleaseInfo), + updated_at: String, + ) +} + +/// The day-separator label for a feed item: the newest hydrated entry's +/// `updatedAt` day (ISO dates sort lexicographically), or `None` while none +/// of its refs are hydrated yet. +pub fn feed_item_day( + item: FeedItem, + cache: dict.Dict(#(String, String), FeedEntry), +) -> Option(String) { + item.entries + |> list.filter_map(fn(ref) { + dict.get(cache, #(ref.actor, ref.entry_id)) + |> result.map(fn(fe) { string.slice(fe.updated_at, 0, 10) }) + }) + |> list.sort(string.compare) + |> list.last + |> option.from_result +} + +/// The first entry ref's `#(actor, entry_id)` pair, the item's identity for +/// pagination de-dup; `None` only for a defensively-empty item. +pub fn feed_item_key(item: FeedItem) -> Option(#(String, String)) { + case item.entries { + [first, ..] -> Some(#(first.actor, first.entry_id)) + [] -> None + } +} + /// How prominent/urgent a notice is; drives its icon and colour. pub type NoticeLevel { Success @@ -659,6 +713,12 @@ pub type Model { overlap: Option(CrateOverlap), // The pressing detail page's release, for the /pressing/:did/:rkey route. pressing: PressingState, + // The network feed's load state, for the /feed route. + feed: FeedState, + // Hydrated feed entry refs, keyed #(actor_did, entry_id). + feed_entries: dict.Dict(#(String, String), FeedEntry), + // True only while a cursor-driven feed page fetch is in flight. + feed_loading_more: Bool, // How many client-side route changes have happened this session; 0 means // still on the route the app booted into. Drives `Back`'s choice between // real browser history and a fallback push (see `route.section`'s diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 86d9b34..9cf9c16 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -1,3 +1,4 @@ +import at_record/gen/feed/get_feed_skeleton.{type FeedItem} import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{ type ArtistHit, type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, @@ -48,6 +49,28 @@ pub type PublicEntryData { ) } +/// `getFeedSkeleton`'s response: the raw activity items plus the fallback +/// flag and next-page cursor. +pub type FeedSkeletonData { + FeedSkeletonData( + items: List(FeedItem), + fallback: Bool, + cursor: Option(String), + ) +} + +/// One hydrated feed entry ref (same shape as `PublicEntryData` plus the +/// entry's `updatedAt` timestamp, carried for day-grouping). +pub type FeedEntryData { + FeedEntryData( + did: String, + handle: String, + entry: Entry, + release: Option(ReleaseInfo), + updated_at: String, + ) +} + /// `getCrateOverlap`'s response. pub type CrateOverlapData { CrateOverlapData( @@ -171,6 +194,14 @@ pub type Msg { IgnoreProposal(uri: String) GotPublicShelf(Result(PublicShelfData, rsvp.Error(String))) GotPublicEntry(Result(PublicEntryData, rsvp.Error(String))) + GotFeedSkeleton(Result(FeedSkeletonData, rsvp.Error(String))) + GotFeedEntry( + actor: String, + entry_id: String, + result: Result(FeedEntryData, rsvp.Error(String)), + ) + FeedShowMore + GotFeedMore(Result(FeedSkeletonData, rsvp.Error(String))) GotCrateOverlap(Result(CrateOverlapData, rsvp.Error(String))) /// Optimistically flips the public-crate page's follow state and fires /// the matching write; the target is the currently-open public crate's did. diff --git a/web/src/at_record_web/pages/feed.gleam b/web/src/at_record_web/pages/feed.gleam index 3b5e6a1..0cb4fec 100644 --- a/web/src/at_record_web/pages/feed.gleam +++ b/web/src/at_record_web/pages/feed.gleam @@ -1,16 +1,340 @@ -//// The Feed page: the network activity feed tab. Static empty state until -//// there's a feed endpoint to back it - the tab exists so NAV1's five-slot -//// bottom bar is real today. +//// The Feed page: the network activity feed tab. Day-grouped rows of what +//// collectors across the network add, want, and fix, hydrated per entry ref +//// off the skeleton the server returns. Unhydrated refs render a skeleton +//// placeholder in place, so the list keeps its shape while it fills in. -import at_record_web/model.{type Model, Browse} -import at_record_web/msg.{type Msg} +import at_record/gen/defs.{type Snapshot} +import at_record/gen/feed/get_feed_skeleton.{ + type EntryRef, type FeedItem, type ReasonActorBatch, type ReasonImport, + type ReasonSingle, type ReasonSubjectConverge, FeedItemReasonReasonActorBatch, + FeedItemReasonReasonImport, FeedItemReasonReasonSingle, + FeedItemReasonReasonSubjectConverge, +} +import at_record_web/model.{ + type FeedEntry, type Model, Browse, FeedFailed, FeedLoaded, FeedLoading, + PressingDetail, PublicRecord, +} +import at_record_web/msg.{type Msg, FeedShowMore} import at_record_web/pages/crate +import at_record_web/plural +import at_record_web/provenance import at_record_web/route +import at_record_web/ui/app_bar as bar +import at_record_web/ui/covers as cov +import at_record_web/ui/infinite_scroll as scroll +import at_record_web/ui/states +import gleam/dict +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html -pub fn view(_model: Model) -> Element(Msg) { +pub fn view(model: Model) -> Element(Msg) { + case model.feed { + FeedLoading -> states.loading_page() + FeedFailed -> states.failed_page("Couldn't load the feed right now.") + FeedLoaded([], _, _) -> empty_state() + FeedLoaded(items, cursor, fallback) -> + loaded_state(model, items, cursor, fallback) + } +} + +fn loaded_state( + model: Model, + items: List(FeedItem), + cursor: Option(String), + fallback: Bool, +) -> Element(Msg) { + let can_load_more = option.is_some(cursor) && !model.feed_loading_more + html.div([attr.class("list-screen")], [ + html.div([attr.class("body list-body")], [ + html.div( + [ + attr.class("list-scroll"), + scroll.on_near_bottom(FeedShowMore, can_load_more), + ], + list.flatten([ + fallback_label(fallback), + feed_rows(model, items), + [loading_marker(model.feed_loading_more)], + ]), + ), + ]), + ]) +} + +fn fallback_label(fallback: Bool) -> List(Element(Msg)) { + case fallback { + True -> [ + html.p([attr.class("section-label")], [text("FROM THE WIDER NETWORK")]), + ] + False -> [] + } +} + +fn loading_marker(loading_more: Bool) -> Element(Msg) { + case loading_more { + True -> html.p([attr.class("load-more-status")], [text("LOADING MORE ···")]) + False -> element.none() + } +} + +/// Fold the items into rows, inserting a day separator whenever a hydrated +/// item's day differs from the running one. An unhydrated item (no day yet) +/// never opens a new day: it renders under whatever day is currently open. +fn feed_rows(model: Model, items: List(FeedItem)) -> List(Element(Msg)) { + let #(_, rows) = + list.fold(items, #(None, []), fn(acc, item) { + let #(current_day, out) = acc + let day = model.feed_item_day(item, model.feed_entries) + let out = case day { + Some(d) if day != current_day -> [day_separator(d), ..out] + _ -> out + } + let next_day = case day { + Some(_) -> day + None -> current_day + } + #(next_day, [feed_row(model, item), ..out]) + }) + list.reverse(rows) +} + +fn day_separator(day: String) -> Element(Msg) { + html.p([attr.class("feed-day")], [text(day)]) +} + +fn feed_row(model: Model, item: FeedItem) -> Element(Msg) { + case item.reason { + FeedItemReasonReasonSingle(reason) -> single_row(model, item, reason) + FeedItemReasonReasonActorBatch(reason) -> batch_row(model, item, reason) + FeedItemReasonReasonSubjectConverge(reason) -> + converge_row(model, item, reason) + FeedItemReasonReasonImport(reason) -> import_row(model, item, reason) + } +} + +fn single_row( + model: Model, + item: FeedItem, + reason: ReasonSingle, +) -> Element(Msg) { + case first_ref(item) { + None -> skeleton_row() + Some(ref) -> + case lookup(model, ref) { + None -> skeleton_row() + Some(fe) -> { + let snap = fe.entry.snapshot + html.div([attr.class("feed-row")], [ + row_head(fe.handle, "@" <> fe.handle <> " " <> reason.action), + cov.cover_row( + route.to_path(PublicRecord(fe.handle, ref.entry_id)), + snap.title <> snap.artist_display, + fe.entry.status, + snap.title, + row_meta(snap), + snap.thumb_url, + ), + ]) + } + } + } +} + +fn batch_row( + model: Model, + item: FeedItem, + reason: ReasonActorBatch, +) -> Element(Msg) { + case hydrated_entries(model, item) { + [] -> skeleton_row() + [#(_, fe), ..] -> { + let label = + "@" + <> fe.handle + <> " " + <> reason.action + <> " " + <> plural.count_noun(list.length(item.entries), "record") + strip_row(model, fe.handle, label, item) + } + } +} + +fn import_row( + model: Model, + item: FeedItem, + reason: ReasonImport, +) -> Element(Msg) { + case hydrated_entries(model, item) { + [] -> skeleton_row() + [#(_, fe), ..] -> { + let via = case reason.source { + Some(src) -> " via " <> provenance.provider_label(src) + None -> "" + } + let label = + "@" + <> fe.handle + <> " imported " + <> plural.count_noun(list.length(item.entries), "record") + <> via + strip_row(model, fe.handle, label, item) + } + } +} + +/// Header line plus a horizontal strip of small cover thumbs, shared by the +/// actor-batch and import reasons. +fn strip_row( + model: Model, + seed: String, + label: String, + item: FeedItem, +) -> Element(Msg) { + html.div([attr.class("feed-row")], [ + row_head(seed, label), + cover_strip(model, item), + ]) +} + +fn cover_strip(model: Model, item: FeedItem) -> Element(Msg) { + html.div( + [attr.class("feed-strip")], + list.map(item.entries, fn(ref) { + case lookup(model, ref) { + Some(fe) -> + html.a( + [ + attr.class("feed-strip__thumb"), + attr.href(route.to_path(PublicRecord(fe.handle, ref.entry_id))), + ], + [cov.thumb(fe.entry.snapshot.thumb_url)], + ) + None -> + html.div([attr.class("feed-strip__thumb feed-skeleton-thumb")], []) + } + }), + ) +} + +fn converge_row( + model: Model, + item: FeedItem, + reason: ReasonSubjectConverge, +) -> Element(Msg) { + case first_ref(item) { + None -> skeleton_row() + Some(ref) -> + case lookup(model, ref) { + None -> skeleton_row() + Some(fe) -> { + let snap = fe.entry.snapshot + let href = case model.split_release_uri(reason.subject.uri) { + Ok(#(did, rkey)) -> route.to_path(PressingDetail(did, rkey)) + Error(_) -> route.to_path(PublicRecord(fe.handle, ref.entry_id)) + } + html.div([attr.class("feed-row")], [ + html.div([attr.class("feed-row__head")], [ + avatar_cluster(model, item), + html.span([attr.class("feed-row__label")], [ + text( + plural.count_noun(list.length(item.entries), "collector") + <> " picked up the same pressing", + ), + ]), + ]), + cov.cover_row( + href, + snap.title <> snap.artist_display, + fe.entry.status, + snap.title, + row_meta(snap), + snap.thumb_url, + ), + ]) + } + } + } +} + +fn avatar_cluster(model: Model, item: FeedItem) -> Element(Msg) { + let avatars = + hydrated_entries(model, item) + |> list.take(3) + |> list.map(fn(pair) { + let #(_, fe) = pair + html.span([attr.class("avatar avatar--sm")], [ + bar.avatar_content(None, fe.handle), + ]) + }) + html.div([attr.class("feed-avatar-cluster")], avatars) +} + +fn row_head(seed: String, label: String) -> Element(Msg) { + html.div([attr.class("feed-row__head")], [ + html.span([attr.class("avatar avatar--sm")], [ + bar.avatar_content(None, seed), + ]), + html.span([attr.class("feed-row__label")], [text(label)]), + ]) +} + +fn skeleton_row() -> Element(Msg) { + html.div([attr.class("feed-row feed-row--skeleton")], [ + html.div([attr.class("feed-skeleton-cover")], []), + html.div([attr.class("feed-skeleton-lines")], [ + html.div([attr.class("skeleton-bar skeleton-bar--w70")], []), + html.div([attr.class("skeleton-bar skeleton-bar--w48")], []), + ]), + ]) +} + +fn row_meta(snap: Snapshot) -> String { + case format_line(snap) { + "" -> snap.artist_display + fmt -> snap.artist_display <> " · " <> fmt + } +} + +fn format_line(snap: Snapshot) -> String { + [snap.format, option.map(snap.year, int.to_string)] + |> option.values + |> string.join(" / ") +} + +fn first_ref(item: FeedItem) -> Option(EntryRef) { + case item.entries { + [ref, ..] -> Some(ref) + [] -> None + } +} + +fn lookup(model: Model, ref: EntryRef) -> Option(FeedEntry) { + dict.get(model.feed_entries, #(ref.actor, ref.entry_id)) + |> option.from_result +} + +/// The `#(ref, hydrated entry)` pairs for an item, in skeleton order, skipping +/// refs that aren't in the cache yet. +fn hydrated_entries( + model: Model, + item: FeedItem, +) -> List(#(EntryRef, FeedEntry)) { + list.filter_map(item.entries, fn(ref) { + lookup(model, ref) + |> option.map(fn(fe) { #(ref, fe) }) + |> option.to_result(Nil) + }) +} + +/// The designed empty state: unchanged from the pre-feed stub, shown whenever +/// the server returns no items at all (regardless of the fallback flag). +fn empty_state() -> Element(Msg) { html.div([attr.class("body page-scroll")], [ html.div([attr.class("empty-state")], [ crate.vinyl_glyph(), diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index d7942b0..97b912d 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -1,21 +1,24 @@ +import at_record/gen/feed/get_feed_skeleton.{type FeedItem} import at_record_web/effects.{ add_item, apply_edit_proposal, browse_add, copy_record_link, discogs_connect, discogs_disconnect, discogs_import, discogs_import_wantlist, discogs_search, - discogs_status, entry_action, follow_user, handle_search, load_avatar, - load_browse, load_crate_overlap, load_pressing, load_public_entry, - load_public_shelf, load_shelf, load_shelf_more, load_timeline, logout, - oauth_login, rate, regrade, search_browse, unfollow_user, + discogs_status, entry_action, follow_user, handle_search, hydrate_feed_entry, + load_avatar, load_browse, load_crate_overlap, load_feed, load_pressing, + load_public_entry, load_public_shelf, load_shelf, load_shelf_more, + load_timeline, logout, oauth_login, rate, regrade, search_browse, + unfollow_user, } import at_record_web/model.{ type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, BrowseRelease, CrateOverlap, Discogs, EditInbox, EditProposalDetail, Entry, - Failure, Form, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, Notice, - PressingDetail, PressingFailed, PressingLoaded, PressingLoading, - ProposalApplied, ProposalApplying, ProposalReviewing, PublicCrate, - PublicEntryFailed, PublicEntryLoaded, PublicEntryLoading, PublicRecord, - PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, Record, RecordAmend, - Scan, ScanDone, ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, Success, - Warning, blank_form, crate_window_size, + Failure, Feed, FeedEntry, FeedFailed, FeedLoaded, FeedLoading, Form, + InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, Notice, PressingDetail, + PressingFailed, PressingLoaded, PressingLoading, ProposalApplied, + ProposalApplying, ProposalReviewing, PublicCrate, PublicEntryFailed, + PublicEntryLoaded, PublicEntryLoading, PublicRecord, PublicShelfFailed, + PublicShelfLoaded, PublicShelfLoading, Record, RecordAmend, Scan, ScanDone, + ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, + blank_form, crate_window_size, } import at_record_web/money import at_record_web/msg.{ @@ -25,20 +28,21 @@ import at_record_web/msg.{ CameraUnsupported, ClearNotice, ClearPhotoHint, CopyRecordLink, CoverFileChosen, CoverUploaded, DisarmLogout, DisarmRemove, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, - DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, - FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, - FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, - GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, - GotBrowseSearch, GotCrateOverlap, GotDiscogs, GotDiscogsDisconnect, - GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotFollow, - GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, - GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, GotUnfollow, - HandleChanged, HandleSearch, IgnoreProposal, LinkCopied, Logout, OnRouteChange, - PhotoCaptured, Rate, Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, - SetDisplay, SetScanMode, SetTheme, SetView, ShowMoreCrate, StartBatchImport, - StartLogin, SubmitAdd, SubmitAmend, TakePhoto, ToggleAmendCover, - ToggleBrowseFilters, ToggleEdit, ToggleEntry, ToggleFollow, - TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, + DiscogsSearch, DiscogsVinylOnly, EntryAction, FeedShowMore, FormArtist, + FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, + FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, + GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, + GotBrowseAdd, GotBrowseSearch, GotCrateOverlap, GotDiscogs, + GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, + GotFeedEntry, GotFeedMore, GotFeedSkeleton, GotFollow, GotHandleSuggestions, + GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, GotScanResult, + GotScanSeen, GotShelf, GotShelfMore, GotTimeline, GotUnfollow, HandleChanged, + HandleSearch, IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, + Rate, Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, SetDisplay, + SetScanMode, SetTheme, SetView, ShowMoreCrate, StartBatchImport, StartLogin, + SubmitAdd, SubmitAmend, TakePhoto, ToggleAmendCover, ToggleBrowseFilters, + ToggleEdit, ToggleEntry, ToggleFollow, TriggerBrowseSearch, UseArtist, + UseDiscogs, UseHandleSuggestion, } import at_record_web/photo_scan import at_record_web/route @@ -1075,6 +1079,89 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, public_entry: PublicEntryFailed), effect.none(), ) + + GotFeedSkeleton(Ok(data)) -> #( + Model(..model, feed: FeedLoaded(data.items, data.cursor, data.fallback)), + hydrate_uncached(data.items, model.feed_entries), + ) + GotFeedSkeleton(Error(_)) -> #( + Model( + ..model, + feed: FeedFailed, + notice: failed("Couldn't load the feed."), + ), + effect.none(), + ) + GotFeedEntry(actor, entry_id, Ok(data)) -> #( + Model( + ..model, + feed_entries: dict.insert( + model.feed_entries, + #(actor, entry_id), + FeedEntry( + did: data.did, + handle: data.handle, + entry: data.entry, + release_info: data.release, + updated_at: data.updated_at, + ), + ), + ), + effect.none(), + ) + // Per-row degrade: an unhydrated ref just keeps its skeleton placeholder, + // so a failed hydration is silently left uncached rather than crashing. + GotFeedEntry(_, _, Error(_)) -> #(model, effect.none()) + FeedShowMore -> + case model.feed { + FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> #( + Model(..model, feed_loading_more: True), + load_feed(Some(cursor)), + ) + _ -> #(model, effect.none()) + } + // Appends the next page, deduping by first-entry-ref key in case an item + // straddles the page boundary. + GotFeedMore(Ok(data)) -> + case model.feed { + FeedLoaded(items, _, _) -> { + let seen = + items + |> list.filter_map(fn(item) { + model.feed_item_key(item) |> option.to_result(Nil) + }) + |> set.from_list + let fresh = + list.filter(data.items, fn(item) { + case model.feed_item_key(item) { + Some(key) -> !set.contains(seen, key) + None -> True + } + }) + #( + Model( + ..model, + feed: FeedLoaded( + list.append(items, fresh), + data.cursor, + data.fallback, + ), + feed_loading_more: False, + ), + hydrate_uncached(fresh, model.feed_entries), + ) + } + _ -> #(Model(..model, feed_loading_more: False), effect.none()) + } + GotFeedMore(Error(_)) -> #( + Model( + ..model, + feed_loading_more: False, + notice: failed("Couldn't load more of the feed."), + ), + effect.none(), + ) + GotCrateOverlap(Ok(data)) -> #( Model( ..model, @@ -1315,6 +1402,10 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { _ -> model.overlap }, pressing: PressingLoading, + feed: case route { + Feed -> FeedLoading + _ -> model.feed + }, ), effect.batch([ leaving_scan(model), @@ -1324,6 +1415,7 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { Scan -> effect.batch([start_capture(scan.mode), effects.scan_seen()]) Browse -> load_browse() + Feed -> load_feed(None) EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() PublicCrate(handle) -> effect.batch([ @@ -1377,6 +1469,20 @@ fn leave_proposal_detail( } } +/// Fan out hydration for every entry ref in a batch of feed items not +/// already sitting in the cache, so a re-fetched or overlapping item never +/// re-hydrates a ref that's already loaded. +fn hydrate_uncached( + items: List(FeedItem), + cache: dict.Dict(#(String, String), model.FeedEntry), +) -> Effect(Msg) { + items + |> list.flat_map(fn(item) { item.entries }) + |> list.filter(fn(ref) { !dict.has_key(cache, #(ref.actor, ref.entry_id)) }) + |> list.map(fn(ref) { hydrate_feed_entry(ref.actor, ref.entry_id) }) + |> effect.batch +} + fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { case inbox { InboxLoaded(cards) -> diff --git a/web/test/feed_test.gleam b/web/test/feed_test.gleam index acb1cde..2403234 100644 --- a/web/test/feed_test.gleam +++ b/web/test/feed_test.gleam @@ -1,10 +1,106 @@ +import at_record/gen/defs.{CatalogRef} +import at_record/gen/feed/get_feed_skeleton.{ + type EntryRef, type FeedItem, EntryRef, FeedItem, + FeedItemReasonReasonActorBatch, FeedItemReasonReasonImport, + FeedItemReasonReasonSingle, FeedItemReasonReasonSubjectConverge, + ReasonActorBatch, ReasonImport, ReasonSingle, ReasonSubjectConverge, +} +import at_record_web/model.{ + type FeedEntry, type Model, Feed, FeedEntry, FeedFailed, FeedLoaded, + FeedLoading, Model, +} +import at_record_web/msg.{ + FeedEntryData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, + GotFeedSkeleton, OnRouteChange, +} import at_record_web/pages/feed +import at_record_web/update.{update} +import gleam/dict +import gleam/list +import gleam/option.{None, Some} import gleam/string import lustre/element -import support.{logged_in} +import rsvp +import support.{an_entry, base, empty_effect, logged_in} + +// --- builders ------------------------------------------------------------ + +fn ref(actor: String, entry_id: String) -> EntryRef { + EntryRef(actor:, entry_id:) +} + +fn single(actor: String, entry_id: String, action: String) -> FeedItem { + FeedItem( + entries: [ref(actor, entry_id)], + reason: FeedItemReasonReasonSingle(ReasonSingle(action:)), + ) +} + +fn batch(refs: List(EntryRef), action: String) -> FeedItem { + FeedItem( + entries: refs, + reason: FeedItemReasonReasonActorBatch(ReasonActorBatch( + action:, + window_start: "2026-07-18T00:00:00Z", + window_end: "2026-07-18T23:59:59Z", + )), + ) +} + +fn converge(refs: List(EntryRef), subject_uri: String) -> FeedItem { + FeedItem( + entries: refs, + reason: FeedItemReasonReasonSubjectConverge(ReasonSubjectConverge( + action: "acquired", + subject: CatalogRef(cid: "bafycid", external_ids: None, uri: subject_uri), + )), + ) +} + +fn import_item( + refs: List(EntryRef), + source: option.Option(String), +) -> FeedItem { + FeedItem( + entries: refs, + reason: FeedItemReasonReasonImport(ReasonImport(source:)), + ) +} + +fn fe(handle: String, updated_at: String) -> FeedEntry { + FeedEntry( + did: "did:" <> handle, + handle:, + entry: an_entry(), + release_info: None, + updated_at:, + ) +} + +fn with_feed( + items: List(FeedItem), + fallback: Bool, + entries: List(#(#(String, String), FeedEntry)), +) -> Model { + Model( + ..logged_in(), + feed: FeedLoaded(items, None, fallback), + feed_entries: dict.from_list(entries), + ) +} + +fn render(model: Model) -> String { + feed.view(model) |> element.to_string +} + +fn occurrences(haystack: String, needle: String) -> Int { + list.length(string.split(haystack, needle)) - 1 +} + +// --- states -------------------------------------------------------------- pub fn feed_view_renders_the_designed_empty_state_test() { - let html = feed.view(logged_in()) |> element.to_string + let html = render(with_feed([], False, [])) assert string.contains(html, "class=\"empty-state\"") assert string.contains(html, "THE FEED IS QUIET") assert string.contains( @@ -14,3 +110,285 @@ pub fn feed_view_renders_the_designed_empty_state_test() { assert string.contains(html, "BROWSE THE NETWORK") assert string.contains(html, "href=\"/browse\"") } + +pub fn feed_loading_state_shows_a_fetching_placeholder_test() { + let html = render(Model(..logged_in(), feed: FeedLoading)) + assert string.contains(html, "FETCHING") +} + +pub fn feed_failed_state_shows_the_couldnt_load_copy_test() { + let html = render(Model(..logged_in(), feed: FeedFailed)) + assert string.contains(html, "error-state__sticker") + assert string.contains(html, "load the feed right now") +} + +pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { + let #(model, _) = + update(logged_in(), GotFeedSkeleton(Error(rsvp.NetworkError))) + assert model.feed == FeedFailed + assert case model.notice { + Some(model.Notice(model.Failure, _)) -> True + _ -> False + } + assert string.contains(render(model), "load the feed right now") +} + +// --- reason rows: hydrated ---------------------------------------------- + +pub fn single_reason_hydrated_renders_header_and_cover_row_test() { + let html = + render( + with_feed([single("did:a", "e0", "added")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test added") + assert string.contains(html, "Spiderland") + assert string.contains(html, "class=\"row\"") + assert string.contains(html, "href=\"/u/alice.test/record/e0\"") +} + +pub fn actor_batch_hydrated_renders_count_and_strip_test() { + let refs = [ref("did:a", "e0"), ref("did:a", "e1")] + let html = + render( + with_feed([batch(refs, "added")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test added 2 records") + assert string.contains(html, "class=\"feed-strip\"") +} + +pub fn subject_converge_hydrated_links_to_the_pressing_test() { + let refs = [ref("did:a", "e0"), ref("did:b", "e9")] + let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" + let html = + render( + with_feed([converge(refs, subject)], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "2 collectors picked up the same pressing") + assert string.contains(html, "class=\"feed-avatar-cluster\"") + assert string.contains(html, "href=\"/pressing/did:x/rk1\"") +} + +pub fn import_reason_hydrated_shows_via_provider_test() { + let refs = [ref("did:a", "e0")] + let html = + render( + with_feed([import_item(refs, Some("discogs"))], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test imported 1 record") + assert string.contains(html, "via Discogs") + assert string.contains(html, "class=\"feed-strip\"") +} + +pub fn import_reason_without_source_omits_via_test() { + let refs = [ref("did:a", "e0")] + let html = + render( + with_feed([import_item(refs, None)], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test imported 1 record") + assert !string.contains(html, " via ") +} + +// --- reason rows: unhydrated (skeleton, no crash) ----------------------- + +pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { + let refs = [ref("did:a", "e0"), ref("did:b", "e1")] + [ + single("did:a", "e0", "added"), + batch(refs, "added"), + converge(refs, "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1"), + import_item(refs, Some("discogs")), + ] + |> list.each(fn(item) { + let html = render(with_feed([item], False, [])) + assert string.contains(html, "feed-row--skeleton") + }) +} + +// --- fallback label ------------------------------------------------------ + +pub fn fallback_true_shows_the_wider_network_label_test() { + let html = + render( + with_feed([single("did:a", "e0", "added")], True, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "FROM THE WIDER NETWORK") +} + +pub fn fallback_false_omits_the_wider_network_label_test() { + let html = + render( + with_feed([single("did:a", "e0", "added")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert !string.contains(html, "FROM THE WIDER NETWORK") +} + +// --- day separators ------------------------------------------------------ + +pub fn items_on_different_days_render_two_separators_test() { + let items = [single("did:a", "e0", "added"), single("did:a", "e1", "added")] + let html = + render( + with_feed(items, False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-17T10:00:00Z")), + ]), + ) + assert occurrences(html, "class=\"feed-day\"") == 2 + assert string.contains(html, "2026-07-18") + assert string.contains(html, "2026-07-17") +} + +pub fn items_on_the_same_day_render_one_separator_test() { + let items = [single("did:a", "e0", "added"), single("did:a", "e1", "added")] + let html = + render( + with_feed(items, False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-18T09:00:00Z")), + ]), + ) + assert occurrences(html, "class=\"feed-day\"") == 1 +} + +// --- update: routing + pagination --------------------------------------- + +pub fn on_route_change_feed_sets_loading_and_fires_a_load_test() { + let #(model, effect) = update(logged_in(), OnRouteChange(Feed)) + assert model.feed == FeedLoading + assert effect != empty_effect() +} + +pub fn got_feed_skeleton_stores_items_and_hydrates_test() { + let data = FeedSkeletonData([single("did:a", "e0", "added")], False, None) + let #(model, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) + let assert FeedLoaded(items, _, _) = model.feed + assert list.length(items) == 1 + // Uncached refs mean a real hydration effect fires. + assert effect != empty_effect() +} + +pub fn got_feed_skeleton_with_no_items_fires_no_hydration_test() { + let data = FeedSkeletonData([], False, None) + let #(_, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) + assert effect == empty_effect() +} + +pub fn got_feed_entry_ok_caches_the_entry_test() { + let data = + FeedEntryData( + did: "did:a", + handle: "alice.test", + entry: an_entry(), + release: None, + updated_at: "2026-07-18T10:00:00Z", + ) + let #(model, _) = update(logged_in(), GotFeedEntry("did:a", "e0", Ok(data))) + assert dict.has_key(model.feed_entries, #("did:a", "e0")) +} + +pub fn got_feed_entry_error_leaves_the_cache_untouched_test() { + let #(model, _) = + update(logged_in(), GotFeedEntry("did:a", "e0", Error(rsvp.NetworkError))) + assert dict.size(model.feed_entries) == 0 + assert model.notice == None +} + +pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { + let seeded = + Model( + ..logged_in(), + feed: FeedLoaded( + [single("did:a", "e0", "added"), single("did:a", "e1", "added")], + Some("cursor1"), + False, + ), + feed_loading_more: True, + ) + // The next page repeats e1 (fold shifted between pages) and adds e2. + let next = + FeedSkeletonData( + [single("did:a", "e1", "added"), single("did:a", "e2", "added")], + False, + None, + ) + let #(updated, _) = update(seeded, GotFeedMore(Ok(next))) + let assert FeedLoaded(items, _, _) = updated.feed + assert item_keys(items) + == [#("did:a", "e0"), #("did:a", "e1"), #("did:a", "e2")] + assert updated.feed_loading_more == False +} + +fn item_keys(items: List(FeedItem)) -> List(#(String, String)) { + list.filter_map(items, fn(item) { + model.feed_item_key(item) |> option.to_result(Nil) + }) +} + +pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() { + let item = single("did:a", "e0", "added") + [ + #(FeedLoaded([item], Some("c"), False), False, True), + #(FeedLoaded([item], None, False), False, False), + #(FeedLoaded([item], Some("c"), False), True, False), + ] + |> list.each(fn(row) { + let #(feed_state, loading, expect_fetch) = row + let seeded = + Model(..logged_in(), feed: feed_state, feed_loading_more: loading) + let #(_, effect) = update(seeded, FeedShowMore) + // A fetch fires only when a cursor exists and none is already in flight. + assert { effect != empty_effect() } == expect_fetch + }) +} + +// --- pure helpers -------------------------------------------------------- + +pub fn feed_item_day_takes_the_newest_hydrated_day_test() { + let item = batch([ref("did:a", "e0"), ref("did:a", "e1")], "added") + let cache = + dict.from_list([ + #(#("did:a", "e0"), fe("alice.test", "2026-07-17T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]) + assert model.feed_item_day(item, cache) == Some("2026-07-18") +} + +pub fn feed_item_day_is_none_when_unhydrated_test() { + let item = single("did:a", "e0", "added") + assert model.feed_item_day(item, dict.new()) == None +} + +pub fn feed_item_key_is_the_first_ref_pair_test() { + let item = batch([ref("did:a", "e0"), ref("did:b", "e1")], "added") + assert model.feed_item_key(item) == Some(#("did:a", "e0")) +} + +pub fn feed_item_key_is_none_for_an_empty_item_test() { + let item = + FeedItem( + entries: [], + reason: FeedItemReasonReasonSingle(ReasonSingle("added")), + ) + assert model.feed_item_key(item) == None +} + +// unused import guard: base() keeps the shared builder referenced here. +pub fn base_model_has_a_feed_field_test() { + assert base().feed == FeedLoading +} diff --git a/web/test/support.gleam b/web/test/support.gleam index feaf6d2..264bfc8 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -64,6 +64,9 @@ pub fn base() -> Model { public_entry: model.PublicEntryLoading, overlap: None, pressing: model.PressingLoading, + feed: model.FeedLoading, + feed_entries: dict.new(), + feed_loading_more: False, nav_depth: 0, ) }