diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 2fc53b4..cf29117 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -2,12 +2,14 @@ import at_record_web/browser import at_record_web/effects.{load_shelf} import at_record_web/model.{type Model, Crate, LoggedOut, Model, blank_form} import at_record_web/msg.{type Msg, OnRouteChange} +import at_record_web/prefs import at_record_web/route import at_record_web/update.{update} import at_record_web/view.{view} import gleam/dict import gleam/list import gleam/option.{type Option, None, Some} +import gleam/result import gleam/string import gleam/uri.{type Uri} import lustre @@ -34,7 +36,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { login_suggestions: [], login_error:, view: "owned", - display: model.Grid, + display: initial_display(), shelf: model.ShelfLoading, crate_window: model.crate_window_size, crate_cursor: None, @@ -61,6 +63,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { browse_genre: "", inbox: model.InboxLoading, confirm_logout: False, + ignored_proposals: initial_ignored_proposals(), ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. let routing = modem.init(on_url_change) @@ -106,3 +109,19 @@ fn oauth_error() -> Option(String) { fn clear_query() -> Effect(Msg) { effect.from(fn(_dispatch) { browser.clear_query() }) } + +/// The persisted crate display mode, read synchronously at init so it's +/// already correct in the first render (no flash of the Grid default). +fn initial_display() -> model.Display { + prefs.get(prefs.display_key) + |> result.map(model.display_from_string) + |> result.unwrap(model.Grid) +} + +/// The persisted locally-ignored edit-proposal uri+cid pairs, same +/// synchronous-at-init reasoning as `initial_display`. +fn initial_ignored_proposals() -> List(#(String, String)) { + prefs.get(prefs.ignored_proposals_key) + |> result.map(model.decode_ignored_proposals) + |> result.unwrap([]) +} diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index ee0ac36..412ee31 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -11,11 +11,11 @@ import at_record/gen/shelf/get_entry as shelf_get_entry import at_record_web/appview import at_record_web/browser import at_record_web/model.{ - type AmendDraft, type BrowseRelease, type DiscogsResult, type EditProposal, - type Entry, type Form, type ImportRun, type NetworkMatch, type ReleaseInfo, - type Suggestion, BrowseRelease, DiscogsResult, DiscogsSearchPage, EditProposal, - Entry, HandleSuggestion, ImportRun, NetworkMatch, ProposalFields, ReleaseInfo, - Suggestion, + type AmendDraft, type BrowseRelease, type DiscogsResult, type Display, + type EditProposal, type Entry, type Form, type ImportRun, type NetworkMatch, + type ReleaseInfo, type Suggestion, BrowseRelease, DiscogsResult, + DiscogsSearchPage, EditProposal, Entry, HandleSuggestion, ImportRun, + NetworkMatch, ProposalFields, ReleaseInfo, Suggestion, } import at_record_web/money import at_record_web/msg.{ @@ -26,6 +26,7 @@ import at_record_web/msg.{ GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, ScanLookup, ShelfData, TimelineData, } +import at_record_web/prefs import gleam/dict import gleam/dynamic/decode import gleam/int @@ -432,6 +433,25 @@ pub fn discogs_import_wantlist() -> Effect(Msg) { ) } +/// Persist the crate page's grid/rows toggle so it survives a reload. +pub fn persist_display(display: Display) -> Effect(Msg) { + effect.from(fn(_dispatch) { + prefs.set(prefs.display_key, model.display_to_string(display)) + }) +} + +/// Persist the locally-ignored edit-proposal uri+cid pairs. +pub fn persist_ignored_proposals( + ignored: List(#(String, String)), +) -> Effect(Msg) { + effect.from(fn(_dispatch) { + prefs.set( + prefs.ignored_proposals_key, + model.encode_ignored_proposals(ignored), + ) + }) +} + /// Dispatch `msg` after a pause in typing on the given `channel` — each /// channel gets its own JS timer, so independent debounced inputs (title /// search, handle search, ...) never cancel each other. diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index cdfa378..8d5e0a3 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -1,7 +1,9 @@ import at_record/gen/defs.{type Price, type Snapshot} import at_record/gen/shelf/entry.{type ShelfEntry} import gleam/dict +import gleam/dynamic/decode import gleam/int +import gleam/json import gleam/list import gleam/option.{type Option, None} import gleam/set @@ -49,13 +51,29 @@ pub const remove_button_id = "record-remove-btn" /// DOM id of the settings LOG OUT button, same reasoning as `remove_button_id`. pub const logout_button_id = "settings-logout-btn" -/// The crate page's cover layout. Not persisted (resets to Grid on reload) - -/// a known limitation until it has somewhere to live (a settings/profile page). +/// The crate page's cover layout, persisted via `prefs.display_key` so it +/// survives a reload. pub type Display { Grid Rows } +pub fn display_to_string(display: Display) -> String { + case display { + Grid -> "grid" + Rows -> "rows" + } +} + +/// Anything other than a recognised value (including "never set") falls back +/// to Grid, today's pre-persistence default. +pub fn display_from_string(value: String) -> Display { + case value { + "rows" -> Rows + _ -> Grid + } +} + /// Provenance for a crate entry, narrowed to just what the detail page's /// source line needs: the capture origin and the external provider's slug. pub type Source { @@ -415,6 +433,63 @@ pub fn inbox_pending_count(inbox: Inbox) -> Int { } } +/// Locally-ignored `catalog.edit` proposals, as uri+cid pairs (the same +/// strongRef shape the proposal itself carries), persisted client-side only +/// via `prefs.ignored_proposals_key` - a repo record would make "ignored" +/// public data, which this v1 deliberately avoids. +pub const max_ignored_proposals = 200 + +/// True when `uri`+`cid` names an ignored proposal; a proposal re-filed with +/// a different cid is not covered by an older ignore. +pub fn is_ignored_proposal( + ignored: List(#(String, String)), + uri: String, + cid: String, +) -> Bool { + list.contains(ignored, #(uri, cid)) +} + +/// Record `uri`+`cid` as ignored (deduped on uri, so re-ignoring the same +/// proposal just moves it to the end) and cap the list to the most recent +/// `max_ignored_proposals` entries so it can't grow unbounded. +pub fn add_ignored_proposal( + ignored: List(#(String, String)), + uri: String, + cid: String, +) -> List(#(String, String)) { + let deduped = list.filter(ignored, fn(pair) { pair.0 != uri }) + let updated = list.append(deduped, [#(uri, cid)]) + case list.length(updated) > max_ignored_proposals { + True -> list.drop(updated, list.length(updated) - max_ignored_proposals) + False -> updated + } +} + +pub fn encode_ignored_proposals(ignored: List(#(String, String))) -> String { + ignored + |> json.array(fn(pair) { + json.object([ + #("uri", json.string(pair.0)), + #("cid", json.string(pair.1)), + ]) + }) + |> json.to_string +} + +/// Anything that fails to parse (never persisted, corrupted, a stale shape) +/// falls back to an empty ignore list rather than blocking the inbox. +pub fn decode_ignored_proposals(raw: String) -> List(#(String, String)) { + let decoder = { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + decode.success(#(uri, cid)) + } + case json.parse(raw, decode.list(decoder)) { + Ok(pairs) -> pairs + Error(_) -> [] + } +} + /// The outcome of one capped collection-import run. pub type ImportRun { ImportRun( @@ -479,6 +554,9 @@ pub type Model { inbox: Inbox, // Armed like `confirm_remove`: first tap arms, second tap executes. confirm_logout: Bool, + // Locally-ignored edit-proposal uri+cid pairs, loaded from + // `prefs.ignored_proposals_key` at init; see `add_ignored_proposal`. + ignored_proposals: List(#(String, String)), ) } diff --git a/web/src/at_record_web/pages/edit_inbox.gleam b/web/src/at_record_web/pages/edit_inbox.gleam index a8b33bb..49c02b5 100644 --- a/web/src/at_record_web/pages/edit_inbox.gleam +++ b/web/src/at_record_web/pages/edit_inbox.gleam @@ -1,8 +1,9 @@ //// The Edit inbox: proposals filed by other users against the caller's own //// catalog releases. Apply mints a superseding release (via //// catalog.applyEditProposal) and swaps the card for a confirmation strip; -//// ignore just drops the card from the model - session-local, no -//// persistence (a known limitation: an ignored proposal reappears next visit). +//// ignore drops the card from the model and persists its uri+cid pair +//// locally (see `model.add_ignored_proposal`), so it stays gone on the next +//// load too. import at_record_web/model.{ type EditProposal, type Inbox, type InboxCard, type Model, EditInbox, diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index e15d431..9062cbc 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -214,7 +214,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { load_shelf(view), ) - SetDisplay(display) -> #(Model(..model, display:), effect.none()) + SetDisplay(display) -> #( + Model(..model, display:), + effects.persist_display(display), + ) // Widening the window is free when enough is already fetched; only once // the window catches up does this reach for the next page over the wire. @@ -1008,11 +1011,16 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { "Could not save that record.", ) + // Proposals ignored on a previous visit (see IgnoreProposal) never make it into the loaded inbox at all. GotEditInbox(Ok(proposals)) -> #( Model( ..model, inbox: InboxLoaded( - list.map(proposals, fn(p) { + proposals + |> list.filter(fn(p) { + !model.is_ignored_proposal(model.ignored_proposals, p.uri, p.cid) + }) + |> list.map(fn(p) { model.InboxCard(proposal: p, status: ProposalReviewing) }), ), @@ -1069,11 +1077,33 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { "Could not apply that proposal.", ) - // Session-local only: dropped from the model, not persisted anywhere. - IgnoreProposal(uri) -> #( - Model(..model, inbox: remove_card(model.inbox, uri)), - effect.none(), - ) + // Dropped from the model right away; the uri+cid pair is also persisted + // (capped, JSON-encoded) so the proposal stays gone on the next visit - + // client-only, a repo record would make "ignored" public data. + IgnoreProposal(uri) -> { + let cid = find_proposal_cid(model.inbox, uri) |> option.unwrap("") + let ignored = + model.add_ignored_proposal(model.ignored_proposals, uri, cid) + #( + Model( + ..model, + inbox: remove_card(model.inbox, uri), + ignored_proposals: ignored, + ), + effects.persist_ignored_proposals(ignored), + ) + } + } +} + +fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { + case inbox { + InboxLoaded(cards) -> + cards + |> list.find(fn(c) { c.proposal.uri == uri }) + |> result.map(fn(c) { c.proposal.cid }) + |> option.from_result + _ -> None } } diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 3e666ac..b76aabf 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -114,7 +114,12 @@ fn crate_actions(handle: String, avatar: Option(String)) -> Element(Msg) { /// actions), mirroring how `bar.icon_link` builds its bar anchors. fn avatar_link(handle: String, avatar: Option(String)) -> Element(Msg) { html.a( - [attr.class("avatar"), attr.href("/settings"), attr.title("Settings")], + [ + attr.class("avatar"), + attr.href("/settings"), + attr.title("Settings"), + attr.aria_label("Settings"), + ], [bar.avatar_content(avatar, handle)], ) } diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index ef99731..f2aec25 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -121,12 +121,13 @@ pub fn set_display_switches_to_rows_test() { assert model.display == Rows } -pub fn set_display_back_to_grid_is_a_pure_toggle_test() { +pub fn set_display_back_to_grid_persists_without_a_shelf_reload_test() { let rows_view = Model(..logged_in(), display: Rows) let #(model, effect) = update(rows_view, SetDisplay(Grid)) assert model.display == Grid - // Switching display is local UI state only, no shelf reload. - assert effect == empty_effect() + // Switching display persists the choice (see prefs_test.gleam) but never + // reloads the shelf. + assert effect != empty_effect() } pub fn session_expired_is_a_warning_notice_test() { diff --git a/web/test/prefs_test.gleam b/web/test/prefs_test.gleam new file mode 100644 index 0000000..4a1ca4e --- /dev/null +++ b/web/test/prefs_test.gleam @@ -0,0 +1,129 @@ +//// Interface-level tests for the generic prefs module's pure helpers (JSON +//// codecs, capping, dedup) and for the `update`/init integration points +//// that read/write them. The FFI boundary itself (`prefs.get`/`prefs.set`, +//// backed by localStorage) is a thin, side-effecting passthrough exercised +//// in the browser, not here - `update` never runs its own effects, so these +//// tests never touch real localStorage. + +import at_record_web/model.{ + type EditProposal, EditProposal, Grid, InboxCard, InboxLoaded, Model, + ProposalFields, ProposalReviewing, Rows, +} +import at_record_web/msg.{GotEditInbox, IgnoreProposal, SetDisplay} +import at_record_web/update.{update} +import gleam/int +import gleam/list +import gleam/option.{None} +import support.{empty_effect, logged_in} + +fn a_proposal(uri: String, cid: String) -> EditProposal { + EditProposal( + uri:, + cid:, + proposer_did: "did:plc:bob", + proposer_handle: None, + target_uri: "at://did:plc:alice/dev.mokkenstorm.crate.catalog.release/r1", + release_title: "Spiderland", + current: ProposalFields( + title: None, + released: None, + country: None, + genres: None, + styles: None, + ), + fields: ProposalFields( + title: None, + released: None, + country: None, + genres: None, + styles: None, + ), + created_at: "2026-02-01T00:00:00Z", + ) +} + +// --- display --- + +pub fn display_to_string_round_trips_test() { + assert model.display_from_string(model.display_to_string(Grid)) == Grid + assert model.display_from_string(model.display_to_string(Rows)) == Rows +} + +pub fn display_from_string_falls_back_to_grid_for_unknown_values_test() { + assert model.display_from_string("") == Grid + assert model.display_from_string("garbage") == Grid +} + +pub fn set_display_persists_and_updates_the_model_test() { + let #(model_after, effect) = update(logged_in(), SetDisplay(Rows)) + assert model_after.display == Rows + assert effect != empty_effect() +} + +// --- ignored proposals: pure helpers --- + +pub fn ignored_proposals_round_trip_through_json_test() { + let ignored = [#("at://a/edit/1", "cid-1"), #("at://a/edit/2", "cid-2")] + let decoded = + ignored |> model.encode_ignored_proposals |> model.decode_ignored_proposals + assert decoded == ignored +} + +pub fn ignored_proposals_decode_falls_back_to_empty_on_garbage_test() { + assert model.decode_ignored_proposals("not json") == [] + assert model.decode_ignored_proposals("{}") == [] +} + +pub fn add_ignored_proposal_dedupes_on_uri_test() { + let once = model.add_ignored_proposal([], "at://a/edit/1", "cid-1") + let twice = model.add_ignored_proposal(once, "at://a/edit/1", "cid-2") + assert twice == [#("at://a/edit/1", "cid-2")] +} + +pub fn add_ignored_proposal_caps_to_the_most_recent_200_test() { + let seeded = + int.range(1, 201, [], fn(acc, n) { + model.add_ignored_proposal(acc, "at://a/edit/" <> int.to_string(n), "cid") + }) + let capped = model.add_ignored_proposal(seeded, "at://a/edit/201", "cid") + assert list.length(capped) == 200 + // The oldest entry (edit/1) was evicted; the newest is present. + assert !list.contains(capped, #("at://a/edit/1", "cid")) + assert list.contains(capped, #("at://a/edit/201", "cid")) +} + +pub fn is_ignored_proposal_matches_on_uri_and_cid_test() { + let ignored = [#("at://a/edit/1", "cid-1")] + assert model.is_ignored_proposal(ignored, "at://a/edit/1", "cid-1") + // A re-filed proposal (same uri, new cid) is not covered by an old ignore. + assert !model.is_ignored_proposal(ignored, "at://a/edit/1", "cid-2") + assert !model.is_ignored_proposal(ignored, "at://a/edit/2", "cid-1") +} + +// --- ignored proposals: update integration --- + +pub fn ignore_proposal_records_the_uri_and_cid_and_persists_test() { + let proposal = a_proposal("at://proposer/edit/1", "cid-1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + ) + let #(model_after, effect) = + update(seeded, IgnoreProposal("at://proposer/edit/1")) + assert model_after.ignored_proposals == [#("at://proposer/edit/1", "cid-1")] + assert effect != empty_effect() +} + +pub fn got_edit_inbox_filters_out_previously_ignored_proposals_test() { + let kept = a_proposal("at://proposer/edit/2", "cid-2") + let seeded = + Model(..logged_in(), ignored_proposals: [#("at://proposer/edit/1", "cid-1")]) + let #(model_after, _) = + update( + seeded, + GotEditInbox(Ok([a_proposal("at://proposer/edit/1", "cid-1"), kept])), + ) + let assert InboxLoaded(cards) = model_after.inbox + assert list.map(cards, fn(c) { c.proposal.uri }) == ["at://proposer/edit/2"] +} diff --git a/web/test/support.gleam b/web/test/support.gleam index ee99fc6..701db11 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -58,6 +58,7 @@ pub fn base() -> Model { browse_genre: "", inbox: model.InboxLoading, confirm_logout: False, + ignored_proposals: [], ) }