diff --git a/lexicons/dev/mokkenstorm/crate/shelf/getCrateOverlap.sdl b/lexicons/dev/mokkenstorm/crate/shelf/getCrateOverlap.sdl new file mode 100644 index 0000000..2338eaf --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/shelf/getCrateOverlap.sdl @@ -0,0 +1,8 @@ +/// Overlap between the caller's own shelf and another actor's public one. +query getCrateOverlap(actor: AtIdentifier! @description("Handle or DID of the crate being compared against.")) @main + : { + commonCount: Int! + /// One title both parties want, when the wantlists share any. + bothWantedTitle: String + wantMatchCount: Int! + } diff --git a/server/src/at_record_server/handlers/crate_overlap.gleam b/server/src/at_record_server/handlers/crate_overlap.gleam new file mode 100644 index 0000000..e9e3daf --- /dev/null +++ b/server/src/at_record_server/handlers/crate_overlap.gleam @@ -0,0 +1,120 @@ +//// Authed comparison between the caller's own crate and another actor's public one. + +import at_record_server/context.{ + type Context, error_json, require_session, with_pds_client, +} +import at_record_server/crate.{type CrateEntry} +import at_record_server/event_log +import at_record_server/handlers/public_shelf +import at_record_server/oauth/sessions.{type OauthSession} +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/set +import gleam/string +import wisp.{type Request, type Response} + +pub type Overlap { + Overlap( + common_count: Int, + both_wanted_title: Option(String), + want_match_count: Int, + ) +} + +pub fn get_crate_overlap(req: Request, ctx: Context) -> Response { + use id, session <- require_session(req, ctx) + case list.key_find(wisp.get_query(req), "actor") { + Error(Nil) -> error_json(400, "missing actor") + Ok(actor) -> do_get_crate_overlap(ctx, id, session, actor) + } +} + +fn do_get_crate_overlap( + ctx: Context, + id: String, + session: OauthSession, + actor: String, +) -> Response { + use client, session <- with_pds_client(ctx, id, session) + case public_shelf.resolve_actor(ctx, actor) { + Error(Nil) -> error_json(404, "could not resolve that user") + Ok(#(did, _handle, pds)) -> + case event_log.load(client, session) { + Error(_) -> error_json(502, "could not load your crate from PDS") + Ok(own_stored) -> + case public_shelf.fetch_public_entries(ctx, pds, did) { + Error(Nil) -> + error_json(502, "could not load that user's crate from their PDS") + Ok(their_stored) -> + overlap(crate.fold(own_stored), crate.fold(their_stored)) + |> encode_overlap + |> json.to_string + |> wisp.json_response(200) + } + } + } +} + +// Matched by the shared catalog `release` uri, same identity promotion/adoption already treats as canonical; entries with no release can't be matched. +pub fn overlap(own: List(CrateEntry), theirs: List(CrateEntry)) -> Overlap { + let own_owned_uris = release_uris(own, crate.Owned) + let own_wanted_uris = release_uris(own, crate.Wanted) + let their_owned_uris = release_uris(theirs, crate.Owned) + + Overlap( + common_count: set.intersection(own_owned_uris, their_owned_uris) + |> set.size, + both_wanted_title: both_wanted_title(theirs, own_wanted_uris), + want_match_count: set.intersection(their_owned_uris, own_wanted_uris) + |> set.size, + ) +} + +fn release_uris( + entries: List(CrateEntry), + status: crate.Status, +) -> set.Set(String) { + entries + |> list.filter(fn(e) { e.status == status }) + |> list.filter_map(fn(e) { + e.release |> option.map(fn(r) { r.uri }) |> option.to_result(Nil) + }) + |> set.from_list +} + +fn both_wanted_title( + theirs: List(CrateEntry), + own_wanted_uris: set.Set(String), +) -> Option(String) { + theirs + |> list.filter(fn(e) { e.status == crate.Wanted }) + |> list.filter_map(fn(e) { + case e.release, e.snapshot { + Some(ref), Some(snap) -> + case set.contains(own_wanted_uris, ref.uri) { + True -> Ok(snap.title) + False -> Error(Nil) + } + _, _ -> Error(Nil) + } + }) + |> list.sort(string.compare) + |> list.first + |> option.from_result +} + +fn encode_overlap(o: Overlap) -> json.Json { + json.object( + list.flatten([ + [ + #("commonCount", json.int(o.common_count)), + #("wantMatchCount", json.int(o.want_match_count)), + ], + case o.both_wanted_title { + Some(t) -> [#("bothWantedTitle", json.string(t))] + None -> [] + }, + ]), + ) +} diff --git a/server/src/at_record_server/handlers/public_shelf.gleam b/server/src/at_record_server/handlers/public_shelf.gleam index 6266fdf..2e30635 100644 --- a/server/src/at_record_server/handlers/public_shelf.gleam +++ b/server/src/at_record_server/handlers/public_shelf.gleam @@ -119,7 +119,7 @@ fn resolve_release( /// resolver the oauth login flow uses. Any failure (unknown identifier, /// resolver unreachable) collapses to `Error(Nil)`: from the caller's side, /// an unresolvable actor looks the same as a nonexistent one. -fn resolve_actor( +pub fn resolve_actor( ctx: Context, actor: String, ) -> Result(#(String, String, String), Nil) { @@ -137,7 +137,7 @@ fn resolve_actor( /// single-page-only shape as `browse.fetch_user_records`, decoded straight /// into `StoredItem`s so `event_log.entry_events`/`crate.fold` can fold them /// exactly like an authed load does. -fn fetch_public_entries( +pub fn fetch_public_entries( ctx: Context, pds: String, did: String, diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam index 60fbebd..3eab1c4 100644 --- a/server/src/at_record_server/router.gleam +++ b/server/src/at_record_server/router.gleam @@ -5,6 +5,7 @@ import at_record_server/context.{type Context, error_json} import at_record_server/handlers/amend import at_record_server/handlers/browse import at_record_server/handlers/cover_proxy +import at_record_server/handlers/crate_overlap import at_record_server/handlers/discogs import at_record_server/handlers/edit_inbox import at_record_server/handlers/oauth @@ -78,6 +79,8 @@ fn dispatch_xrpc( public_shelf.get_public_shelf(req, ctx) "dev.mokkenstorm.crate.shelf.getPublicEntry", Get -> public_shelf.get_public_entry(req, ctx) + "dev.mokkenstorm.crate.shelf.getCrateOverlap", Get -> + crate_overlap.get_crate_overlap(req, ctx) "dev.mokkenstorm.crate.discogs.searchReleases", Get -> discogs.search(req, ctx) "dev.mokkenstorm.crate.discogs.searchArtists", Get -> diff --git a/server/test/crate_overlap_test.gleam b/server/test/crate_overlap_test.gleam new file mode 100644 index 0000000..9322ae0 --- /dev/null +++ b/server/test/crate_overlap_test.gleam @@ -0,0 +1,282 @@ +//// Pure overlap-computation tests plus end-to-end wiring tests for the +//// authed `shelf.getCrateOverlap` handler. + +import at_record/gen/defs.{CatalogRef, Snapshot} +import at_record_server/context.{type Context} +import at_record_server/crate.{type CrateEntry, CrateEntry} +import at_record_server/handlers/crate_overlap +import at_record_server/oauth/config +import at_record_server/oauth/session_store +import at_record_server/oauth/sessions +import atproto/xrpc +import gleam/bit_array +import gleam/http +import gleam/http/response +import gleam/json +import gleam/option.{None, Some} +import support +import wisp +import wisp/simulate + +fn entry( + id: String, + status: crate.Status, + release_uri: option.Option(String), + title: String, +) -> CrateEntry { + CrateEntry( + entry_id: id, + status:, + snapshot: Some(Snapshot( + artist_display: "", + cover: None, + format: None, + thumb_url: None, + title:, + year: None, + )), + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + release: option.map(release_uri, fn(uri) { + CatalogRef(cid: "c", external_ids: None, uri:) + }), + price: None, + counterparty: None, + source: None, + created_at: "t", + updated_at: "t", + events: [], + ) +} + +pub fn common_count_is_shared_owned_releases_test() { + let own = [ + entry("a", crate.Owned, Some("uri1"), "A"), + entry("b", crate.Owned, Some("uri2"), "B"), + ] + let theirs = [ + entry("c", crate.Owned, Some("uri1"), "A"), + entry("d", crate.Owned, Some("uri3"), "C"), + ] + let result = crate_overlap.overlap(own, theirs) + assert result.common_count == 1 +} + +pub fn entries_without_a_release_never_match_test() { + let own = [entry("a", crate.Owned, None, "Same Title")] + let theirs = [entry("b", crate.Owned, None, "Same Title")] + let result = crate_overlap.overlap(own, theirs) + assert result.common_count == 0 +} + +pub fn both_wanted_title_picks_the_alphabetically_first_match_test() { + let own = [ + entry("a", crate.Wanted, Some("u1"), "Zebra"), + entry("b", crate.Wanted, Some("u2"), "Apple"), + ] + let theirs = [ + entry("c", crate.Wanted, Some("u1"), "Zebra"), + entry("d", crate.Wanted, Some("u2"), "Apple"), + ] + let result = crate_overlap.overlap(own, theirs) + assert result.both_wanted_title == Some("Apple") +} + +pub fn both_wanted_title_is_absent_with_no_shared_wants_test() { + let own = [entry("a", crate.Wanted, Some("u1"), "Zebra")] + let theirs = [entry("b", crate.Wanted, Some("u2"), "Apple")] + let result = crate_overlap.overlap(own, theirs) + assert result.both_wanted_title == None +} + +pub fn want_match_count_is_their_owned_on_your_wantlist_test() { + let own = [entry("a", crate.Wanted, Some("u1"), "X")] + let theirs = [ + entry("b", crate.Owned, Some("u1"), "X"), + entry("c", crate.Owned, Some("u2"), "Y"), + ] + let result = crate_overlap.overlap(own, theirs) + assert result.want_match_count == 1 +} + +pub fn want_match_count_dedupes_repeat_owned_copies_test() { + let own = [entry("a", crate.Wanted, Some("u1"), "X")] + let theirs = [ + entry("b", crate.Owned, Some("u1"), "X"), + entry("c", crate.Owned, Some("u1"), "X"), + ] + let result = crate_overlap.overlap(own, theirs) + assert result.want_match_count == 1 +} + +const own_did = "did:plc:x" + +const actor_did = "did:plc:pub" + +const actor_handle = "pub.test" + +const actor_pds_host = "pds.pub.test" + +const session_cookie = "ar_oauth_sid" + +const far_future = 9_999_999_999 + +fn a_session() -> sessions.OauthSession { + support.stub_session_with( + access_token: "at", + refresh_token: "rt", + expires_at: far_future, + ) +} + +fn shelf_record( + owner_did: String, + rkey: String, + title: String, + release_rkey: String, +) -> json.Json { + json.object([ + #( + "uri", + json.string( + "at://" <> owner_did <> "/dev.mokkenstorm.crate.shelf.entry/" <> rkey, + ), + ), + #("cid", json.string("bafy" <> rkey)), + #( + "value", + json.object([ + #("action", json.string("acquired")), + #("createdAt", json.string("2026-01-01T00:00:00Z")), + #( + "snapshot", + json.object([ + #("title", json.string(title)), + #("artistDisplay", json.string("")), + ]), + ), + #( + "release", + json.object([ + #("cid", json.string("bafyrel")), + #( + "uri", + json.string( + "at://shared/dev.mokkenstorm.crate.catalog.release/" + <> release_rkey, + ), + ), + ]), + ), + ]), + ), + ]) +} + +fn own_record(rkey: String, title: String, release_rkey: String) -> json.Json { + shelf_record(own_did, rkey, title, release_rkey) +} + +fn actor_record( + rkey: String, + title: String, + release_rkey: String, +) -> json.Json { + shelf_record(actor_did, rkey, title, release_rkey) +} + +fn list_records_body(records: List(json.Json)) -> String { + json.object([#("records", json.preprocessed_array(records))]) + |> json.to_string +} + +fn resolve_body() -> String { + json.object([ + #("did", json.string(actor_did)), + #("handle", json.string(actor_handle)), + #("pds", json.string("https://" <> actor_pds_host)), + #("signing_key", json.string("zTest")), + ]) + |> json.to_string +} + +fn network_client(own_body: String, actor_body: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.host { + "resolver.test" -> + Ok(response.Response(200, [], bit_array.from_string(resolve_body()))) + "pds.example" -> + Ok(response.Response(200, [], bit_array.from_string(own_body))) + host if host == actor_pds_host -> + Ok(response.Response(200, [], bit_array.from_string(actor_body))) + _ -> panic as "unexpected host" + } + }) +} + +fn test_context(client: xrpc.Client) -> #(Context, config.Config) { + let cfg = support.stub_config(client) + let ctx = + support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + fn(_req) { Error("unused") }, + [], + ) + #(ctx, cfg) +} + +fn authed_request(path: String, cfg: config.Config) -> wisp.Request { + let assert Ok(id) = session_store.create(cfg.sessions, a_session()) + simulate.request(http.Get, path) + |> simulate.cookie(session_cookie, id, wisp.Signed) +} + +pub fn get_crate_overlap_requires_a_session_test() { + let #(ctx, _cfg) = + test_context(network_client(list_records_body([]), list_records_body([]))) + let resp = + crate_overlap.get_crate_overlap( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap?actor=pub.test", + ), + ctx, + ) + assert resp.status == 401 +} + +pub fn get_crate_overlap_requires_an_actor_test() { + let #(ctx, cfg) = + test_context(network_client(list_records_body([]), list_records_body([]))) + let req = + authed_request("/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap", cfg) + let resp = crate_overlap.get_crate_overlap(req, ctx) + assert resp.status == 400 +} + +pub fn get_crate_overlap_combines_both_crates_test() { + let own_body = + list_records_body([ + own_record("3aaa", "Spiderland", "shared-spiderland"), + own_record("3bbb", "Loveless", "own-only-loveless"), + ]) + let actor_body = + list_records_body([ + actor_record("4aaa", "Spiderland", "shared-spiderland"), + actor_record("4bbb", "Isn't Anything", "actor-only-isnt"), + ]) + let #(ctx, cfg) = test_context(network_client(own_body, actor_body)) + let req = + authed_request( + "/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap?actor=pub.test", + cfg, + ) + let resp = crate_overlap.get_crate_overlap(req, ctx) + assert resp.status == 200 + let body = simulate.read_body(resp) + assert support.field_int(body, ["commonCount"]) == Ok(1) +} diff --git a/server/test/support.gleam b/server/test/support.gleam index 40e357f..66039cf 100644 --- a/server/test/support.gleam +++ b/server/test/support.gleam @@ -236,6 +236,10 @@ pub fn field_string(body: String, path: List(String)) -> Result(String, Nil) { json.parse(body, decode.at(path, decode.string)) |> result.replace_error(Nil) } +pub fn field_int(body: String, path: List(String)) -> Result(Int, Nil) { + json.parse(body, decode.at(path, decode.int)) |> result.replace_error(Nil) +} + pub fn field_strings( body: String, path: List(String), diff --git a/web/css/06-crate.css b/web/css/06-crate.css index 6a8a834..f6ca040 100644 --- a/web/css/06-crate.css +++ b/web/css/06-crate.css @@ -65,6 +65,42 @@ bottom: 10px; } +/* Bottom-aligned flow stack, so an optional taste-overlap panel can grow it without hand-tuned absolute offsets. */ +.hero__content { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 6px; + padding: 0 18px 12px; +} +.hero__content .hero__count { + position: static; +} +.hero__overlap { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; +} +.hero__overlap-headline { + margin: 0; + color: var(--cream-fixed); + font: 700 13px/1.2 var(--mono); +} +.hero__overlap-detail { + margin: 0; + max-width: 100%; + color: var(--cream-fixed); + opacity: 0.8; + font: 400 11px/1.3 var(--mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* --- toolbar / tags --------------------------------------------------- */ .body { padding: 16px; diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index f68887e..d4c3757 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -68,6 +68,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { ignored_proposals: initial_ignored_proposals(), public_shelf: model.PublicShelfLoading, public_entry: model.PublicEntryLoading, + overlap: None, pressing: model.PressingLoading, ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index ca681cc..03e171e 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -20,12 +20,13 @@ import at_record_web/model.{ import at_record_web/money import at_record_web/msg.{ type Msg, type ShelfData, AppliedProposal, BarcodeDetected, CameraUnsupported, - CoverUploaded, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, - GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, - GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, - GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, LinkCopied, - PublicEntryData, PublicShelfData, ScanLookup, ShelfData, TimelineData, + CoverUploaded, CrateOverlapData, GotAction, GotAdd, GotAmend, GotApplyProposal, + GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, + GotCrateOverlap, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, + GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, GotLogout, GotPressing, + GotPublicEntry, GotPublicShelf, GotScanResult, GotScanSeen, GotShelf, + GotShelfMore, GotTimeline, LinkCopied, PublicEntryData, PublicShelfData, + ScanLookup, ShelfData, TimelineData, } import at_record_web/prefs import gleam/dict @@ -867,6 +868,26 @@ pub fn load_public_entry(handle: String, entry_id: String) -> Effect(Msg) { rsvp.get(url, rsvp.expect_json(decoder, GotPublicEntry)) } +pub fn load_crate_overlap(handle: String) -> Effect(Msg) { + let decoder = { + use common_count <- decode.field("commonCount", decode.int) + use both_wanted_title <- decode.optional_field( + "bothWantedTitle", + None, + decode.optional(decode.string), + ) + use want_match_count <- decode.field("wantMatchCount", decode.int) + decode.success(CrateOverlapData( + common_count:, + both_wanted_title:, + want_match_count:, + )) + } + let url = + xrpc("shelf.getCrateOverlap") <> "?actor=" <> uri.percent_encode(handle) + rsvp.get(url, rsvp.expect_json(decoder, GotCrateOverlap)) +} + /// Copy a shareable link (an absolute URL, built client-side from `path`) to /// the clipboard; `LinkCopied` carries whether it actually worked, so the UI /// can show a notice either way. diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 77b4fa4..cffa0d9 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -150,6 +150,15 @@ pub type PublicShelf { PublicShelfFailed } +/// The signed-in viewer's overlap with another actor's public crate. +pub type CrateOverlap { + CrateOverlap( + common_count: Int, + both_wanted_title: Option(String), + want_match_count: Int, + ) +} + /// One entry off another user's public crate, plus its resolved release /// fields; mirrors `PublicShelf`'s load-state shape. pub type PublicEntryState { @@ -641,6 +650,8 @@ pub type Model { // Another user's public crate/entry, loaded for the /u/:handle routes. public_shelf: PublicShelf, public_entry: PublicEntryState, + // The signed-in viewer's overlap with the public crate currently open. + overlap: Option(CrateOverlap), // The pressing detail page's release, for the /pressing/:did/:rkey route. pressing: PressingState, ) diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 9798113..65e7380 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -48,6 +48,15 @@ pub type PublicEntryData { ) } +/// `getCrateOverlap`'s response. +pub type CrateOverlapData { + CrateOverlapData( + common_count: Int, + both_wanted_title: Option(String), + want_match_count: Int, + ) +} + /// A barcode lookup's result: a network match wins outright when present /// (Discogs was never even queried); otherwise the best Discogs match if /// any, plus "did you mean" suggestions when the barcode itself resolved to @@ -158,6 +167,7 @@ pub type Msg { IgnoreProposal(uri: String) GotPublicShelf(Result(PublicShelfData, rsvp.Error(String))) GotPublicEntry(Result(PublicEntryData, rsvp.Error(String))) + GotCrateOverlap(Result(CrateOverlapData, rsvp.Error(String))) /// Share the public URL for one of the caller's own entries. CopyRecordLink(entry_id: String) LinkCopied(Bool) diff --git a/web/src/at_record_web/pages/public_crate.gleam b/web/src/at_record_web/pages/public_crate.gleam index 7e149ae..bfb7eb3 100644 --- a/web/src/at_record_web/pages/public_crate.gleam +++ b/web/src/at_record_web/pages/public_crate.gleam @@ -4,44 +4,65 @@ import at_record/gen/defs.{type Snapshot} import at_record_web/model.{ - type Entry, type Model, PublicRecord, PublicShelfFailed, PublicShelfLoaded, - PublicShelfLoading, + type CrateOverlap, type Entry, type Model, LoggedIn, PublicRecord, + PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, } import at_record_web/msg.{type Msg} +import at_record_web/plural 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/states import gleam/int import gleam/list -import gleam/option +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, handle: String) -> Element(Msg) { + let overlap = visible_overlap(model, handle) case model.public_shelf { - PublicShelfLoading -> loading_state(handle) - PublicShelfFailed -> failed_state(handle) + PublicShelfLoading -> loading_state(handle, overlap) + PublicShelfFailed -> failed_state(handle, overlap) PublicShelfLoaded(_, loaded_handle, []) if loaded_handle == handle -> - empty_state(handle) + empty_state(handle, overlap) PublicShelfLoaded(_, loaded_handle, items) if loaded_handle == handle -> - loaded_state(handle, items) - PublicShelfLoaded(_, _, _) -> loading_state(handle) + loaded_state(handle, items, overlap) + PublicShelfLoaded(_, _, _) -> loading_state(handle, overlap) } } -fn loaded_state(handle: String, items: List(Entry)) -> Element(Msg) { +fn visible_overlap(model: Model, handle: String) -> Option(CrateOverlap) { + case model.auth, model.overlap { + LoggedIn(viewer), Some(overlap) if viewer != handle -> + case overlap.common_count > 0 || option.is_some(detail_line(overlap)) { + True -> Some(overlap) + False -> None + } + _, _ -> None + } +} + +fn loaded_state( + handle: String, + items: List(Entry), + overlap: Option(CrateOverlap), +) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(handle, list.length(items)), + hero(handle, list.length(items), overlap), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [grid(handle, items)]), ]), ]) } -fn hero(handle: String, count: Int) -> Element(Msg) { +fn hero( + handle: String, + count: Int, + overlap: Option(CrateOverlap), +) -> Element(Msg) { html.div([attr.class("hero")], [ html.div([attr.class("hero__bar hero__bar--public")], [ bar.icon_link("←", "Back", "/"), @@ -49,12 +70,54 @@ fn hero(handle: String, count: Int) -> Element(Msg) { text("@" <> handle), ]), ]), - html.span([attr.class("hero__count")], [ - text(int.to_string(count) <> " in the crate"), + html.div([attr.class("hero__content")], [ + case overlap { + Some(o) -> overlap_panel(o) + None -> element.none() + }, + html.span([attr.class("hero__count")], [ + text(int.to_string(count) <> " in the crate"), + ]), ]), ]) } +fn overlap_panel(overlap: CrateOverlap) -> Element(Msg) { + html.div([attr.class("hero__overlap")], [ + html.span([attr.class("chip chip--owned")], [text("TASTE OVERLAP")]), + html.p([attr.class("hero__overlap-headline")], [ + text(plural.count_noun(overlap.common_count, "record") <> " in common"), + ]), + case detail_line(overlap) { + Some(line) -> html.p([attr.class("hero__overlap-detail")], [text(line)]) + None -> element.none() + }, + ]) +} + +fn detail_line(overlap: CrateOverlap) -> Option(String) { + let parts = + [ + option.map(overlap.both_wanted_title, fn(title) { + "you both want " <> title + }), + case overlap.want_match_count > 0 { + True -> + Some( + "they have " + <> plural.count_noun(overlap.want_match_count, "record") + <> " from your wantlist", + ) + False -> None + }, + ] + |> option.values + case parts { + [] -> None + _ -> Some(string.join(parts, " · ")) + } +} + fn grid(handle: String, items: List(Entry)) -> Element(Msg) { html.div([attr.class("grid")], list.map(items, card(handle, _))) } @@ -78,9 +141,12 @@ fn format_line(snap: Snapshot) -> String { |> string.join(" / ") } -fn loading_state(handle: String) -> Element(Msg) { +fn loading_state( + handle: String, + overlap: Option(CrateOverlap), +) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(handle, 0), + hero(handle, 0, overlap), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ html.p([attr.class("loading-status")], [text("◌ FETCHING…")]), @@ -89,9 +155,9 @@ fn loading_state(handle: String) -> Element(Msg) { ]) } -fn empty_state(handle: String) -> Element(Msg) { +fn empty_state(handle: String, overlap: Option(CrateOverlap)) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(handle, 0), + hero(handle, 0, overlap), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ html.p([attr.class("empty")], [ @@ -102,9 +168,9 @@ fn empty_state(handle: String) -> Element(Msg) { ]) } -fn failed_state(handle: String) -> Element(Msg) { +fn failed_state(handle: String, overlap: Option(CrateOverlap)) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(handle, 0), + hero(handle, 0, overlap), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ states.error_sticker( diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 915cd28..a29809c 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -2,20 +2,20 @@ 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, handle_search, load_avatar, load_browse, - load_pressing, load_public_entry, load_public_shelf, load_shelf, - load_shelf_more, load_timeline, logout, oauth_login, rate, regrade, + 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, } import at_record_web/model.{ type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, - BrowseRelease, 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, Scan, ScanDone, ScanReview, - ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, - crate_window_size, + 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, Scan, + ScanDone, ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, + blank_form, crate_window_size, } import at_record_web/money import at_record_web/msg.{ @@ -29,15 +29,16 @@ import at_record_web/msg.{ FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, - GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, - GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, GotLogout, GotPressing, - GotPublicEntry, GotPublicShelf, GotScanResult, GotScanSeen, GotShelf, - GotShelfMore, GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, - LinkCopied, Logout, OnRouteChange, PhotoCaptured, Rate, Regrade, RemoveScanRow, - RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, SetTheme, SetView, - ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, SubmitAmend, TakePhoto, - ToggleAmend, ToggleAmendCover, ToggleBrowseFilters, ToggleEdit, ToggleEntry, - TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, + GotBrowseSearch, GotCrateOverlap, GotDiscogs, GotDiscogsDisconnect, + GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, + GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, GotScanResult, + GotScanSeen, GotShelf, GotShelfMore, GotTimeline, HandleChanged, HandleSearch, + IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, Rate, + Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, + SetTheme, SetView, ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, + SubmitAmend, TakePhoto, ToggleAmend, ToggleAmendCover, ToggleBrowseFilters, + ToggleEdit, ToggleEntry, TriggerBrowseSearch, UseArtist, UseDiscogs, + UseHandleSuggestion, } import at_record_web/photo_scan import at_record_web/route @@ -151,6 +152,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { PublicRecord(_, _) -> PublicEntryLoading _ -> model.public_entry }, + overlap: case route { + PublicCrate(_) -> None + _ -> model.overlap + }, pressing: PressingLoading, ), effect.batch([ @@ -162,7 +167,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effect.batch([start_capture(scan.mode), effects.scan_seen()]) Browse -> load_browse() EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() - PublicCrate(handle) -> load_public_shelf(handle) + PublicCrate(handle) -> + effect.batch([ + load_public_shelf(handle), + overlap_effect(model.auth, handle), + ]) PublicRecord(handle, entry_id) -> load_public_entry(handle, entry_id) _ -> effect.none() @@ -309,10 +318,16 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { crate_window: crate_window_size, crate_cursor: data.cursor, ), - case model.avatar { - None -> load_avatar(data.handle) - Some(_) -> effect.none() - }, + effect.batch([ + case model.avatar { + None -> load_avatar(data.handle) + Some(_) -> effect.none() + }, + case model.route { + PublicCrate(handle) -> overlap_effect(LoggedIn(data.handle), handle) + _ -> effect.none() + }, + ]), ) // Appends the next page, deduping defensively by entry id in case a @@ -1192,6 +1207,18 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, public_entry: PublicEntryFailed), effect.none(), ) + GotCrateOverlap(Ok(data)) -> #( + Model( + ..model, + overlap: Some(CrateOverlap( + common_count: data.common_count, + both_wanted_title: data.both_wanted_title, + want_match_count: data.want_match_count, + )), + ), + effect.none(), + ) + GotCrateOverlap(Error(_)) -> #(Model(..model, overlap: None), effect.none()) // Only signed-in visitors see the copy-link button (their own record // page), so a logged-out call is unreachable in practice. @@ -1436,6 +1463,13 @@ fn leaving_scan(model: Model) -> Effect(Msg) { } } +fn overlap_effect(auth: model.Auth, handle: String) -> Effect(Msg) { + case auth { + LoggedIn(viewer) if viewer != handle -> load_crate_overlap(handle) + _ -> effect.none() + } +} + /// Both armed confirms reset on every route change; drop their outside-click /// watchers too so a stale one never lingers on a button that just left the /// page. diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam index dc1685c..cb9bcce 100644 --- a/web/test/public_crate_test.gleam +++ b/web/test/public_crate_test.gleam @@ -1,19 +1,19 @@ //// Public-route tests: `/u/:handle` and `/u/:handle/record/:entryId`. import at_record_web/model.{ - LoggedOut, Model, PublicCrate, PublicEntryFailed, PublicEntryLoaded, - PublicEntryLoading, PublicRecord, PublicShelfFailed, PublicShelfLoaded, - PublicShelfLoading, + type Model, CrateOverlap, LoggedIn, LoggedOut, Model, PublicCrate, + PublicEntryFailed, PublicEntryLoaded, PublicEntryLoading, PublicRecord, + PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, } import at_record_web/msg.{ - CopyRecordLink, GotPublicEntry, GotPublicShelf, LinkCopied, OnRouteChange, - PublicEntryData, PublicShelfData, + CopyRecordLink, CrateOverlapData, GotCrateOverlap, GotPublicEntry, + GotPublicShelf, LinkCopied, OnRouteChange, PublicEntryData, PublicShelfData, } import at_record_web/route import at_record_web/update.{update} import at_record_web/view import gleam/list -import gleam/option.{None} +import gleam/option.{None, Some} import gleam/string import gleam/uri import lustre/element @@ -138,3 +138,87 @@ pub fn link_copied_sets_a_notice_either_way_test() { let #(fail_model, _) = update(base(), LinkCopied(False)) assert fail_model.notice != None } + +pub fn on_route_change_to_public_crate_resets_overlap_test() { + let stale = Model(..base(), overlap: Some(CrateOverlap(3, None, 0))) + let #(model, _) = update(stale, OnRouteChange(PublicCrate("bob.test"))) + assert model.overlap == None +} + +pub fn got_crate_overlap_ok_sets_overlap_test() { + let #(model, _) = + update( + base(), + GotCrateOverlap( + Ok(CrateOverlapData( + common_count: 2, + both_wanted_title: Some("Spiderland"), + want_match_count: 1, + )), + ), + ) + assert model.overlap == Some(CrateOverlap(2, Some("Spiderland"), 1)) +} + +pub fn got_crate_overlap_error_clears_overlap_test() { + let stale = Model(..base(), overlap: Some(CrateOverlap(2, None, 1))) + let #(model, _) = update(stale, GotCrateOverlap(Error(unauthorized()))) + assert model.overlap == None +} + +fn public_crate_model(viewer_handle: String, handle: String) -> Model { + Model( + ..base(), + auth: LoggedIn(viewer_handle), + route: PublicCrate(handle), + public_shelf: PublicShelfLoaded("did:plc:a", handle, [an_entry()]), + ) +} + +pub fn overlap_panel_shows_when_signed_in_viewing_someone_else_test() { + let html = + Model( + ..public_crate_model("me.test", "alice.test"), + overlap: Some(CrateOverlap(3, Some("Spiderland"), 2)), + ) + |> view.view + |> element.to_string + assert string.contains(html, "TASTE OVERLAP") + assert string.contains(html, "3 records in common") + assert string.contains(html, "you both want Spiderland") + assert string.contains(html, "they have 2 records from your wantlist") +} + +pub fn overlap_panel_hidden_when_viewing_own_crate_test() { + let html = + Model( + ..public_crate_model("alice.test", "alice.test"), + overlap: Some(CrateOverlap(3, None, 0)), + ) + |> view.view + |> element.to_string + assert !string.contains(html, "TASTE OVERLAP") +} + +pub fn overlap_panel_hidden_when_logged_out_test() { + let html = + Model( + ..public_crate_model("me.test", "alice.test"), + auth: LoggedOut, + overlap: Some(CrateOverlap(3, None, 0)), + ) + |> view.view + |> element.to_string + assert !string.contains(html, "TASTE OVERLAP") +} + +pub fn overlap_panel_hidden_when_nothing_to_show_test() { + let html = + Model( + ..public_crate_model("me.test", "alice.test"), + overlap: Some(CrateOverlap(0, None, 0)), + ) + |> view.view + |> element.to_string + assert !string.contains(html, "TASTE OVERLAP") +} diff --git a/web/test/support.gleam b/web/test/support.gleam index 03d7254..aae7182 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -63,6 +63,7 @@ pub fn base() -> Model { ignored_proposals: [], public_shelf: model.PublicShelfLoading, public_entry: model.PublicEntryLoading, + overlap: None, pressing: model.PressingLoading, ) }