From 2fb238a8d30afe6a1dae9996a176bd3dd0f3b735 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 19 Jul 2026 17:25:50 +0200 Subject: [PATCH] refactor: actor-scoped shelf queries --- e2e/fixtures/xrpc.ts | 3 + e2e/tests/adoption.spec.ts | 3 +- e2e/tests/edit-inbox.spec.ts | 7 +- .../dev/mokkenstorm/crate/shelf/getEntry.sdl | 11 +- .../crate/shelf/getPublicEntry.sdl | 12 - .../crate/shelf/getPublicShelf.sdl | 9 - .../mokkenstorm/crate/shelf/listEntries.sdl | 6 +- server/src/at_record_server/context.gleam | 13 + server/src/at_record_server/edit_inbox.gleam | 2 +- server/src/at_record_server/event_log.gleam | 11 +- .../src/at_record_server/graph_follows.gleam | 11 +- .../handlers/crate_overlap.gleam | 10 +- .../handlers/edit_inbox.gleam | 5 +- .../handlers/public_shelf.gleam | 172 ---------- .../src/at_record_server/handlers/shelf.gleam | 229 +++++++++---- .../oauth/client_metadata.gleam | 20 +- server/src/at_record_server/promotion.gleam | 2 +- server/src/at_record_server/router.gleam | 95 +++--- server/src/at_record_server/shelf_owner.gleam | 208 ++++++++++++ ...helf_test.gleam => actor_shelf_test.gleam} | 149 ++++++-- server/test/browse_handler_test.gleam | 5 +- server/test/crate_overlap_test.gleam | 20 +- server/test/discogs_scan_test.gleam | 2 +- server/test/edit_inbox_list_test.gleam | 2 +- server/test/feed_handler_test.gleam | 16 +- server/test/graph_handler_test.gleam | 6 +- server/test/shelf_list_test.gleam | 5 +- server/test/shelf_owner_test.gleam | 40 +++ server/test/support.gleam | 6 + shared/src/at_record/storage.gleam | 23 ++ web/src/at_record_web.gleam | 26 +- web/src/at_record_web/browser.gleam | 2 +- web/src/at_record_web/effects.gleam | 318 ++++++++---------- web/src/at_record_web/ffi.mjs | 4 +- web/src/at_record_web/model.gleam | 116 ++++--- web/src/at_record_web/msg.gleam | 45 +-- web/src/at_record_web/pages/crate.gleam | 10 +- web/src/at_record_web/pages/feed.gleam | 10 +- .../at_record_web/pages/public_crate.gleam | 16 +- .../at_record_web/pages/public_record.gleam | 21 +- web/src/at_record_web/pages/record.gleam | 21 +- web/src/at_record_web/update.gleam | 311 ++++++++--------- web/src/at_record_web/view.gleam | 32 +- web/test/crate_test.gleam | 119 ++++--- web/test/feed_test.gleam | 18 +- web/test/login_test.gleam | 4 +- web/test/nav_test.gleam | 18 +- web/test/public_crate_test.gleam | 117 ++++--- web/test/record_amend_test.gleam | 50 +-- web/test/record_test.gleam | 80 +++-- web/test/scan_test.gleam | 28 +- web/test/support.gleam | 33 +- 52 files changed, 1440 insertions(+), 1062 deletions(-) create mode 100644 e2e/fixtures/xrpc.ts delete mode 100644 lexicons/dev/mokkenstorm/crate/shelf/getPublicEntry.sdl delete mode 100644 lexicons/dev/mokkenstorm/crate/shelf/getPublicShelf.sdl delete mode 100644 server/src/at_record_server/handlers/public_shelf.gleam create mode 100644 server/src/at_record_server/shelf_owner.gleam rename server/test/{public_shelf_test.gleam => actor_shelf_test.gleam} (58%) create mode 100644 server/test/shelf_owner_test.gleam diff --git a/e2e/fixtures/xrpc.ts b/e2e/fixtures/xrpc.ts new file mode 100644 index 0000000..8bf3e9c --- /dev/null +++ b/e2e/fixtures/xrpc.ts @@ -0,0 +1,3 @@ +/** The crate XRPC namespace, centralised so a production swap is one edit. */ +export const xrpcPath = (method: string): string => + `/xrpc/dev.mokkenstorm.crate.${method}`; diff --git a/e2e/tests/adoption.spec.ts b/e2e/tests/adoption.spec.ts index 0e588aa..10ea722 100644 --- a/e2e/tests/adoption.spec.ts +++ b/e2e/tests/adoption.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import { loginAs } from "../fixtures/login.js"; +import { xrpcPath } from "../fixtures/xrpc.js"; const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; @@ -13,7 +14,7 @@ async function addFirstSearchResult(page: Page, query: string): Promise { // isn't reliable for that, so wait for the actual POST to resolve. const saved = page.waitForResponse( (r) => - r.url().endsWith("/xrpc/dev.mokkenstorm.crate.shelf.addEntry") && + r.url().endsWith(xrpcPath("shelf.addEntry")) && r.request().method() === "POST", { timeout: 15_000 }, ); diff --git a/e2e/tests/edit-inbox.spec.ts b/e2e/tests/edit-inbox.spec.ts index f06ca7e..d56c7e8 100644 --- a/e2e/tests/edit-inbox.spec.ts +++ b/e2e/tests/edit-inbox.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import { loginAs } from "../fixtures/login.js"; +import { xrpcPath } from "../fixtures/xrpc.js"; const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; @@ -10,7 +11,7 @@ async function addFirstSearchResult(page: Page, query: string): Promise { await page.click(".suggestions li >> nth=0"); const saved = page.waitForResponse( (r) => - r.url().endsWith("/xrpc/dev.mokkenstorm.crate.shelf.addEntry") && + r.url().endsWith(xrpcPath("shelf.addEntry")) && r.request().method() === "POST", { timeout: 15_000 }, ); @@ -53,7 +54,7 @@ test("alice proposes a correction on bob's record; bob reviews and applies it", const proposed = alicePage.waitForResponse( (r) => - r.url().endsWith("/xrpc/dev.mokkenstorm.crate.shelf.amendEntry") && + r.url().endsWith(xrpcPath("shelf.amendEntry")) && r.request().method() === "POST", { timeout: 15_000 }, ); @@ -77,7 +78,7 @@ test("alice proposes a correction on bob's record; bob reviews and applies it", const applied = bobPage.waitForResponse( (r) => - r.url().endsWith("/xrpc/dev.mokkenstorm.crate.catalog.applyEditProposal") && + r.url().endsWith(xrpcPath("catalog.applyEditProposal")) && r.request().method() === "POST", { timeout: 15_000 }, ); diff --git a/lexicons/dev/mokkenstorm/crate/shelf/getEntry.sdl b/lexicons/dev/mokkenstorm/crate/shelf/getEntry.sdl index 9a22f13..b7213a2 100644 --- a/lexicons/dev/mokkenstorm/crate/shelf/getEntry.sdl +++ b/lexicons/dev/mokkenstorm/crate/shelf/getEntry.sdl @@ -1,7 +1,12 @@ -/// The full event timeline for one entry, plus its resolved release fields. -query getEntry(entry: String! @description("The entry's genesis TID.")) @main +/// One folded crate entry plus its full event timeline and resolved release +/// fields. Reads the session's own crate by default; pass `actor` to read any +/// account's public entry with no auth. +query getEntry(actor: AtIdentifier @description("Handle or DID of the crate's owner; defaults to the session's account, which then requires auth."), entry: String! @description("The entry's genesis TID.")) @main : { - entryId: String! + did: Did! + handle: Handle! + /// The folded current state of the entry, same shape as a listEntries row. + entry: dev.mokkenstorm.crate.shelf.listEntries#entry! /// Every shelf event for the entry, oldest first. events: [dev.mokkenstorm.crate.shelf.entry]! /// Display fields from the resolved catalog release, omitted when unresolvable. diff --git a/lexicons/dev/mokkenstorm/crate/shelf/getPublicEntry.sdl b/lexicons/dev/mokkenstorm/crate/shelf/getPublicEntry.sdl deleted file mode 100644 index 68692b8..0000000 --- a/lexicons/dev/mokkenstorm/crate/shelf/getPublicEntry.sdl +++ /dev/null @@ -1,12 +0,0 @@ -/// One entry off any actor's crate, folded, plus its resolved release -/// fields; no auth required, mirroring getPublicShelf. The whole record is -/// public network data, so nothing here is narrower than getEntry's own -/// fields beyond dropping the raw per-event timeline. -query getPublicEntry(actor: AtIdentifier! @description("Handle or DID of the crate's owner."), entryId: String! @description("The entry's genesis TID.")) @main - : { - did: Did! - handle: Handle! - entry: dev.mokkenstorm.crate.shelf.listEntries#entry! - /// Display fields from the resolved catalog release, omitted when unresolvable. - release: dev.mokkenstorm.crate.defs#releaseDisplay - } diff --git a/lexicons/dev/mokkenstorm/crate/shelf/getPublicShelf.sdl b/lexicons/dev/mokkenstorm/crate/shelf/getPublicShelf.sdl deleted file mode 100644 index bf9d701..0000000 --- a/lexicons/dev/mokkenstorm/crate/shelf/getPublicShelf.sdl +++ /dev/null @@ -1,9 +0,0 @@ -/// The folded crate for any actor, with no auth required: shelf records live -/// in the owner's own PDS and are public network data, so this reads them -/// straight off the actor's repo instead of the caller's session. -query getPublicShelf(actor: AtIdentifier! @description("Handle or DID of the crate's owner.")) @main - : { - did: Did! - handle: Handle! - items: [dev.mokkenstorm.crate.shelf.listEntries#entry]! - } diff --git a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.sdl b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.sdl index 081f0fb..a763997 100644 --- a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.sdl +++ b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.sdl @@ -15,10 +15,12 @@ type entry { updatedAt: Datetime! } -/// The caller's folded crate for a view, one row per entry. -query listEntries(cursor: String @description("Opaque pagination token from a previous response's cursor field; an unrecognized or stale cursor restarts from the top."), limit: Int @description("Max entries to return, capped at 100. Omitted together with cursor, returns the full view (pre-pagination behavior)."), view: String @description("Which slice of the crate to fold; defaults to owned.") @known("owned", "wanted", "all", "history")) @main +/// A folded crate for a view, one row per entry. Reads the session's own crate +/// by default; pass `actor` to read any account's public crate with no auth. +query listEntries(actor: AtIdentifier @description("Handle or DID of the crate's owner; defaults to the session's account, which then requires auth."), cursor: String @description("Opaque pagination token from a previous response's cursor field; an unrecognized or stale cursor restarts from the top."), limit: Int @description("Max entries to return, capped at 100. Omitted together with cursor, returns the full view (pre-pagination behavior)."), view: String @description("Which slice of the crate to fold; defaults to owned. `current` is every non-gone entry, the public-crate view.") @known("owned", "wanted", "all", "history", "current")) @main : { cursor: String @description("Opaque token for the next page; absent when there are no more entries.") + did: Did! handle: Handle! items: [entry]! /// Map of entryId to the @handle of the foreign publisher an adopted release came from. diff --git a/server/src/at_record_server/context.gleam b/server/src/at_record_server/context.gleam index 745cd12..1a783ca 100644 --- a/server/src/at_record_server/context.gleam +++ b/server/src/at_record_server/context.gleam @@ -52,6 +52,18 @@ pub type Context { ) } +/// An optional response field as a zero-or-one element list, so handlers can +/// `list.flatten` it into a `json.object` instead of casing at every call site. +pub fn optional_field( + name: String, + value: Option(String), +) -> List(#(String, json.Json)) { + case value { + Some(v) -> [#(name, json.string(v))] + None -> [] + } +} + /// XRPC error body: a PascalCase `error` name derived from the status plus the /// human-readable `message`. pub fn error_json(status: Int, message: String) -> Response { @@ -69,6 +81,7 @@ fn error_name(status: Int) -> String { 401 -> "AuthenticationRequired" 404 -> "NotFound" 409 -> "Conflict" + 413 -> "PayloadTooLarge" 429 -> "RateLimitExceeded" 502 -> "UpstreamFailure" 503 -> "ServiceUnavailable" diff --git a/server/src/at_record_server/edit_inbox.gleam b/server/src/at_record_server/edit_inbox.gleam index 7eb64da..b5df681 100644 --- a/server/src/at_record_server/edit_inbox.gleam +++ b/server/src/at_record_server/edit_inbox.gleam @@ -26,7 +26,7 @@ import gleam/set import gleam/string import wisp -const edit_backlink_source = "dev.mokkenstorm.crate.catalog.edit:subject.uri" +const edit_backlink_source = catalog_edit.collection <> ":subject.uri" // How many of the caller's own current releases are queried for backlinks // per request; releases beyond this (newest-first) are dropped and logged. diff --git a/server/src/at_record_server/event_log.gleam b/server/src/at_record_server/event_log.gleam index 1c0b9b2..e136dae 100644 --- a/server/src/at_record_server/event_log.gleam +++ b/server/src/at_record_server/event_log.gleam @@ -6,7 +6,6 @@ import at_record/gen/shelf/entry.{type ShelfEntry, encode_shelf_entry} import at_record/storage.{type StoredItem} import at_record_server/oauth/sessions.{type OauthSession} import atproto/repo -import atproto/uri import atproto/xrpc.{type Client, type XrpcError} import gleam/dynamic/decode import gleam/list @@ -103,13 +102,5 @@ pub fn delete( } fn row_decoder() -> decode.Decoder(StoredItem(ShelfEntry)) { - use at_uri <- decode.field("uri", decode.string) - use cid <- decode.field("cid", decode.string) - use value <- decode.field("value", entry.shelf_entry_decoder()) - decode.success(storage.StoredItem( - uri: at_uri, - cid:, - rkey: uri.rkey(at_uri), - value:, - )) + storage.list_records_row_decoder(entry.shelf_entry_decoder()) } diff --git a/server/src/at_record_server/graph_follows.gleam b/server/src/at_record_server/graph_follows.gleam index 343e140..a104699 100644 --- a/server/src/at_record_server/graph_follows.gleam +++ b/server/src/at_record_server/graph_follows.gleam @@ -6,7 +6,6 @@ import at_record/storage.{type StoredItem} import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/provenance import atproto/repo -import atproto/uri import atproto/xrpc.{type Client, type XrpcError} import gleam/dynamic/decode import gleam/list @@ -72,13 +71,5 @@ pub fn delete( } fn row_decoder() -> decode.Decoder(StoredItem(GraphFollow)) { - use at_uri <- decode.field("uri", decode.string) - use cid <- decode.field("cid", decode.string) - use value <- decode.field("value", follow.graph_follow_decoder()) - decode.success(storage.StoredItem( - uri: at_uri, - cid:, - rkey: uri.rkey(at_uri), - value:, - )) + storage.list_records_row_decoder(follow.graph_follow_decoder()) } diff --git a/server/src/at_record_server/handlers/crate_overlap.gleam b/server/src/at_record_server/handlers/crate_overlap.gleam index 7160c5b..949c157 100644 --- a/server/src/at_record_server/handlers/crate_overlap.gleam +++ b/server/src/at_record_server/handlers/crate_overlap.gleam @@ -6,8 +6,8 @@ import at_record_server/context.{ import at_record_server/crate.{type CrateEntry} import at_record_server/event_log import at_record_server/graph_follows -import at_record_server/handlers/public_shelf import at_record_server/oauth/sessions.{type OauthSession} +import at_record_server/shelf_owner import atproto/xrpc.{type Client} import gleam/json import gleam/list @@ -39,15 +39,17 @@ fn do_get_crate_overlap( actor: String, ) -> Response { use client, session <- with_pds_client(ctx, id, session) - case public_shelf.resolve_actor(ctx, actor) { + case shelf_owner.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) -> + case shelf_owner.fetch_public_entries(ctx, pds, did) { + Error(shelf_owner.FetchFailed) -> error_json(502, "could not load that user's crate from their PDS") + Error(shelf_owner.TooManyPages) -> + error_json(413, "that user's crate is too large to page through") Ok(their_stored) -> overlap(crate.fold(own_stored), crate.fold(their_stored)) |> encode_overlap(viewer_follow_fields(client, session, did)) diff --git a/server/src/at_record_server/handlers/edit_inbox.gleam b/server/src/at_record_server/handlers/edit_inbox.gleam index b998e91..f4eedf9 100644 --- a/server/src/at_record_server/handlers/edit_inbox.gleam +++ b/server/src/at_record_server/handlers/edit_inbox.gleam @@ -64,10 +64,7 @@ pub fn list_proposals(req: Request, ctx: Context) -> Response { json.array(page, list_edit_proposals.encode_proposal_row), ), ], - case next_cursor { - Some(c) -> [#("cursor", json.string(c))] - None -> [] - }, + context.optional_field("cursor", next_cursor), ]), ) |> json.to_string diff --git a/server/src/at_record_server/handlers/public_shelf.gleam b/server/src/at_record_server/handlers/public_shelf.gleam deleted file mode 100644 index 2e30635..0000000 --- a/server/src/at_record_server/handlers/public_shelf.gleam +++ /dev/null @@ -1,172 +0,0 @@ -//// Public, unauthenticated reads of another repo's shelf: a crate's records -//// live in its owner's own PDS and are public network data (same as -//// browse's fan-out), so these hit the actor's PDS directly with no -//// session, then fold via the same `crate`/`event_log` machinery the authed -//// shelf handlers use. - -import at_record/gen/catalog/release as catalog_release -import at_record/gen/client as generated_client -import at_record/gen/shelf/entry -import at_record/storage.{type StoredItem, StoredItem} -import at_record_server/context.{type Context, error_json} -import at_record_server/crate -import at_record_server/event_log -import atproto/uri -import gleam/dynamic/decode -import gleam/json -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/result -import wisp.{type Request, type Response} - -pub fn get_public_shelf(req: Request, ctx: Context) -> Response { - case list.key_find(wisp.get_query(req), "actor") { - Error(Nil) -> error_json(400, "missing actor") - Ok(actor) -> do_get_public_shelf(ctx, actor) - } -} - -fn do_get_public_shelf(ctx: Context, actor: String) -> Response { - case resolve_actor(ctx, actor) { - Error(Nil) -> error_json(404, "could not resolve that user") - Ok(#(did, handle, pds)) -> - case fetch_public_entries(ctx, pds, did) { - Error(Nil) -> - error_json(502, "could not load that user's crate from their PDS") - Ok(stored) -> { - // Snapshot-less entries (legacy/purge remnants) would fail the - // frontend decoder, so they never leave the BFF; a sold/dropped - // entry is still shown, since a public profile is meant to read - // like the owner's whole current shelf, not just what's kept. - let entries = - crate.fold(stored) - |> list.filter(fn(e) { e.status != crate.Gone }) - |> list.filter(fn(e) { e.snapshot != None }) - json.object([ - #("did", json.string(did)), - #("handle", json.string(handle)), - #("items", json.array(entries, crate.encode_entry)), - ]) - |> json.to_string - |> wisp.json_response(200) - } - } - } -} - -pub fn get_public_entry(req: Request, ctx: Context) -> Response { - let query = wisp.get_query(req) - case list.key_find(query, "actor"), list.key_find(query, "entryId") { - Ok(actor), Ok(entry_id) -> do_get_public_entry(ctx, actor, entry_id) - _, _ -> error_json(400, "missing actor or entryId") - } -} - -fn do_get_public_entry( - ctx: Context, - actor: String, - entry_id: String, -) -> Response { - case resolve_actor(ctx, actor) { - Error(Nil) -> error_json(404, "could not resolve that user") - Ok(#(did, handle, pds)) -> - case fetch_public_entries(ctx, pds, did) { - Error(Nil) -> - error_json(502, "could not load that user's crate from their PDS") - Ok(stored) -> - case event_log.entry_events(stored, entry_id) { - Error(Nil) -> error_json(404, "unknown entry") - Ok(events) -> - case crate.fold(events) |> list.first { - Error(Nil) -> error_json(404, "unknown entry") - Ok(folded) -> { - let release = resolve_release(ctx, folded) - json.object( - list.flatten([ - [ - #("did", json.string(did)), - #("handle", json.string(handle)), - #("entry", crate.encode_entry(folded)), - ], - case release { - Some(r) -> [ - #("release", crate.encode_release_info(r)), - ] - None -> [] - }, - ]), - ) - |> json.to_string - |> wisp.json_response(200) - } - } - } - } - } -} - -// Best-effort read: no ref, or a fetch miss, just means the response omits it. -fn resolve_release( - ctx: Context, - entry: crate.CrateEntry, -) -> Option(catalog_release.CatalogRelease) { - entry.release - |> option.then(fn(ref) { ctx.catalog.fetch_release(ref.uri) }) - |> option.map(fn(pair) { pair.1 }) -} - -/// Handle or DID -> did/handle/pds, via the same Slingshot-backed identity -/// 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. -pub fn resolve_actor( - ctx: Context, - actor: String, -) -> Result(#(String, String, String), Nil) { - generated_client.identity_resolve_mini_doc( - ctx.atproto.client, - ctx.atproto.resolver, - generated_client.IdentityResolveMiniDocParams(identifier: actor), - None, - ) - |> result.map(fn(doc) { #(doc.did, doc.handle, doc.pds) }) - |> result.replace_error(Nil) -} - -/// Public `listRecords` against the actor's own PDS, no token: same -/// 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. -pub fn fetch_public_entries( - ctx: Context, - pds: String, - did: String, -) -> Result(List(StoredItem(entry.ShelfEntry)), Nil) { - let params = - generated_client.RepoListRecordsParams( - collection: entry.collection, - cursor: None, - limit: Some(100), - repo: did, - reverse: None, - ) - case - generated_client.repo_list_records(ctx.atproto.client, pds, params, None) - { - Error(_) -> Error(Nil) - Ok(output) -> - output.records - |> list.try_map(fn(row) { - decode.run(row.value, entry.shelf_entry_decoder()) - |> result.map(fn(value) { - StoredItem( - uri: row.uri, - cid: row.cid, - rkey: uri.rkey(row.uri), - value:, - ) - }) - }) - |> result.replace_error(Nil) - } -} diff --git a/server/src/at_record_server/handlers/shelf.gleam b/server/src/at_record_server/handlers/shelf.gleam index 0929fa7..c15279e 100644 --- a/server/src/at_record_server/handlers/shelf.gleam +++ b/server/src/at_record_server/handlers/shelf.gleam @@ -25,6 +25,7 @@ import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/pagination import at_record_server/promotion import at_record_server/provenance.{now_rfc3339} +import at_record_server/shelf_owner import atproto/blob import atproto/uri import atproto/xrpc.{type Client} @@ -38,61 +39,124 @@ import gleam/result import gleam/string import wisp.{type Request, type Response} +/// The folded crate for one view: the session's own by default, or any +/// actor's public crate when `actor` is present (no auth required then). pub fn list_shelf(req: Request, ctx: Context) -> Response { + case list.key_find(wisp.get_query(req), "actor") { + Ok(actor) -> list_actor_shelf(req, ctx, actor) + Error(Nil) -> list_own_shelf(req, ctx) + } +} + +fn list_own_shelf(req: Request, ctx: Context) -> Response { use id, session <- require_session(req, ctx) use client, session <- with_pds_client(ctx, id, session) case event_log.load(client, session) { Error(_) -> error_json(502, "could not load crate from PDS") - Ok(events) -> { - let query = wisp.get_query(req) - let view = list.key_find(query, "view") |> result.unwrap("owned") - // Snapshot-less entries (legacy/purge remnants) would fail the frontend decoder, so they never leave the BFF. - // The fold needs every event to reduce correctly, so pagination slices - // the already-folded, filtered, deterministically-ordered view rather - // than the raw log. - let entries = - crate.fold(events) - |> list.filter(in_view(view, _)) - |> list.filter(fn(e) { e.snapshot != None }) - |> list.sort(fn(a, b) { string.compare(a.entry_id, b.entry_id) }) - let cursor = list.key_find(query, "cursor") |> option.from_result - let limit = - list.key_find(query, "limit") - |> result.try(int.parse) - |> option.from_result - let #(page, next_cursor) = - pagination.page(entries, fn(e) { e.entry_id }, view, cursor, limit) - let resolved = list.map(page, resolve_cover(session, _)) - json.object( - list.flatten([ - [ - #("handle", json.string(session.handle)), - #("items", json.array(resolved, crate.encode_entry)), - #( - "viaHandles", - json.object( - via_handles(ctx, session, resolved) - |> dict.to_list - |> list.map(fn(pair) { #(pair.0, json.string(pair.1)) }), - ), - ), - ], - case next_cursor { - Some(c) -> [#("cursor", json.string(c))] - None -> [] - }, - ]), - ) - |> json.to_string - |> wisp.json_response(200) - } + Ok(events) -> + shelf_response(req, ctx, session.did, session.handle, events, False) + } +} + +fn list_actor_shelf(req: Request, ctx: Context, actor: String) -> Response { + case shelf_owner.resolve_actor(ctx, actor) { + Error(Nil) -> error_json(404, "could not resolve that user") + Ok(#(did, handle, pds)) -> + case shelf_owner.fetch_public_entries(ctx, pds, did) { + Error(shelf_owner.FetchFailed) -> + error_json(502, "could not load that user's crate from their PDS") + Error(shelf_owner.TooManyPages) -> + error_json(413, "that user's crate is too large to page through") + Ok(events) -> shelf_response(req, ctx, did, handle, events, True) + } + } +} + +/// The shared read pipeline for own and actor mode: fold to one page, resolve +/// covers, attribute adopted releases, shape the response. `public` is True +/// on the unauthenticated actor path. +fn shelf_response( + req: Request, + ctx: Context, + did: String, + handle: String, + events: List(StoredItem(ShelfEntry)), + public: Bool, +) -> Response { + let query = wisp.get_query(req) + let view = list.key_find(query, "view") |> result.unwrap("owned") + // A stranger reads the current shelf only: `history`/`all` would expose + // entries the owner sold or dropped. + let view = case public { + True -> "current" + False -> view } + let #(page, next_cursor) = folded_page(events, view, query) + let items = list.map(page, resolve_cover(did, _)) + json.object( + list.flatten([ + [ + #("did", json.string(did)), + #("handle", json.string(handle)), + #("items", json.array(items, crate.encode_entry)), + #( + "viaHandles", + encode_handle_map(via_handles_for(ctx, public, did, items)), + ), + ], + context.optional_field("cursor", next_cursor), + ]), + ) + |> json.to_string + |> wisp.json_response(200) } -// Attributes entries whose release was adopted from someone else's repo; deduped per unique foreign DID. +/// One page of the folded view. The fold needs every event to reduce +/// correctly, so pagination slices the folded, filtered, deterministically +/// ordered view rather than the raw log; snapshot-less remnants would fail +/// the frontend decoder, so they never leave the BFF. +fn folded_page( + events: List(StoredItem(ShelfEntry)), + view: String, + query: List(#(String, String)), +) -> #(List(crate.CrateEntry), Option(String)) { + let cursor = list.key_find(query, "cursor") |> option.from_result + let limit = + list.key_find(query, "limit") |> result.try(int.parse) |> option.from_result + crate.fold(events) + |> list.filter(in_view(view, _)) + |> list.filter(fn(e) { e.snapshot != None }) + |> list.sort(fn(a, b) { string.compare(a.entry_id, b.entry_id) }) + |> pagination.page(fn(e) { e.entry_id }, view, cursor, limit) +} + +fn encode_handle_map(handles: Dict(String, String)) -> json.Json { + handles + |> dict.to_list + |> list.map(fn(pair) { #(pair.0, json.string(pair.1)) }) + |> json.object +} + +// The actor (unauthenticated) path skips attribution lookups entirely: each +// foreign DID would otherwise cost an uncached serial plc.directory GET, and +// a crafted repo can hold up to a page's worth of them per request. +fn via_handles_for( + ctx: Context, + public: Bool, + owner_did: String, + entries: List(crate.CrateEntry), +) -> Dict(String, String) { + case public { + True -> dict.new() + False -> via_handles(ctx, owner_did, entries) + } +} + +// Attributes entries whose release was adopted from a repo other than the +// crate owner's; deduped per unique foreign DID. fn via_handles( ctx: Context, - session: OauthSession, + owner_did: String, entries: List(crate.CrateEntry), ) -> Dict(String, String) { let foreign_dids = @@ -101,7 +165,7 @@ fn via_handles( { use ref <- option.then(e.release) use #(did, _, _) <- option.then(promotion.parse_at_uri(ref.uri)) - case did == session.did { + case did == owner_did { True -> None False -> Some(#(e.entry_id, did)) } @@ -126,11 +190,8 @@ fn via_handles( } // A stored cover blob is resolved to the same-origin cover proxy URL at read -// time, rather than the PDS's public getBlob URL directly. -fn resolve_cover( - session: OauthSession, - e: crate.CrateEntry, -) -> crate.CrateEntry { +// time, rather than the owning repo's PDS getBlob URL directly. +fn resolve_cover(did: String, e: crate.CrateEntry) -> crate.CrateEntry { case e.snapshot { Some(s) -> case s.cover { @@ -138,10 +199,7 @@ fn resolve_cover( crate.CrateEntry( ..e, snapshot: Some( - Snapshot( - ..s, - thumb_url: Some(cover_proxy.url(session.did, b.cid)), - ), + Snapshot(..s, thumb_url: Some(cover_proxy.url(did, b.cid))), ), ) None -> e @@ -153,37 +211,72 @@ fn resolve_cover( fn in_view(view: String, e: crate.CrateEntry) -> Bool { case view { "all" | "history" -> True + // The public-crate view: the whole current shelf, gone entries excluded. + "current" -> e.status != crate.Gone "wanted" -> e.status == crate.Wanted _ -> e.status == crate.Owned } } -pub fn shelf_timeline(req: Request, ctx: Context) -> Response { - use id, session <- require_session(req, ctx) - case list.key_find(wisp.get_query(req), "entry") { +/// One entry's superset: the folded current state, its full event timeline, +/// and its resolved release. The session's own entry by default, or any actor's +/// public entry when `actor` is present (no auth required then). +pub fn get_entry(req: Request, ctx: Context) -> Response { + let query = wisp.get_query(req) + case list.key_find(query, "entry") { Error(Nil) -> error_json(400, "missing entry") - Ok(entry_id) -> do_timeline(ctx, id, session, entry_id) + Ok(entry_id) -> + case list.key_find(query, "actor") { + Ok(actor) -> actor_entry(ctx, actor, entry_id) + Error(Nil) -> own_entry(req, ctx, entry_id) + } } } -fn do_timeline( - ctx: Context, - id: String, - session: OauthSession, - entry_id: String, -) -> Response { +fn own_entry(req: Request, ctx: Context, entry_id: String) -> Response { + use id, session <- require_session(req, ctx) use client, session <- with_pds_client(ctx, id, session) case event_log.load(client, session) { Error(_) -> error_json(502, "could not load crate from PDS") Ok(stored) -> - case event_log.entry_events(stored, entry_id) { + entry_response(ctx, session.did, session.handle, stored, entry_id) + } +} + +fn actor_entry(ctx: Context, actor: String, entry_id: String) -> Response { + case shelf_owner.resolve_actor(ctx, actor) { + Error(Nil) -> error_json(404, "could not resolve that user") + Ok(#(did, handle, pds)) -> + case shelf_owner.fetch_public_entries(ctx, pds, did) { + Error(shelf_owner.FetchFailed) -> + error_json(502, "could not load that user's crate from their PDS") + Error(shelf_owner.TooManyPages) -> + error_json(413, "that user's crate is too large to page through") + Ok(stored) -> entry_response(ctx, did, handle, stored, entry_id) + } + } +} + +fn entry_response( + ctx: Context, + did: String, + handle: String, + stored: List(StoredItem(ShelfEntry)), + entry_id: String, +) -> Response { + case event_log.entry_events(stored, entry_id) { + Error(Nil) -> error_json(404, "unknown entry") + Ok(events) -> + case crate.fold(events) |> list.first { Error(Nil) -> error_json(404, "unknown entry") - Ok(events) -> { + Ok(folded) -> { let release = resolve_release(ctx, events) json.object( list.flatten([ [ - #("entryId", json.string(entry_id)), + #("did", json.string(did)), + #("handle", json.string(handle)), + #("entry", crate.encode_entry(resolve_cover(did, folded))), #( "events", json.array( diff --git a/server/src/at_record_server/oauth/client_metadata.gleam b/server/src/at_record_server/oauth/client_metadata.gleam index 21c1eef..430c9cf 100644 --- a/server/src/at_record_server/oauth/client_metadata.gleam +++ b/server/src/at_record_server/oauth/client_metadata.gleam @@ -2,6 +2,11 @@ //// a confidential web client: authorization-code + refresh, private_key_jwt //// auth, DPoP-bound tokens, keys advertised via jwks_uri. +import at_record/gen/catalog/artist as catalog_artist +import at_record/gen/catalog/edit as catalog_edit +import at_record/gen/catalog/genre as catalog_genre +import at_record/gen/catalog/release as catalog_release +import at_record/gen/shelf/entry as shelf_entry import gleam/json import gleam/string @@ -16,11 +21,16 @@ pub const callback_path = "/api/oauth/callback" /// collection the app writes. `blob:` is needed for uploadBlob (cover art). pub const scopes = [ "atproto", - "repo:dev.mokkenstorm.crate.shelf.entry", - "repo:dev.mokkenstorm.crate.catalog.release", - "repo:dev.mokkenstorm.crate.catalog.artist", - "repo:dev.mokkenstorm.crate.catalog.genre", - "repo:dev.mokkenstorm.crate.catalog.edit", + "repo:" + <> shelf_entry.collection, + "repo:" + <> catalog_release.collection, + "repo:" + <> catalog_artist.collection, + "repo:" + <> catalog_genre.collection, + "repo:" + <> catalog_edit.collection, "blob:image/*", ] diff --git a/server/src/at_record_server/promotion.gleam b/server/src/at_record_server/promotion.gleam index fa83822..e80eab8 100644 --- a/server/src/at_record_server/promotion.gleam +++ b/server/src/at_record_server/promotion.gleam @@ -27,7 +27,7 @@ import gleam/option.{type Option, None, Some} import gleam/string import wisp -const backlink_source = "dev.mokkenstorm.crate.catalog.release:externalIds[].url" +const backlink_source = catalog_release.collection <> ":externalIds[].url" pub type Promoted { Promoted(release: defs.CatalogRef, origin: String) diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam index 456b93d..8507da1 100644 --- a/server/src/at_record_server/router.gleam +++ b/server/src/at_record_server/router.gleam @@ -11,7 +11,6 @@ import at_record_server/handlers/edit_inbox import at_record_server/handlers/feed import at_record_server/handlers/graph import at_record_server/handlers/oauth -import at_record_server/handlers/public_shelf import at_record_server/handlers/shelf import at_record_server/oauth/client_metadata import at_record_server/oauth/keys @@ -46,63 +45,55 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { } } -// A flat case over full NSID strings: greppable and mirrors the lexicon method ids. +// One prefix match keeps the namespace in a single place for the eventual +// production-NSID swap; the suffix arms stay greppable and mirror the lexicon +// method ids. fn dispatch_xrpc( req: Request, ctx: Context, nsid: String, method: http.Method, ) -> Response { - case nsid, method { - "dev.mokkenstorm.crate.server.getSession", Get -> oauth.whoami(req, ctx) - "dev.mokkenstorm.crate.server.deleteSession", Post -> oauth.logout(req, ctx) - "dev.mokkenstorm.crate.catalog.listReleases", Get -> browse.browse(req, ctx) - "dev.mokkenstorm.crate.catalog.getRelease", Get -> - browse.get_release(req, ctx) - "dev.mokkenstorm.crate.catalog.adoptRelease", Post -> browse.add(req, ctx) - "dev.mokkenstorm.crate.catalog.listEditProposals", Get -> - edit_inbox.list_proposals(req, ctx) - "dev.mokkenstorm.crate.catalog.applyEditProposal", Post -> - edit_inbox.apply_proposal(req, ctx) - "dev.mokkenstorm.crate.shelf.listEntries", Get -> shelf.list_shelf(req, ctx) - "dev.mokkenstorm.crate.shelf.getEntry", Get -> - shelf.shelf_timeline(req, ctx) - "dev.mokkenstorm.crate.shelf.addEntry", Post -> - shelf.add_shelf_item(req, ctx) - "dev.mokkenstorm.crate.shelf.appendEvent", Post -> - shelf.append_event(req, ctx) - "dev.mokkenstorm.crate.shelf.purgeEntry", Post -> - shelf.purge_entry(req, ctx) - "dev.mokkenstorm.crate.shelf.amendEntry", Post -> - amend.amend_entry(req, ctx) - "dev.mokkenstorm.crate.shelf.uploadCover", Post -> - amend.upload_cover(req, ctx) - "dev.mokkenstorm.crate.shelf.getPublicShelf", Get -> - 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.graph.followUser", Post -> graph.follow(req, ctx) - "dev.mokkenstorm.crate.graph.unfollowUser", Post -> graph.unfollow(req, ctx) - "dev.mokkenstorm.crate.feed.getFeedSkeleton", Get -> - feed.get_feed_skeleton(req, ctx) - "dev.mokkenstorm.crate.discogs.searchReleases", Get -> - discogs.search(req, ctx) - "dev.mokkenstorm.crate.discogs.searchArtists", Get -> - discogs.search_artists(req, ctx) - "dev.mokkenstorm.crate.discogs.resolveBarcode", Get -> - discogs.scan(req, ctx) - "dev.mokkenstorm.crate.discogs.getScanSeen", Get -> - discogs.scan_seen(req, ctx) - "dev.mokkenstorm.crate.discogs.getStatus", Get -> discogs.status(req, ctx) - "dev.mokkenstorm.crate.discogs.disconnectAccount", Post -> - discogs.disconnect(req, ctx) - "dev.mokkenstorm.crate.discogs.importCollection", Post -> - discogs.import_collection(req, ctx) - "dev.mokkenstorm.crate.discogs.importWantlist", Post -> - discogs.import_wantlist(req, ctx) - _, _ -> method_not_implemented(nsid) + case nsid { + "dev.mokkenstorm.crate." <> rest -> crate_xrpc(req, ctx, rest, method) + _ -> method_not_implemented(nsid) + } +} + +fn crate_xrpc( + req: Request, + ctx: Context, + rest: String, + method: http.Method, +) -> Response { + case rest, method { + "server.getSession", Get -> oauth.whoami(req, ctx) + "server.deleteSession", Post -> oauth.logout(req, ctx) + "catalog.listReleases", Get -> browse.browse(req, ctx) + "catalog.getRelease", Get -> browse.get_release(req, ctx) + "catalog.adoptRelease", Post -> browse.add(req, ctx) + "catalog.listEditProposals", Get -> edit_inbox.list_proposals(req, ctx) + "catalog.applyEditProposal", Post -> edit_inbox.apply_proposal(req, ctx) + "shelf.listEntries", Get -> shelf.list_shelf(req, ctx) + "shelf.getEntry", Get -> shelf.get_entry(req, ctx) + "shelf.addEntry", Post -> shelf.add_shelf_item(req, ctx) + "shelf.appendEvent", Post -> shelf.append_event(req, ctx) + "shelf.purgeEntry", Post -> shelf.purge_entry(req, ctx) + "shelf.amendEntry", Post -> amend.amend_entry(req, ctx) + "shelf.uploadCover", Post -> amend.upload_cover(req, ctx) + "shelf.getCrateOverlap", Get -> crate_overlap.get_crate_overlap(req, ctx) + "graph.followUser", Post -> graph.follow(req, ctx) + "graph.unfollowUser", Post -> graph.unfollow(req, ctx) + "feed.getFeedSkeleton", Get -> feed.get_feed_skeleton(req, ctx) + "discogs.searchReleases", Get -> discogs.search(req, ctx) + "discogs.searchArtists", Get -> discogs.search_artists(req, ctx) + "discogs.resolveBarcode", Get -> discogs.scan(req, ctx) + "discogs.getScanSeen", Get -> discogs.scan_seen(req, ctx) + "discogs.getStatus", Get -> discogs.status(req, ctx) + "discogs.disconnectAccount", Post -> discogs.disconnect(req, ctx) + "discogs.importCollection", Post -> discogs.import_collection(req, ctx) + "discogs.importWantlist", Post -> discogs.import_wantlist(req, ctx) + _, _ -> method_not_implemented("dev.mokkenstorm.crate." <> rest) } } diff --git a/server/src/at_record_server/shelf_owner.gleam b/server/src/at_record_server/shelf_owner.gleam new file mode 100644 index 0000000..0a8347b --- /dev/null +++ b/server/src/at_record_server/shelf_owner.gleam @@ -0,0 +1,208 @@ +//// Reading someone else's crate: shelf records are public data in the +//// owner's PDS, so resolve the actor, fetch with no session, and run the +//// same `crate`/`event_log` fold as an authed load, except capped at +//// `max_pages` pages (1000 records total): the authed load pages to +//// exhaustion, this one refuses to keep fanning out into a stranger's PDS +//// forever. + +import at_record/gen/client as generated_client +import at_record/gen/repo/list_records.{type RecordEntry} +import at_record/gen/shelf/entry +import at_record/storage.{type StoredItem} +import at_record_server/context.{type Context} +import gleam/dynamic/decode +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gleam/uri +import wisp + +/// Handle or DID -> did/handle/pds via the Slingshot resolver. Every +/// failure collapses to `Error(Nil)`: an unresolvable actor and a +/// nonexistent one look the same to the caller, so the response can't be +/// used as an account-existence oracle; the two are logged distinctly so +/// ops can still tell a down resolver from a plain typo. +pub fn resolve_actor( + ctx: Context, + actor: String, +) -> Result(#(String, String, String), Nil) { + case + generated_client.identity_resolve_mini_doc( + ctx.atproto.client, + ctx.atproto.resolver, + generated_client.IdentityResolveMiniDocParams(identifier: actor), + None, + ) + { + Error(generated_client.IdentityResolveMiniDocTransport(err)) -> { + wisp.log_warning( + "shelf_owner: resolver unreachable for " + <> actor + <> ": " + <> string.inspect(err), + ) + Error(Nil) + } + Error(generated_client.IdentityResolveMiniDocUnexpected(status, _, _)) -> { + wisp.log_warning( + "shelf_owner: no such actor " + <> actor + <> " (resolver status " + <> int.to_string(status) + <> ")", + ) + Error(Nil) + } + Ok(doc) -> + case is_safe_pds_url(doc.pds) { + True -> Ok(#(doc.did, doc.handle, doc.pds)) + False -> Error(Nil) + } + } +} + +/// `doc.pds` comes straight off an attacker-controllable DID document, so a +/// rejected endpoint (non-https, loopback, link-local, RFC1918) behaves like +/// an unresolvable actor to the caller rather than being fetched. +pub fn is_safe_pds_url(url: String) -> Bool { + case uri.parse(url) { + Error(_) -> False + Ok(parsed) -> + case parsed.scheme, parsed.host { + Some("https"), Some(host) -> is_safe_host(host) + _, _ -> False + } + } +} + +// URL-level check only: a hostname that looks fine here could still resolve +// to a private address at request time (DNS rebinding), which this can't see. +fn is_safe_host(host: String) -> Bool { + case string.lowercase(host) { + "localhost" -> False + lower -> + case ipv4_octets(lower) { + Some(octets) -> !is_private_ipv4(octets) + None -> True + } + } +} + +fn ipv4_octets(host: String) -> Option(List(Int)) { + case string.split(host, ".") { + [a, b, c, d] -> + case int.parse(a), int.parse(b), int.parse(c), int.parse(d) { + Ok(oa), Ok(ob), Ok(oc), Ok(od) -> Some([oa, ob, oc, od]) + _, _, _, _ -> None + } + _ -> None + } +} + +fn is_private_ipv4(octets: List(Int)) -> Bool { + case octets { + [10, _, _, _] -> True + [127, _, _, _] -> True + [169, 254, _, _] -> True + [172, b, _, _] if b >= 16 && b <= 31 -> True + [192, 168, _, _] -> True + _ -> False + } +} + +/// Hitting the page cap is a distinct outcome from a transport/decode +/// failure, so the handler can answer honestly instead of handing back a +/// silently short list. +pub type FetchEntriesError { + FetchFailed + TooManyPages +} + +/// The `com.atproto.repo.listRecords` page size. +const page_size = 100 + +/// Hard cap on pages paged per actor request: `max_pages * page_size` (1000) +/// records. Beyond this a stranger's crate answers `TooManyPages` instead of +/// truncating silently. +const max_pages = 10 + +/// Public `listRecords` on the actor's PDS, no token, paged to exhaustion +/// (or to `max_pages`) like the authed fold, decoded to the `StoredItem`s +/// the shared fold expects. +pub fn fetch_public_entries( + ctx: Context, + pds: String, + did: String, +) -> Result(List(StoredItem(entry.ShelfEntry)), FetchEntriesError) { + fetch_pages(ctx, pds, did, None, [], 0) +} + +fn fetch_pages( + ctx: Context, + pds: String, + did: String, + cursor: Option(String), + acc: List(StoredItem(entry.ShelfEntry)), + page: Int, +) -> Result(List(StoredItem(entry.ShelfEntry)), FetchEntriesError) { + case page >= max_pages { + True -> Error(TooManyPages) + False -> { + let params = + generated_client.RepoListRecordsParams( + collection: entry.collection, + cursor:, + limit: Some(page_size), + repo: did, + reverse: None, + ) + case + generated_client.repo_list_records( + ctx.atproto.client, + pds, + params, + None, + ) + { + Error(_) -> Error(FetchFailed) + Ok(output) -> { + let rows = decode_rows(did, output.records) + let all = list.append(acc, rows) + case output.cursor { + Some("") | None -> Ok(all) + Some(next) -> fetch_pages(ctx, pds, did, Some(next), all, page + 1) + } + } + } + } + } +} + +// One malformed record in a stranger's repo must not 502 their whole crate, +// so undecodable rows are skipped rather than failing the page; the count +// is logged so a spike in skips (e.g. a lexicon drift) is still visible. +fn decode_rows( + did: String, + records: List(RecordEntry), +) -> List(StoredItem(entry.ShelfEntry)) { + let decoded = + records + |> list.filter_map(fn(row) { + decode.run(row.value, entry.shelf_entry_decoder()) + |> result.map(fn(value) { storage.stored_row(row.uri, row.cid, value) }) + }) + let skipped = list.length(records) - list.length(decoded) + case skipped > 0 { + True -> + wisp.log_warning( + "shelf_owner: skipped " + <> int.to_string(skipped) + <> " undecodable record(s) for " + <> did, + ) + False -> Nil + } + decoded +} diff --git a/server/test/public_shelf_test.gleam b/server/test/actor_shelf_test.gleam similarity index 58% rename from server/test/public_shelf_test.gleam rename to server/test/actor_shelf_test.gleam index a3b06ff..6f014d5 100644 --- a/server/test/public_shelf_test.gleam +++ b/server/test/actor_shelf_test.gleam @@ -1,12 +1,17 @@ -//// Handler tests for unauthenticated public shelf/entry reads, faking the -//// resolver and the actor's own PDS via a host-branching `xrpc.Client`. +//// Handler tests for the unified shelf endpoints in their unauthenticated +//// actor mode (`?actor=`), faking the resolver and the actor's own PDS via a +//// host-branching `xrpc.Client`. The public-crate view is `view=current`. +//// Also pins the boundary of that mode: with no session and no `actor`, +//// both endpoints mean "my own crate" and demand auth. import at_record/gen/catalog/release as catalog_release import at_record_server/catalog_deps.{type Deps, Deps} import at_record_server/context.{type Context} -import at_record_server/handlers/public_shelf +import at_record_server/handlers/shelf import atproto/xrpc import gleam/bit_array +import gleam/dict +import gleam/dynamic/decode import gleam/http import gleam/http/response import gleam/json @@ -69,7 +74,7 @@ fn wanted_genesis(rkey: String) -> json.Json { } /// A genesis plus a later `sold` event referencing it, so folding leaves it -/// `Gone`: `getPublicShelf` must exclude it from the response. +/// `Gone`: the `current` view must exclude it from the response. fn sold_pair( genesis_rkey: String, event_rkey: String, @@ -161,7 +166,7 @@ fn test_context(client: xrpc.Client, catalog: Deps) -> Context { ) } -pub fn get_public_shelf_folds_and_excludes_gone_entries_test() { +pub fn actor_shelf_folds_and_excludes_gone_entries_test() { let #(sold_genesis, sold_event) = sold_pair("3ccc", "3ccd") let records = list_records_body([ @@ -173,10 +178,10 @@ pub fn get_public_shelf_folds_and_excludes_gone_entries_test() { let ctx = test_context(network_client(records), support.unreachable_catalog_deps()) let resp = - public_shelf.get_public_shelf( + shelf.list_shelf( simulate.request( http.Get, - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=pub.test", + support.xrpc("shelf.listEntries?actor=pub.test&view=current"), ), ctx, ) @@ -191,14 +196,58 @@ pub fn get_public_shelf_folds_and_excludes_gone_entries_test() { assert titles == ["Spiderland", "Loveless"] } -pub fn get_public_entry_returns_folded_entry_and_release_test() { +// A foreign-DID release would normally send `via_handles` off to +// plc.directory; `network_client` panics on any host besides the resolver +// and the actor's own PDS, so this would fail loudly if the actor path +// still fanned out for attribution on someone else's crate. +pub fn actor_shelf_skips_via_handles_lookup_for_foreign_release_test() { + let foreign_release = + record("3fff", [ + #("action", json.string("acquired")), + #("createdAt", json.string("2026-01-05T00:00:00Z")), + snapshot("Loveless", "My Bloody Valentine"), + #( + "release", + json.object([ + #("cid", json.string("bafyforeign")), + #( + "uri", + json.string( + "at://did:plc:other/dev.mokkenstorm.crate.catalog.release/r2", + ), + ), + ]), + ), + ]) + let records = list_records_body([foreign_release]) + let ctx = + test_context(network_client(records), support.unreachable_catalog_deps()) + let resp = + shelf.list_shelf( + simulate.request( + http.Get, + support.xrpc("shelf.listEntries?actor=pub.test&view=current"), + ), + ctx, + ) + assert resp.status == 200 + let body = simulate.read_body(resp) + let assert Ok(via_handles) = + json.parse( + body, + decode.at(["viaHandles"], decode.dict(decode.string, decode.string)), + ) + assert dict.size(via_handles) == 0 +} + +pub fn actor_entry_returns_folded_entry_and_release_test() { let records = list_records_body([owned_genesis("3aaa")]) let ctx = test_context(network_client(records), catalog_with_release()) let resp = - public_shelf.get_public_entry( + shelf.get_entry( simulate.request( http.Get, - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test&entryId=3aaa", + support.xrpc("shelf.getEntry?actor=pub.test&entry=3aaa"), ), ctx, ) @@ -216,33 +265,27 @@ pub fn error_status_test() { let known_entry = list_records_body([owned_genesis("3aaa")]) [ #( - public_shelf.get_public_shelf, + shelf.list_shelf, failing_resolver_client(), - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf", - 400, - ), - #( - public_shelf.get_public_shelf, - failing_resolver_client(), - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=nobody.test", + support.xrpc("shelf.listEntries?actor=nobody.test"), 404, ), #( - public_shelf.get_public_shelf, + shelf.list_shelf, pds_unreachable_client(), - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=pub.test", + support.xrpc("shelf.listEntries?actor=pub.test"), 502, ), #( - public_shelf.get_public_entry, + shelf.get_entry, network_client(known_entry), - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test&entryId=missing", + support.xrpc("shelf.getEntry?actor=pub.test&entry=missing"), 404, ), #( - public_shelf.get_public_entry, + shelf.get_entry, failing_resolver_client(), - "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test", + support.xrpc("shelf.getEntry?actor=pub.test"), 400, ), ] @@ -256,3 +299,61 @@ pub fn error_status_test() { assert resp.status == want }) } + +// With no session and no `actor`, both endpoints fall back to "my own +// crate", so the auth boundary (not a 400) is what a caller hits. +pub fn list_entries_with_no_session_and_no_actor_is_unauthorized_test() { + let ctx = + test_context(failing_resolver_client(), support.unreachable_catalog_deps()) + let resp = + shelf.list_shelf( + simulate.request(http.Get, support.xrpc("shelf.listEntries")), + ctx, + ) + assert resp.status == 401 +} + +pub fn get_entry_with_no_session_and_no_actor_is_unauthorized_test() { + let ctx = + test_context(failing_resolver_client(), support.unreachable_catalog_deps()) + let resp = + shelf.get_entry( + simulate.request(http.Get, support.xrpc("shelf.getEntry?entry=3aaa")), + ctx, + ) + assert resp.status == 401 +} + +pub fn actor_view_history_is_clamped_to_current_test() { + let #(sold_genesis, sold_event) = sold_pair("3eee", "3eef") + let records = list_records_body([sold_genesis, sold_event]) + let ctx = + test_context(network_client(records), support.unreachable_catalog_deps()) + let current_resp = + shelf.list_shelf( + simulate.request( + http.Get, + support.xrpc("shelf.listEntries?actor=pub.test&view=current"), + ), + ctx, + ) + let assert Ok(current_ids) = + support.field_nested(simulate.read_body(current_resp), ["items"], [ + "entryId", + ]) + assert current_ids == [] + + let history_resp = + shelf.list_shelf( + simulate.request( + http.Get, + support.xrpc("shelf.listEntries?actor=pub.test&view=history"), + ), + ctx, + ) + let assert Ok(history_ids) = + support.field_nested(simulate.read_body(history_resp), ["items"], [ + "entryId", + ]) + assert history_ids == [] +} diff --git a/server/test/browse_handler_test.gleam b/server/test/browse_handler_test.gleam index 51d14dc..fe83483 100644 --- a/server/test/browse_handler_test.gleam +++ b/server/test/browse_handler_test.gleam @@ -88,10 +88,7 @@ fn test_context() -> #(Context, config.Config) { fn browse_body(query: String, ctx: Context, cfg: config.Config) -> String { let assert Ok(id) = session_store.create(cfg.sessions, a_session()) let req = - simulate.request( - http.Get, - "/xrpc/dev.mokkenstorm.crate.catalog.listReleases" <> query, - ) + simulate.request(http.Get, support.xrpc("catalog.listReleases") <> query) |> simulate.cookie(session_cookie, id, wisp.Signed) let resp = browse(req, ctx) assert resp.status == 200 diff --git a/server/test/crate_overlap_test.gleam b/server/test/crate_overlap_test.gleam index 11d1a64..85896e8 100644 --- a/server/test/crate_overlap_test.gleam +++ b/server/test/crate_overlap_test.gleam @@ -294,7 +294,7 @@ pub fn get_crate_overlap_requires_a_session_test() { crate_overlap.get_crate_overlap( simulate.request( http.Get, - "/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap?actor=pub.test", + support.xrpc("shelf.getCrateOverlap?actor=pub.test"), ), ctx, ) @@ -304,8 +304,7 @@ pub fn get_crate_overlap_requires_a_session_test() { 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 req = authed_request(support.xrpc("shelf.getCrateOverlap"), cfg) let resp = crate_overlap.get_crate_overlap(req, ctx) assert resp.status == 400 } @@ -323,10 +322,7 @@ pub fn get_crate_overlap_combines_both_crates_test() { ]) 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, - ) + authed_request(support.xrpc("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) @@ -342,10 +338,7 @@ pub fn get_crate_overlap_sets_viewer_follows_when_a_follow_record_exists_test() ) let #(ctx, cfg) = test_context(client) let req = - authed_request( - "/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap?actor=pub.test", - cfg, - ) + authed_request(support.xrpc("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) @@ -363,10 +356,7 @@ pub fn get_crate_overlap_omits_follow_uri_when_not_following_test() { ) let #(ctx, cfg) = test_context(client) let req = - authed_request( - "/xrpc/dev.mokkenstorm.crate.shelf.getCrateOverlap?actor=pub.test", - cfg, - ) + authed_request(support.xrpc("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) diff --git a/server/test/discogs_scan_test.gleam b/server/test/discogs_scan_test.gleam index c74c623..907f5f5 100644 --- a/server/test/discogs_scan_test.gleam +++ b/server/test/discogs_scan_test.gleam @@ -100,7 +100,7 @@ fn authenticated_scan( let req = simulate.request( http.Get, - "/xrpc/dev.mokkenstorm.crate.discogs.resolveBarcode?barcode=" <> barcode, + support.xrpc("discogs.resolveBarcode?barcode=") <> barcode, ) |> simulate.cookie(session_cookie, id, wisp.Signed) let resp = discogs_handler.scan(req, ctx) diff --git a/server/test/edit_inbox_list_test.gleam b/server/test/edit_inbox_list_test.gleam index d78d7ba..982a563 100644 --- a/server/test/edit_inbox_list_test.gleam +++ b/server/test/edit_inbox_list_test.gleam @@ -165,7 +165,7 @@ fn list_proposals(query: String, ctx: Context, cfg: config.Config) -> String { let req = simulate.request( http.Get, - "/xrpc/dev.mokkenstorm.crate.catalog.listEditProposals" <> query, + support.xrpc("catalog.listEditProposals") <> query, ) |> simulate.cookie(session_cookie, id, wisp.Signed) let resp = edit_inbox_handler.list_proposals(req, ctx) diff --git a/server/test/feed_handler_test.gleam b/server/test/feed_handler_test.gleam index 4008128..2cc031d 100644 --- a/server/test/feed_handler_test.gleam +++ b/server/test/feed_handler_test.gleam @@ -31,7 +31,9 @@ const session_cookie = "ar_oauth_sid" const far_future = 9_999_999_999 -const feed_path = "/xrpc/dev.mokkenstorm.crate.feed.getFeedSkeleton" +fn feed_path() -> String { + support.xrpc("feed.getFeedSkeleton") +} fn a_session() -> sessions.OauthSession { support.stub_session_with( @@ -172,7 +174,7 @@ pub fn followed_rows_are_included_and_viewer_and_non_followed_are_excluded_test( list_records_body([follow_record_json("3aaa", "did:plc:f1")]), ) let #(ctx, cfg) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path, cfg), ctx) + let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_bool(body, ["fallback"]) == Ok(False) @@ -195,7 +197,7 @@ pub fn empty_follow_graph_falls_back_to_network_wide_including_the_viewer_test() )) let client = network_client(list_records_body([])) let #(ctx, cfg) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path, cfg), ctx) + let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_bool(body, ["fallback"]) == Ok(True) @@ -218,7 +220,7 @@ pub fn nonempty_follows_with_no_followed_adoptions_falls_back_on_the_first_page_ list_records_body([follow_record_json("3bbb", "did:plc:f1")]), ) let #(ctx, cfg) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path, cfg), ctx) + let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_bool(body, ["fallback"]) == Ok(True) @@ -255,14 +257,14 @@ pub fn cursor_stays_in_the_followed_namespace_across_pages_test() { ) let #(ctx, cfg) = test_context(client, store) let first = - feed.get_feed_skeleton(authed_get(feed_path <> "?limit=1", cfg), ctx) + feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) let first_body = simulate.read_body(first) assert support.field_bool(first_body, ["fallback"]) == Ok(False) assert item_actors(first_body) == ["did:plc:f1"] let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) let second = feed.get_feed_skeleton( - authed_get(feed_path <> "?limit=1&cursor=" <> cursor, cfg), + authed_get(feed_path() <> "?limit=1&cursor=" <> cursor, cfg), ctx, ) let second_body = simulate.read_body(second) @@ -287,7 +289,7 @@ pub fn limit_is_capped_at_the_lexicon_default_test() { list_records_body([follow_record_json("3aaa", "did:plc:f1")]), ) let #(ctx, cfg) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path, cfg), ctx) + let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) let body = simulate.read_body(resp) assert item_count(body) == 100 assert support.field_present(body, ["cursor"]) diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index d9ea6d8..26c8075 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -135,7 +135,7 @@ pub fn follow_creates_a_record_and_returns_its_uri_test() { )) let req = authed_post( - "/xrpc/dev.mokkenstorm.crate.graph.followUser", + support.xrpc("graph.followUser"), json.object([#("subject", json.string(target_did))]), cfg, ) @@ -154,7 +154,7 @@ pub fn unfollow_deletes_the_matching_follow_and_returns_ok_test() { )) let req = authed_post( - "/xrpc/dev.mokkenstorm.crate.graph.unfollowUser", + support.xrpc("graph.unfollowUser"), json.object([#("subject", json.string(target_did))]), cfg, ) @@ -166,7 +166,7 @@ pub fn unfollow_returns_404_when_no_follow_exists_test() { let #(ctx, cfg) = test_context(network_client(list_records_body([]), "")) let req = authed_post( - "/xrpc/dev.mokkenstorm.crate.graph.unfollowUser", + support.xrpc("graph.unfollowUser"), json.object([#("subject", json.string(target_did))]), cfg, ) diff --git a/server/test/shelf_list_test.gleam b/server/test/shelf_list_test.gleam index 4c005f0..4c8f0fc 100644 --- a/server/test/shelf_list_test.gleam +++ b/server/test/shelf_list_test.gleam @@ -100,10 +100,7 @@ fn client_with_entries(owned_count: Int, total: Int) -> xrpc.Client { fn list_shelf(query: String, ctx: Context, cfg: config.Config) -> String { let assert Ok(id) = session_store.create(cfg.sessions, a_session()) let req = - simulate.request( - http.Get, - "/xrpc/dev.mokkenstorm.crate.shelf.listEntries" <> query, - ) + simulate.request(http.Get, support.xrpc("shelf.listEntries") <> query) |> simulate.cookie(session_cookie, id, wisp.Signed) let resp = shelf_handler.list_shelf(req, ctx) assert resp.status == 200 diff --git a/server/test/shelf_owner_test.gleam b/server/test/shelf_owner_test.gleam new file mode 100644 index 0000000..2df3d00 --- /dev/null +++ b/server/test/shelf_owner_test.gleam @@ -0,0 +1,40 @@ +//// `resolve_actor` hands `doc.pds` straight from an attacker-controllable DID +//// document to an outbound fetch, so `is_safe_pds_url` is the SSRF gate: pin +//// it directly rather than only through the handler. + +import at_record_server/shelf_owner +import gleam/list + +pub fn https_is_required_test() { + assert !shelf_owner.is_safe_pds_url("http://pds.example") +} + +pub fn loopback_is_rejected_test() { + ["https://127.0.0.1", "https://localhost", "https://127.1.2.3:8080"] + |> list.each(fn(url) { + assert !shelf_owner.is_safe_pds_url(url) + }) +} + +pub fn link_local_is_rejected_test() { + assert !shelf_owner.is_safe_pds_url("https://169.254.169.254") +} + +pub fn rfc1918_ranges_are_rejected_test() { + ["https://10.0.0.5", "https://172.16.0.1", "https://192.168.1.1"] + |> list.each(fn(url) { + assert !shelf_owner.is_safe_pds_url(url) + }) +} + +pub fn adjacent_ranges_are_not_confused_with_rfc1918_test() { + // 172.15.x and 172.32.x are outside the 172.16.0.0/12 private block. + ["https://172.15.0.1", "https://172.32.0.1"] + |> list.each(fn(url) { + assert shelf_owner.is_safe_pds_url(url) + }) +} + +pub fn ordinary_https_host_is_accepted_test() { + assert shelf_owner.is_safe_pds_url("https://pds.example.com") +} diff --git a/server/test/support.gleam b/server/test/support.gleam index f02ec6c..7cb2d3c 100644 --- a/server/test/support.gleam +++ b/server/test/support.gleam @@ -26,6 +26,12 @@ import gleam/result import gose import kryptos/ec +/// The crate XRPC path for `rest` (method name plus optional query string), +/// so the namespace lives in one place on the test side too. +pub fn xrpc(rest: String) -> String { + "/xrpc/dev.mokkenstorm.crate." <> rest +} + pub fn unreachable_client() -> xrpc.Client { xrpc.Client(send: fn(_req) { Error("unused") }) } diff --git a/shared/src/at_record/storage.gleam b/shared/src/at_record/storage.gleam index 4f515da..de72a1c 100644 --- a/shared/src/at_record/storage.gleam +++ b/shared/src/at_record/storage.gleam @@ -2,6 +2,7 @@ //// (`uri`/`cid`/`rkey`) plus the decoded record `value`. Not a lexicon concept, //// so it's hand-written and generic over the record. +import atproto/uri import gleam/dynamic/decode import gleam/json @@ -9,6 +10,28 @@ pub type StoredItem(record) { StoredItem(uri: String, cid: String, rkey: String, value: record) } +/// Build a `StoredItem` for a `listRecords` row, deriving `rkey` from the +/// record's at-uri. The single home for that derivation, shared by the row +/// decoder below and by callers that already hold a decoded value. +pub fn stored_row( + at_uri: String, + cid: String, + value: record, +) -> StoredItem(record) { + StoredItem(uri: at_uri, cid:, rkey: uri.rkey(at_uri), value:) +} + +/// Decode one `com.atproto.repo.listRecords` row (`uri`/`cid`/`value`) into a +/// `StoredItem`. `value_decoder` decodes the row's record body. +pub fn list_records_row_decoder( + value_decoder: decode.Decoder(record), +) -> decode.Decoder(StoredItem(record)) { + use at_uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use value <- decode.field("value", value_decoder) + decode.success(stored_row(at_uri, cid, value)) +} + pub fn encode_stored_item( stored: StoredItem(record), encode_value: fn(record) -> json.Json, diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 6edffdf..9db1cef 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -1,7 +1,7 @@ import at_record_web/browser -import at_record_web/effects.{load_shelf, load_timeline} +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/msg.{type Msg, GotEntry, OnRouteChange} import at_record_web/prefs import at_record_web/route import at_record_web/update.{update} @@ -38,7 +38,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { view: "owned", display: initial_display(), theme: initial_theme(), - shelf: model.ShelfLoading, + crates: dict.new(), crate_window: model.crate_window_size, crate_cursor: None, shelf_loading_more: False, @@ -50,8 +50,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { selected: None, editing: False, confirm_remove: False, - timeline: [], - release_info: None, + entry_detail: model.EntryDetailLoading, discogs: model.blank_discogs(), scan: model.blank_scan(), publishing: False, @@ -65,8 +64,6 @@ fn init(_flags) -> #(Model, Effect(Msg)) { inbox: model.InboxLoading, confirm_logout: False, ignored_proposals: initial_ignored_proposals(), - public_shelf: model.PublicShelfLoading, - public_entry: model.PublicEntryLoading, overlap: None, pressing: model.PressingLoading, feed: model.FeedLoading, @@ -84,29 +81,28 @@ fn init(_flags) -> #(Model, Effect(Msg)) { 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.PublicCrate(handle) -> [effects.load_shelf(Some(handle), "current")] model.PublicRecord(handle, entry_id) -> [ - effects.load_public_entry(handle, entry_id), + effects.load_entry(Some(handle), entry_id, GotEntry), ] // Nothing's in `model.browse` yet at boot, so a direct load always fetches. model.PressingDetail(did, rkey) -> [ effects.load_pressing(model.release_uri(did, rkey)), ] - // The shelf itself always loads below regardless of route; only the - // timeline needs its own fetch here, same as Record would if landed on - // directly (OnRouteChange never fires for the initial route). - model.RecordAmend(entry_id) -> [load_timeline(entry_id)] + // A direct load of Record needs its own detail fetch, since OnRouteChange + // never fires for the initial route. + model.Record(entry_id) -> [effects.load_entry(None, entry_id, GotEntry)] _ -> [] } let startup = case login_error { Some(_) -> effect.batch([ routing, - load_shelf("owned"), + load_shelf(None, "owned"), clear_query(), ..route_effects ]) - None -> effect.batch([routing, load_shelf("owned"), ..route_effects]) + None -> effect.batch([routing, load_shelf(None, "owned"), ..route_effects]) } #(model, startup) } diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index d6eec59..7d2f9fc 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -22,7 +22,7 @@ pub fn on_outside_click(id: String, callback: fn() -> Nil) -> Nil pub fn off_outside_click(id: String) -> Nil @external(javascript, "./ffi.mjs", "uploadCover") -pub fn upload_cover(entry_id: String, callback: fn(Bool) -> Nil) -> Nil +pub fn upload_cover(url: String, callback: fn(Bool) -> Nil) -> Nil @external(javascript, "./ffi.mjs", "startScanner") pub fn start_scanner( diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 0566b2d..d70d906 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -19,16 +19,15 @@ import at_record_web/model.{ } import at_record_web/money import at_record_web/msg.{ - 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, GotFeedEntry, GotFeedMore, GotFeedSkeleton, - GotFollow, GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, - GotPublicShelf, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, - GotTimeline, GotUnfollow, LinkCopied, PublicEntryData, PublicShelfData, - ScanLookup, ShelfData, TimelineData, + type EntryDetailData, type FeedSkeletonData, type Msg, type ShelfData, + AppliedProposal, BarcodeDetected, CameraUnsupported, CoverUploaded, + CrateOverlapData, EntryDetailData, FeedSkeletonData, GotAction, GotActorShelf, + GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, + GotBrowseAdd, GotBrowseSearch, GotCrateOverlap, GotDiscogs, + GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, + GotFeedMore, GotFeedSkeleton, GotFollow, GotHandleSuggestions, GotLogout, + GotPressing, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotUnfollow, + LinkCopied, ScanLookup, ShelfData, } import at_record_web/prefs import gleam/dict @@ -44,38 +43,73 @@ import gleam/uri import lustre/effect.{type Effect} import rsvp -fn xrpc(method: String) -> String { - "/xrpc/dev.mokkenstorm.crate." <> method +/// The BFF method URL, with query `params` percent-encoded and appended when +/// present. +fn xrpc(method: String, params: List(#(String, String))) -> String { + case params { + [] -> "/xrpc/dev.mokkenstorm.crate." <> method + _ -> + "/xrpc/dev.mokkenstorm.crate." + <> method + <> "?" + <> uri.query_to_string(params) + } } -pub fn load_shelf(view: String) -> Effect(Msg) { - rsvp.get( - xrpc("shelf.listEntries") - <> "?view=" - <> view - <> "&limit=" - <> int.to_string(model.crate_page_limit), - rsvp.expect_json(shelf_decoder(), GotShelf), - ) +/// Load a folded crate for `view`: the session's own (`actor` None) or an +/// actor's public one (Some). The own load paginates and lands as `GotShelf`; +/// the public load fetches the full view (no limit) and lands as +/// `GotActorShelf`. +pub fn load_shelf(actor: Option(String), view: String) -> Effect(Msg) { + case actor { + None -> + rsvp.get( + shelf_url(None, view, None, Some(model.crate_page_limit)), + rsvp.expect_json(shelf_decoder(), GotShelf), + ) + Some(_) -> + rsvp.get( + shelf_url(actor, view, None, None), + rsvp.expect_json(shelf_decoder(), GotActorShelf), + ) + } } -/// Fetch the next page for `view` starting after `cursor` (the previous -/// response's `cursor` field): same shape as `load_shelf`, since -/// shelf.listEntries answers every page the same way. +/// Fetch the next own-crate page for `view` starting after `cursor` (the +/// previous response's `cursor` field): same shape as `load_shelf`, since +/// listEntries answers every page the same way. pub fn load_shelf_more(view: String, cursor: String) -> Effect(Msg) { rsvp.get( - xrpc("shelf.listEntries") - <> "?view=" - <> view - <> "&cursor=" - <> cursor - <> "&limit=" - <> int.to_string(model.crate_page_limit), + shelf_url(None, view, Some(cursor), Some(model.crate_page_limit)), rsvp.expect_json(shelf_decoder(), GotShelfMore), ) } +fn shelf_url( + actor: Option(String), + view: String, + cursor: Option(String), + limit: Option(Int), +) -> String { + let params = + list.flatten([ + opt_param("actor", actor), + [#("view", view)], + opt_param("cursor", cursor), + opt_param("limit", option.map(limit, int.to_string)), + ]) + xrpc("shelf.listEntries", params) +} + +fn opt_param(key: String, value: Option(String)) -> List(#(String, String)) { + case value { + Some(v) -> [#(key, v)] + None -> [] + } +} + fn shelf_decoder() -> decode.Decoder(ShelfData) { + use did <- decode.field("did", decode.string) use handle <- decode.field("handle", decode.string) use items <- decode.field("items", decode.list(entry_decoder())) use via_handles <- decode.optional_field( @@ -88,7 +122,7 @@ fn shelf_decoder() -> decode.Decoder(ShelfData) { None, decode.optional(decode.string), ) - decode.success(ShelfData(handle:, items:, via_handles:, cursor:)) + decode.success(ShelfData(did:, handle:, items:, via_handles:, cursor:)) } fn entry_decoder() -> decode.Decoder(Entry) { @@ -135,6 +169,7 @@ fn entry_decoder() -> decode.Decoder(Entry) { None, decode.optional(source_decoder()), ) + use updated_at <- decode.field("updatedAt", decode.string) decode.success(Entry( entry_id:, status:, @@ -147,6 +182,7 @@ fn entry_decoder() -> decode.Decoder(Entry) { price:, counterparty:, source:, + updated_at:, )) } @@ -161,7 +197,7 @@ fn source_decoder() -> decode.Decoder(model.Source) { pub fn logout() -> Effect(Msg) { rsvp.post( - xrpc("server.deleteSession"), + xrpc("server.deleteSession", []), json.object([]), rsvp.expect_json(nil_decoder(), fn(_) { GotLogout }), ) @@ -182,20 +218,21 @@ pub fn discogs_search_url( artist: Option(String), ) -> String { let format = case vinyl_only { - True -> "&format=Vinyl" - False -> "" + True -> [#("format", "Vinyl")] + False -> [] } let artist_param = case artist { - Some(a) if a != "" -> "&artist=" <> uri.percent_encode(a) - _ -> "" + Some(a) if a != "" -> [#("artist", a)] + _ -> [] } - xrpc("discogs.searchReleases") - <> "?q=" - <> uri.percent_encode(query) - <> "&page=" - <> int.to_string(page) - <> format - <> artist_param + xrpc( + "discogs.searchReleases", + list.flatten([ + [#("q", query), #("page", int.to_string(page))], + format, + artist_param, + ]), + ) } pub fn discogs_search( @@ -220,11 +257,10 @@ pub fn discogs_search_artists(query: String, page: Int) -> Effect(Msg) { let decoder = decode.field("results", decode.list(artist_decoder()), decode.success) let url = - xrpc("discogs.searchArtists") - <> "?q=" - <> uri.percent_encode(query) - <> "&page=" - <> int.to_string(page) + xrpc("discogs.searchArtists", [ + #("q", query), + #("page", int.to_string(page)), + ]) rsvp.get(url, rsvp.expect_json(decoder, GotArtists)) } @@ -321,8 +357,7 @@ pub fn scan_lookup(barcode: String) -> Effect(Msg) { ) decode.success(ScanLookup(result:, suggestions:, network:)) } - let url = - xrpc("discogs.resolveBarcode") <> "?barcode=" <> uri.percent_encode(barcode) + let url = xrpc("discogs.resolveBarcode", [#("barcode", barcode)]) rsvp.get(url, rsvp.expect_json(decoder, GotScanResult(barcode, _))) } @@ -351,7 +386,7 @@ fn network_decoder() -> decode.Decoder(NetworkMatch) { pub fn scan_seen() -> Effect(Msg) { let decoder = decode.field("ids", decode.list(decode.string), decode.success) rsvp.get( - xrpc("discogs.getScanSeen"), + xrpc("discogs.getScanSeen", []), rsvp.expect_json(decoder, fn(r) { GotScanSeen(result.map(r, set.from_list)) }), @@ -387,7 +422,7 @@ pub fn discogs_status() -> Effect(Msg) { }) } rsvp.get( - xrpc("discogs.getStatus"), + xrpc("discogs.getStatus", []), rsvp.expect_json(decoder, GotDiscogsStatus), ) } @@ -400,7 +435,7 @@ pub fn discogs_connect() -> Effect(Msg) { pub fn discogs_disconnect() -> Effect(Msg) { rsvp.post( - xrpc("discogs.disconnectAccount"), + xrpc("discogs.disconnectAccount", []), json.object([]), rsvp.expect_json(nil_decoder(), GotDiscogsDisconnect), ) @@ -421,7 +456,7 @@ fn import_run_decoder() -> decode.Decoder(ImportRun) { /// One capped import run; the server response says whether to run again. pub fn discogs_import() -> Effect(Msg) { rsvp.post( - xrpc("discogs.importCollection"), + xrpc("discogs.importCollection", []), json.object([]), rsvp.expect_json(import_run_decoder(), GotDiscogsImport), ) @@ -431,7 +466,7 @@ pub fn discogs_import() -> Effect(Msg) { /// `GotDiscogsImport` handling) with `discogs_import`. pub fn discogs_import_wantlist() -> Effect(Msg) { rsvp.post( - xrpc("discogs.importWantlist"), + xrpc("discogs.importWantlist", []), json.object([]), rsvp.expect_json(import_run_decoder(), GotDiscogsImport), ) @@ -508,8 +543,9 @@ fn stop_outside_click(id: String) -> Effect(Msg) { /// Ship the chosen cover file (camera shot or upload) as a cover-only /// amendment; the DOM input holds the file, so FFI does the multipart POST. pub fn upload_cover(entry_id: String) -> Effect(Msg) { + let url = xrpc("shelf.uploadCover", [#("entry", entry_id)]) effect.from(fn(dispatch) { - browser.upload_cover(entry_id, fn(ok) { dispatch(CoverUploaded(ok)) }) + browser.upload_cover(url, fn(ok) { dispatch(CoverUploaded(ok)) }) }) } @@ -549,7 +585,7 @@ pub fn amend_entry(entry_id: String, draft: AmendDraft) -> Effect(Msg) { #("refreshCover", json.bool(draft.refresh_cover)), ]) rsvp.post( - xrpc("shelf.amendEntry"), + xrpc("shelf.amendEntry", []), body, rsvp.expect_json(nil_decoder(), GotAmend), ) @@ -557,7 +593,7 @@ pub fn amend_entry(entry_id: String, draft: AmendDraft) -> Effect(Msg) { pub fn add_item(form: Form) -> Effect(Msg) { rsvp.post( - xrpc("shelf.addEntry"), + xrpc("shelf.addEntry", []), add_body(form), rsvp.expect_json(nil_decoder(), GotAdd), ) @@ -571,7 +607,7 @@ pub fn add_scanned( on_done: fn(String, Result(Nil, rsvp.Error(String))) -> Msg, ) -> Effect(Msg) { rsvp.post( - xrpc("shelf.addEntry"), + xrpc("shelf.addEntry", []), add_body(form), rsvp.expect_json(nil_decoder(), on_done(barcode, _)), ) @@ -591,7 +627,7 @@ pub fn adopt_scanned( #("status", json.string("owned")), ]) rsvp.post( - xrpc("catalog.adoptRelease"), + xrpc("catalog.adoptRelease", []), body, rsvp.expect_json(nil_decoder(), on_done(barcode, _)), ) @@ -601,7 +637,7 @@ pub fn adopt_scanned( /// folded against the viewer's own crate. pub fn load_browse() -> Effect(Msg) { rsvp.get( - xrpc("catalog.listReleases"), + xrpc("catalog.listReleases", []), rsvp.expect_json( decode.field( "releases", @@ -617,20 +653,18 @@ pub fn load_browse() -> Effect(Msg) { /// `load_browse` exactly (same decoder), so a search result drops straight /// into `model.browse`. pub fn search_browse(query: String, genre: String) -> Effect(Msg) { - let q_param = case string.trim(query) { - "" -> "" - q -> "q=" <> uri.percent_encode(q) - } - let genre_param = case string.trim(genre) { - "" -> "" - g -> "genre=" <> uri.percent_encode(g) - } let params = - [q_param, genre_param] |> list.filter(fn(p) { p != "" }) |> string.join("&") - let url = case params { - "" -> xrpc("catalog.listReleases") - _ -> xrpc("catalog.listReleases") <> "?" <> params - } + list.flatten([ + case string.trim(query) { + "" -> [] + q -> [#("q", q)] + }, + case string.trim(genre) { + "" -> [] + g -> [#("genre", g)] + }, + ]) + let url = xrpc("catalog.listReleases", params) rsvp.get( url, rsvp.expect_json( @@ -675,7 +709,7 @@ fn browse_release_decoder() -> decode.Decoder(BrowseRelease) { /// `OnRouteChange(PressingDetail(..))`). pub fn load_pressing(release_uri: String) -> Effect(Msg) { rsvp.get( - xrpc("catalog.getRelease") <> "?uri=" <> uri.percent_encode(release_uri), + xrpc("catalog.getRelease", [#("uri", release_uri)]), rsvp.expect_json( decode.field("release", browse_release_decoder(), decode.success), GotPressing, @@ -692,7 +726,7 @@ pub fn browse_add(uri: String, cid: String, status: String) -> Effect(Msg) { #("status", json.string(status)), ]) rsvp.post( - xrpc("catalog.adoptRelease"), + xrpc("catalog.adoptRelease", []), body, rsvp.expect_json(nil_decoder(), GotBrowseAdd(uri, _)), ) @@ -708,7 +742,7 @@ fn strong_ref(uri: String, cid: String) -> Json { /// when the inbox page opens, never at login. pub fn load_edit_inbox() -> Effect(Msg) { rsvp.get( - xrpc("catalog.listEditProposals"), + xrpc("catalog.listEditProposals", []), rsvp.expect_json( decode.field( "proposals", @@ -774,7 +808,7 @@ pub fn apply_edit_proposal(uri: String, cid: String) -> Effect(Msg) { decode.success(AppliedProposal(release_uri:)) } rsvp.post( - xrpc("catalog.applyEditProposal"), + xrpc("catalog.applyEditProposal", []), body, rsvp.expect_json(decoder, GotApplyProposal(uri, _)), ) @@ -787,7 +821,7 @@ fn entry_event( fields: List(#(String, Json)), ) -> Effect(Msg) { rsvp.post( - xrpc("shelf.appendEvent"), + xrpc("shelf.appendEvent", []), json.object([ #("entryId", json.string(entry_id)), #("action", json.string(action)), @@ -812,20 +846,31 @@ pub fn rate(entry_id: String, rating: Int) -> Effect(Msg) { entry_event(entry_id, "rated", [#("rating", json.int(rating))]) } -/// Fetch the full append-only event log for one entry, plus its resolved -/// catalog release (best-effort: omitted from the response when unresolvable). -pub fn load_timeline(entry_id: String) -> Effect(Msg) { - let decoder = { - use events <- decode.field("events", decode.list(shelf_entry_decoder())) - use release <- decode.optional_field( - "release", - None, - decode.optional(release_info_decoder()), - ) - decode.success(TimelineData(events:, release:)) - } - let url = xrpc("shelf.getEntry") <> "?entry=" <> uri.percent_encode(entry_id) - rsvp.get(url, rsvp.expect_json(decoder, GotTimeline)) +/// Fetch one entry's superset detail (folded entry, full event log, resolved +/// release): the session's own (`actor` None) or an actor's public one (Some). +/// `to_msg` routes the result, so the record pages and the feed cache share +/// this one loader. +pub fn load_entry( + actor: Option(String), + entry_id: String, + to_msg: fn(Result(EntryDetailData, rsvp.Error(String))) -> Msg, +) -> Effect(Msg) { + let params = list.flatten([opt_param("actor", actor), [#("entry", entry_id)]]) + let url = xrpc("shelf.getEntry", params) + rsvp.get(url, rsvp.expect_json(entry_detail_decoder(), to_msg)) +} + +fn entry_detail_decoder() -> decode.Decoder(EntryDetailData) { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use entry <- decode.field("entry", entry_decoder()) + use events <- decode.field("events", decode.list(shelf_entry_decoder())) + use release <- decode.optional_field( + "release", + None, + decode.optional(release_info_decoder()), + ) + decode.success(EntryDetailData(did:, handle:, entry:, events:, release:)) } fn release_info_decoder() -> decode.Decoder(ReleaseInfo) { @@ -840,43 +885,6 @@ fn release_info_decoder() -> decode.Decoder(ReleaseInfo) { }) } -/// Another user's folded public crate, no auth: same row shape as -/// `load_shelf`'s items, so it shares `entry_decoder`. -pub fn load_public_shelf(handle: String) -> Effect(Msg) { - let decoder = { - use did <- decode.field("did", decode.string) - use handle <- decode.field("handle", decode.string) - use items <- decode.field("items", decode.list(entry_decoder())) - decode.success(PublicShelfData(did:, handle:, items:)) - } - let url = - xrpc("shelf.getPublicShelf") <> "?actor=" <> uri.percent_encode(handle) - rsvp.get(url, rsvp.expect_json(decoder, GotPublicShelf)) -} - -/// One entry off another user's public crate, plus its resolved release -/// fields; shares its decoders with `load_public_shelf`/`load_timeline`. -pub fn load_public_entry(handle: 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 release <- decode.optional_field( - "release", - None, - decode.optional(release_info_decoder()), - ) - decode.success(PublicEntryData(did:, handle:, entry:, release:)) - } - let url = - xrpc("shelf.getPublicEntry") - <> "?actor=" - <> uri.percent_encode(handle) - <> "&entryId=" - <> uri.percent_encode(entry_id) - 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. @@ -885,15 +893,14 @@ pub fn load_feed(cursor: Option(String)) -> Effect(Msg) { 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 + xrpc( + "feed.getFeedSkeleton", + list.flatten([ + [#("limit", int.to_string(model.feed_page_limit))], + opt_param("cursor", cursor), + ]), + ) rsvp.get(url, rsvp.expect_json(feed_skeleton_decoder(), to_msg)) } @@ -911,32 +918,6 @@ fn feed_skeleton_decoder() -> decode.Decoder(FeedSkeletonData) { 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) @@ -964,8 +945,7 @@ pub fn load_crate_overlap(handle: String) -> Effect(Msg) { follow_uri:, )) } - let url = - xrpc("shelf.getCrateOverlap") <> "?actor=" <> uri.percent_encode(handle) + let url = xrpc("shelf.getCrateOverlap", [#("actor", handle)]) rsvp.get(url, rsvp.expect_json(decoder, GotCrateOverlap)) } @@ -973,7 +953,7 @@ pub fn load_crate_overlap(handle: String) -> Effect(Msg) { pub fn follow_user(subject_did: String) -> Effect(Msg) { let decoder = decode.field("uri", decode.string, decode.success) rsvp.post( - xrpc("graph.followUser"), + xrpc("graph.followUser", []), json.object([#("subject", json.string(subject_did))]), rsvp.expect_json(decoder, GotFollow), ) @@ -982,7 +962,7 @@ pub fn follow_user(subject_did: String) -> Effect(Msg) { /// Delete the caller's follow record for `subject_did`. pub fn unfollow_user(subject_did: String) -> Effect(Msg) { rsvp.post( - xrpc("graph.unfollowUser"), + xrpc("graph.unfollowUser", []), json.object([#("subject", json.string(subject_did))]), rsvp.expect_json(nil_decoder(), GotUnfollow), ) diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index 53d834f..d97a716 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -56,13 +56,13 @@ export function offOutsideClick(id) { } } -export function uploadCover(entryId, cb) { +export function uploadCover(url, cb) { const input = document.getElementById("cover-file"); const file = input && input.files && input.files[0]; if (!file) { cb(false); return; } const fd = new FormData(); fd.append("cover", file); - fetch(`/xrpc/dev.mokkenstorm.crate.shelf.uploadCover?entry=${encodeURIComponent(entryId)}`, { method: "POST", body: fd }) + fetch(url, { method: "POST", body: fd }) .then((r) => cb(r.ok)) .catch(() => cb(false)); } diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 6b0f9ca..5dfabed 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -1,3 +1,4 @@ +import at_record/gen/catalog/release as catalog_release 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} @@ -135,30 +136,66 @@ pub type Entry { price: Option(Price), counterparty: Option(String), source: Option(Source), + updated_at: String, ) } -/// The shelf's load state, so a failed fetch is distinguishable from a -/// genuinely empty crate (both would otherwise be an empty item list). +/// Whose crate a shelf load is for: the session's own account, or another +/// actor (carrying that actor's resolved did/handle for the public views). +pub type Owner { + Own + Actor(did: String, handle: String) +} + +/// A shelf's load state, so a failed fetch is distinguishable from a genuinely +/// empty crate. One type for both the session's own crate and any actor's +/// public one; `ShelfLoaded` carries the `Owner` it was loaded for. pub type Shelf { ShelfLoading - ShelfLoaded(List(Entry)) + ShelfLoaded(owner: Owner, items: List(Entry)) ShelfFailed } -/// The loaded entries, or [] while loading or after a failed load. -pub fn entries(model: Model) -> List(Entry) { - case model.shelf { - ShelfLoaded(items) -> items - _ -> [] +/// Which cached `Shelf` a crate load belongs to: the session's own account, +/// or an actor reached off a route. Routes carry handles, not dids, so the +/// actor arm keys on handle alone (the loaded `Shelf` itself still carries +/// the resolved did via `Owner`). Nothing ever evicts a key: the cache is +/// unbounded for the session's lifetime, by design. +pub type CrateKey { + OwnCrate + ActorCrate(handle: String) +} + +/// The cached `Shelf` for `key`, or `ShelfLoading` if nothing has been +/// fetched under it yet this session. +pub fn crate_of(model: Model, key: CrateKey) -> Shelf { + case dict.get(model.crates, key) { + Ok(shelf) -> shelf + Error(Nil) -> ShelfLoading } } -/// Another user's public crate load state, mirroring `Shelf`. -pub type PublicShelf { - PublicShelfLoading - PublicShelfLoaded(did: String, handle: String, items: List(Entry)) - PublicShelfFailed +/// Store `shelf` under `key`, leaving every other cached crate untouched. +pub fn set_crate(model: Model, key: CrateKey, shelf: Shelf) -> Model { + Model(..model, crates: dict.insert(model.crates, key, shelf)) +} + +/// Set `key` to `ShelfLoading` only if nothing is cached there yet, so +/// revisiting an already-loaded crate keeps rendering the stale copy while +/// the route's own fetch effect refreshes it in the background. +pub fn set_crate_loading_if_absent(model: Model, key: CrateKey) -> Model { + case dict.has_key(model.crates, key) { + True -> model + False -> set_crate(model, key, ShelfLoading) + } +} + +/// The loaded own-crate entries, or [] while loading or after a failed load. +pub fn entries(model: Model) -> List(Entry) { + case crate_of(model, OwnCrate) { + ShelfLoaded(_, items) -> items + _ -> [] + } } /// The signed-in viewer's overlap with another actor's public crate. @@ -172,17 +209,26 @@ pub type CrateOverlap { ) } -/// One entry off another user's public crate, plus its resolved release -/// fields; mirrors `PublicShelf`'s load-state shape. -pub type PublicEntryState { - PublicEntryLoading - PublicEntryLoaded( +/// One entry's superset: the crate owner's did/handle, the folded current +/// entry, its full event timeline, and its resolved release fields. Fed by the +/// unified `getEntry` response and shared by the own/public record pages and +/// the feed hydration cache. +pub type EntryDetail { + EntryDetail( did: String, handle: String, entry: Entry, + events: List(ShelfEntry), release_info: Option(ReleaseInfo), ) - PublicEntryFailed +} + +/// The open record's detail load state, mirroring `Shelf`. Serves both the +/// authed record page and the public one (routes are mutually exclusive). +pub type EntryDetailState { + EntryDetailLoading + EntryDetailLoaded(EntryDetail) + EntryDetailFailed } /// The network activity feed's load state, mirroring `Shelf`. `FeedLoaded` @@ -194,30 +240,17 @@ pub type FeedState { 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), + cache: dict.Dict(#(String, String), EntryDetail), ) -> 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) }) + |> result.map(fn(fe) { string.slice(fe.entry.updated_at, 0, 10) }) }) |> list.fold(None, fn(newest, day) { case newest { @@ -536,7 +569,7 @@ pub fn split_release_uri(uri: String) -> Result(#(String, String), Nil) { /// The `catalog.release` collection nsid, so `PressingDetail`'s did/rkey can /// be rebuilt into the at:// uri `split_release_uri` above undoes. -pub const release_collection = "dev.mokkenstorm.crate.catalog.release" +pub const release_collection = catalog_release.collection pub fn release_uri(did: String, rkey: String) -> String { "at://" <> did <> "/" <> release_collection <> "/" <> rkey @@ -689,7 +722,9 @@ pub type Model { view: String, display: Display, theme: Theme, - shelf: Shelf, + // Cached crate loads, keyed by whose crate it is: the session's own + // (`OwnCrate`) or an actor's public one (`ActorCrate`). + crates: dict.Dict(CrateKey, Shelf), // How many crate items are currently revealed (client-side windowing). crate_window: Int, // The current view's next-page cursor; `None` means either nothing has @@ -708,8 +743,8 @@ pub type Model { selected: Option(String), editing: Bool, confirm_remove: Bool, - timeline: List(ShelfEntry), - release_info: Option(ReleaseInfo), + // The open record's superset detail (own or public); fed by getEntry. + entry_detail: EntryDetailState, discogs: Discogs, scan: ScanState, // True only while the amendment write itself is in flight — narrower @@ -735,9 +770,6 @@ pub type Model { // Locally-ignored edit-proposal uri+cid pairs, loaded from // `prefs.ignored_proposals_key` at init; see `add_ignored_proposal`. ignored_proposals: List(#(String, String)), - // 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. @@ -745,7 +777,7 @@ pub type Model { // 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), + feed_entries: dict.Dict(#(String, String), EntryDetail), // 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 diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 9cf9c16..eba560d 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -18,8 +18,11 @@ pub type AppliedProposal { AppliedProposal(release_uri: String) } +/// `listEntries`'s response: a folded crate for one view (own or an actor's), +/// with the owner's did and, for a paginated fetch, the next-page cursor. pub type ShelfData { ShelfData( + did: String, handle: String, items: List(Entry), via_handles: dict.Dict(String, String), @@ -28,23 +31,15 @@ pub type ShelfData { ) } -/// One entry's event log plus its resolved catalog release, if any. -pub type TimelineData { - TimelineData(events: List(ShelfEntry), release: Option(ReleaseInfo)) -} - -/// `getPublicShelf`'s response: another user's folded crate, no auth. -pub type PublicShelfData { - PublicShelfData(did: String, handle: String, items: List(Entry)) -} - -/// `getPublicEntry`'s response: one entry off another user's crate plus its -/// resolved release fields. -pub type PublicEntryData { - PublicEntryData( +/// `getEntry`'s response: the crate owner's did/handle, the folded entry, its +/// full event log, and its resolved release fields. One payload for the own +/// record page, the public record page, and the feed hydration cache. +pub type EntryDetailData { + EntryDetailData( did: String, handle: String, entry: Entry, + events: List(ShelfEntry), release: Option(ReleaseInfo), ) } @@ -59,18 +54,6 @@ pub type FeedSkeletonData { ) } -/// 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( @@ -136,9 +119,10 @@ pub type Msg { ArmRemove DisarmRemove EntryAction(entry_id: String, action: String) - ToggleEntry(entry_id: String) ToggleEdit - GotTimeline(Result(TimelineData, rsvp.Error(String))) + /// The open record's superset detail (own or public record page), fed by + /// `getEntry`; both pages share the `entry_detail` slot. + GotEntry(Result(EntryDetailData, rsvp.Error(String))) Regrade(entry_id: String, grade: String) Rate(entry_id: String, rating: String) GotAction(Result(Nil, rsvp.Error(String))) @@ -192,13 +176,12 @@ pub type Msg { result: Result(AppliedProposal, rsvp.Error(String)), ) IgnoreProposal(uri: String) - GotPublicShelf(Result(PublicShelfData, rsvp.Error(String))) - GotPublicEntry(Result(PublicEntryData, rsvp.Error(String))) + GotActorShelf(Result(ShelfData, rsvp.Error(String))) GotFeedSkeleton(Result(FeedSkeletonData, rsvp.Error(String))) GotFeedEntry( actor: String, entry_id: String, - result: Result(FeedEntryData, rsvp.Error(String)), + result: Result(EntryDetailData, rsvp.Error(String)), ) FeedShowMore GotFeedMore(Result(FeedSkeletonData, rsvp.Error(String))) diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index f918c3a..08be4a0 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -4,8 +4,8 @@ //// panel, offline error panel, or the loaded grid). import at_record_web/model.{ - type Display, type Entry, type Model, Add, Grid, Record, Rows, Scan, - ShelfFailed, ShelfLoaded, ShelfLoading, + type Display, type Entry, type Model, Add, Grid, OwnCrate, Record, Rows, Scan, + ShelfFailed, ShelfLoaded, ShelfLoading, crate_of, } import at_record_web/msg.{ type Msg, RetryShelf, SetDisplay, SetView, ShowMoreCrate, @@ -28,11 +28,11 @@ pub fn view(model: Model) -> Element(Msg) { // The standalone masthead is skipped for this route (view.gleam authed_view); // its wordmark folds into the hero's own inset bar instead. let hero_bar = nav.masthead_bar() - case model.shelf { + case crate_of(model, OwnCrate) { ShelfLoading -> loading_state(hero_bar, model.view, model.display) ShelfFailed -> failed_state(hero_bar) - ShelfLoaded([]) -> empty_state(hero_bar, model.view) - ShelfLoaded(items) -> + ShelfLoaded(_, []) -> empty_state(hero_bar, model.view) + ShelfLoaded(_, items) -> loaded_state( hero_bar, model.view, diff --git a/web/src/at_record_web/pages/feed.gleam b/web/src/at_record_web/pages/feed.gleam index 178971f..1b6ff27 100644 --- a/web/src/at_record_web/pages/feed.gleam +++ b/web/src/at_record_web/pages/feed.gleam @@ -10,7 +10,7 @@ import at_record/gen/feed/get_feed_skeleton.{ FeedItemReasonReasonSubjectConverge, } import at_record_web/model.{ - type FeedEntry, type Model, Browse, FeedFailed, FeedLoaded, FeedLoading, + type EntryDetail, type Model, Browse, FeedFailed, FeedLoaded, FeedLoading, PressingDetail, PublicRecord, } import at_record_web/msg.{type Msg, FeedShowMore} @@ -139,7 +139,7 @@ fn single_row( fn hydrated_strip_row( model: Model, item: FeedItem, - label_for: fn(FeedEntry) -> String, + label_for: fn(EntryDetail) -> String, ) -> Element(Msg) { case hydrated_entries(model, item) { [] -> skeleton_row() @@ -289,7 +289,7 @@ fn first_ref(item: FeedItem) -> Option(EntryRef) { } } -fn lookup(model: Model, ref: EntryRef) -> Option(FeedEntry) { +fn lookup(model: Model, ref: EntryRef) -> Option(EntryDetail) { dict.get(model.feed_entries, #(ref.actor, ref.entry_id)) |> option.from_result } @@ -300,7 +300,7 @@ fn lookup(model: Model, ref: EntryRef) -> Option(FeedEntry) { fn first_ref_hydrated( model: Model, item: FeedItem, -) -> Option(#(EntryRef, FeedEntry)) { +) -> Option(#(EntryRef, EntryDetail)) { first_ref(item) |> option.then(fn(ref) { lookup(model, ref) |> option.map(fn(fe) { #(ref, fe) }) @@ -312,7 +312,7 @@ fn first_ref_hydrated( fn hydrated_entries( model: Model, item: FeedItem, -) -> List(#(EntryRef, FeedEntry)) { +) -> List(#(EntryRef, EntryDetail)) { list.filter_map(item.entries, fn(ref) { lookup(model, ref) |> option.map(fn(fe) { #(ref, fe) }) diff --git a/web/src/at_record_web/pages/public_crate.gleam b/web/src/at_record_web/pages/public_crate.gleam index 843de15..8f6082a 100644 --- a/web/src/at_record_web/pages/public_crate.gleam +++ b/web/src/at_record_web/pages/public_crate.gleam @@ -3,8 +3,8 @@ //// links into the public record route instead of the authed one. import at_record_web/model.{ - type CrateOverlap, type Entry, type Model, LoggedIn, LoggedOut, PublicRecord, - PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, + type CrateOverlap, type Entry, type Model, Actor, ActorCrate, LoggedIn, + LoggedOut, PublicRecord, ShelfFailed, ShelfLoaded, ShelfLoading, crate_of, } import at_record_web/msg.{type Msg, ToggleFollow} import at_record_web/plural @@ -23,14 +23,14 @@ import lustre/element/html import lustre/event pub fn view(model: Model, handle: String) -> Element(Msg) { - case model.public_shelf { - PublicShelfLoading -> loading_state(model, handle) - PublicShelfFailed -> failed_state(model, handle) - PublicShelfLoaded(_, loaded_handle, []) if loaded_handle == handle -> + case crate_of(model, ActorCrate(handle)) { + ShelfLoading -> loading_state(model, handle) + ShelfFailed -> failed_state(model, handle) + ShelfLoaded(Actor(_, loaded_handle), []) if loaded_handle == handle -> empty_state(model, handle) - PublicShelfLoaded(_, loaded_handle, items) if loaded_handle == handle -> + ShelfLoaded(Actor(_, loaded_handle), items) if loaded_handle == handle -> loaded_state(model, handle, items) - PublicShelfLoaded(_, _, _) -> loading_state(model, handle) + ShelfLoaded(_, _) -> loading_state(model, handle) } } diff --git a/web/src/at_record_web/pages/public_record.gleam b/web/src/at_record_web/pages/public_record.gleam index 68894e0..4d2028b 100644 --- a/web/src/at_record_web/pages/public_record.gleam +++ b/web/src/at_record_web/pages/public_record.gleam @@ -1,11 +1,11 @@ //// One entry off another user's public crate: a read-only detail view, no -//// edit/amend/remove actions and no event timeline (the folded entry plus -//// its resolved release fields is all `getPublicEntry` returns). Reuses the +//// edit/amend/remove actions and no event timeline. Reads the shared +//// `entry_detail` slot (populated by `getEntry` with an actor) and reuses the //// pure display helpers off the authed record page. import at_record_web/model.{ - type Entry, type Model, type ReleaseInfo, PublicEntryFailed, PublicEntryLoaded, - PublicEntryLoading, + type Entry, type Model, type ReleaseInfo, EntryDetailFailed, EntryDetailLoaded, + EntryDetailLoading, } import at_record_web/msg.{type Msg} import at_record_web/pages/record.{ @@ -20,13 +20,12 @@ import lustre/element.{type Element, text} import lustre/element/html pub fn view(model: Model, handle: String) -> Element(Msg) { - case model.public_entry { - PublicEntryLoading -> states.loading_page() - PublicEntryFailed -> failed_state(handle) - PublicEntryLoaded(_, loaded_handle, entry, release_info) - if loaded_handle == handle - -> loaded_state(loaded_handle, entry, release_info) - PublicEntryLoaded(_, _, _, _) -> states.loading_page() + case model.entry_detail { + EntryDetailLoading -> states.loading_page() + EntryDetailFailed -> failed_state(handle) + EntryDetailLoaded(d) if d.handle == handle -> + loaded_state(handle, d.entry, d.release_info) + EntryDetailLoaded(_) -> states.loading_page() } } diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index 3ac86dd..3da1608 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -7,7 +7,8 @@ import at_record/gen/defs.{type Snapshot} import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{ - type Entry, type Model, type ReleaseInfo, LoggedIn, LoggedOut, + type Entry, type EntryDetail, type Model, type ReleaseInfo, LoggedIn, + LoggedOut, } import at_record_web/money import at_record_web/msg.{ @@ -29,7 +30,8 @@ import lustre/element.{type Element, text} import lustre/element/html import lustre/event -pub fn view(model: Model, entry: Entry) -> Element(Msg) { +pub fn view(model: Model, detail: EntryDetail) -> Element(Msg) { + let entry = detail.entry let snap = entry.snapshot html.div([attr.class("page-scroll")], [ cov.detail_cover( @@ -41,8 +43,8 @@ pub fn view(model: Model, entry: Entry) -> Element(Msg) { ), titleblock(entry), html.div([attr.class("detail-body")], [ - pressing_section(model, snap, entry.entry_id), - record_section(model, entry), + pressing_section(model, snap, entry.entry_id, detail.release_info), + record_section(entry, detail.events), html.div([attr.class("actions-block")], [ actions( entry.entry_id, @@ -54,7 +56,7 @@ pub fn view(model: Model, entry: Entry) -> Element(Msg) { ]), share_block(model, entry.entry_id), edit_panel(entry, model.editing, model.busy), - timeline(model.timeline), + timeline(detail.events), ]), ]) } @@ -102,11 +104,12 @@ fn pressing_section( model: Model, snap: Snapshot, entry_id: String, + release_info: Option(ReleaseInfo), ) -> Element(Msg) { html.div([attr.class("layer-section")], [ rd.layer_band("THE PRESSING · SHARED CATALOG", element.none()), - pressing_rows(snap, model.release_info), - release_chips(model.release_info), + pressing_rows(snap, release_info), + release_chips(release_info), pressing_provenance(model.via_handles, entry_id), ]) } @@ -149,11 +152,11 @@ fn pressing_provenance( /// "YOUR RECORD": this shelf entry's own condition, price, notes, and how /// you personally logged it, as distinct from the shared pressing above. -fn record_section(model: Model, entry: Entry) -> Element(Msg) { +fn record_section(entry: Entry, events: List(ShelfEntry)) -> Element(Msg) { html.div([attr.class("layer-section")], [ rd.layer_band("YOUR RECORD · IN YOUR REPO", ctl.status_badge(entry.status)), condition(entry), - record_rows(entry, model.timeline), + record_rows(entry, events), record_provenance(entry), notes(entry.notes), ]) diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 2fa1399..49064e3 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -2,23 +2,22 @@ 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, 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, + discogs_status, entry_action, follow_user, handle_search, load_avatar, + load_browse, load_crate_overlap, load_entry, load_feed, load_pressing, + load_shelf, load_shelf_more, 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, 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, + type BrowseRelease, type Entry, type EntryDetail, type Model, type Notice, + Actor, ActorCrate, Add, Browse, BrowseRelease, CrateOverlap, Discogs, + EditInbox, EditProposalDetail, Entry, EntryDetail, EntryDetailFailed, + EntryDetailLoaded, EntryDetailLoading, Failure, Feed, FeedFailed, FeedLoaded, + FeedLoading, Form, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, + Notice, Own, OwnCrate, PressingDetail, PressingFailed, PressingLoaded, + PressingLoading, ProposalApplied, ProposalApplying, ProposalReviewing, + PublicCrate, PublicRecord, Record, RecordAmend, Scan, ScanDone, ScanReview, + ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, crate_of, + crate_window_size, set_crate, set_crate_loading_if_absent, } import at_record_web/money import at_record_web/msg.{ @@ -31,18 +30,17 @@ import at_record_web/msg.{ 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, + GotActorShelf, 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, + GotEntry, GotFeedEntry, GotFeedMore, GotFeedSkeleton, GotFollow, + GotHandleSuggestions, GotLogout, GotPressing, GotScanResult, GotScanSeen, + GotShelf, GotShelfMore, 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, + ToggleFollow, TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, } import at_record_web/photo_scan import at_record_web/route @@ -127,9 +125,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Logout -> #( Model( - ..model, + ..set_crate(model, OwnCrate, ShelfLoading), auth: LoggedOut, - shelf: ShelfLoading, notice: None, avatar: None, confirm_logout: False, @@ -137,7 +134,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effect.batch([logout(), effects.disarm_confirm(model.logout_button_id)]), ) GotLogout -> #( - Model(..model, auth: LoggedOut, shelf: ShelfLoading, avatar: None), + Model( + ..set_crate(model, OwnCrate, ShelfLoading), + auth: LoggedOut, + avatar: None, + ), effect.none(), ) @@ -147,16 +148,15 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { // minted under, so it wouldn't resume anywhere useful against a new one. SetView(view) -> #( Model( - ..model, + ..set_crate(model, OwnCrate, ShelfLoading), view:, - shelf: ShelfLoading, busy: True, notice: None, crate_window: crate_window_size, crate_cursor: None, shelf_loading_more: False, ), - load_shelf(view), + load_shelf(None, view), ) SetDisplay(display) -> #( @@ -186,15 +186,14 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { // Re-run the shelf load after a failed fetch (or session restore). RetryShelf -> #( Model( - ..model, - shelf: ShelfLoading, + ..set_crate(model, OwnCrate, ShelfLoading), busy: True, notice: None, crate_window: crate_window_size, crate_cursor: None, shelf_loading_more: False, ), - load_shelf(model.view), + load_shelf(None, model.view), ) ClearNotice -> #(Model(..model, notice: None), effect.none()) @@ -202,9 +201,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { GotShelf(Ok(data)) -> { let updated = Model( - ..model, + ..set_crate(model, OwnCrate, ShelfLoaded(Own, data.items)), auth: LoggedIn(data.handle), - shelf: ShelfLoaded(data.items), via_handles: data.via_handles, busy: False, crate_window: crate_window_size, @@ -238,10 +236,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { model.append_new(model.entries(model), data.items, fn(e) { Some(e.entry_id) }) + let grown = ShelfLoaded(Own, list.append(model.entries(model), fresh)) #( Model( - ..model, - shelf: ShelfLoaded(list.append(model.entries(model), fresh)), + ..set_crate(model, OwnCrate, grown), via_handles: dict.merge(model.via_handles, data.via_handles), crate_cursor: data.cursor, shelf_loading_more: False, @@ -261,13 +259,16 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { GotShelf(Error(rsvp.HttpError(resp))) -> case resp.status { 401 -> #( - Model(..model, auth: LoggedOut, shelf: ShelfLoaded([]), busy: False), + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), effect.none(), ) _ -> #( Model( - ..model, - shelf: ShelfFailed, + ..set_crate(model, OwnCrate, ShelfFailed), busy: False, notice: failed("Couldn't load your shelf (server error)."), ), @@ -276,8 +277,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { } GotShelf(Error(_)) -> #( Model( - ..model, - shelf: ShelfFailed, + ..set_crate(model, OwnCrate, ShelfFailed), busy: False, notice: failed("Couldn't reach the server."), ), @@ -393,7 +393,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { busy: True, notice: succeeded("Added \"" <> title <> "\" to your crate."), ), - load_shelf(model.view), + load_shelf(None, model.view), ) } GotAdd(Error(e)) -> write_error(model, e, "Could not save the record.") @@ -426,38 +426,18 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ]), ) - ToggleEntry(entry_id) -> - case model.selected { - Some(open) if open == entry_id -> #( - Model(..model, selected: None, timeline: [], release_info: None), - effect.none(), - ) - _ -> #( - Model( - ..model, - selected: Some(entry_id), - busy: True, - timeline: [], - release_info: None, - ), - load_timeline(entry_id), - ) - } - GotTimeline(Ok(data)) -> #( + // The open record's superset detail (own record page and public record + // page share the `entry_detail` slot; the routes are mutually exclusive). + GotEntry(Ok(data)) -> #( Model( ..model, - timeline: data.events, - release_info: data.release, + entry_detail: EntryDetailLoaded(detail_of(data)), busy: False, ), effect.none(), ) - GotTimeline(Error(_)) -> #( - Model( - ..model, - busy: False, - notice: failed("Couldn't load the history for that entry."), - ), + GotEntry(Error(_)) -> #( + Model(..model, entry_detail: EntryDetailFailed, busy: False), effect.none(), ) @@ -508,7 +488,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { write_error( Model( ..model, - shelf: ShelfLoaded(replace_entry(model.entries(model), original)), + entry_detail: set_detail_entry(model.entry_detail, original), pending_revert: None, ), e, @@ -613,7 +593,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { busy: True, notice: succeeded(import_summary(run)), ), - load_shelf(model.view), + load_shelf(None, model.view), ) GotDiscogsImport(Error(e)) -> write_error( @@ -867,7 +847,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { GotBrowse(Error(rsvp.HttpError(resp))) -> case resp.status { 401 -> #( - Model(..model, auth: LoggedOut, shelf: ShelfLoaded([]), busy: False), + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), effect.none(), ) _ -> #( @@ -981,7 +965,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { GotEditInbox(Error(rsvp.HttpError(resp))) -> case resp.status { 401 -> #( - Model(..model, auth: LoggedOut, shelf: ShelfLoaded([]), busy: False), + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), effect.none(), ) _ -> #( @@ -1016,7 +1004,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { inbox: set_card_status(model.inbox, uri, ProposalApplied(release_uri)), ), effect.batch([ - load_shelf(model.view), + load_shelf(None, model.view), leave_proposal_detail(model.route, uri), ]), ) @@ -1050,33 +1038,25 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) } - GotPublicShelf(Ok(data)) -> #( - Model( - ..model, - public_shelf: PublicShelfLoaded(data.did, data.handle, data.items), + GotActorShelf(Ok(data)) -> #( + set_crate( + model, + ActorCrate(data.handle), + ShelfLoaded(Actor(data.did, data.handle), data.items), ), effect.none(), ) - GotPublicShelf(Error(_)) -> #( - Model(..model, public_shelf: PublicShelfFailed), - effect.none(), - ) - GotPublicEntry(Ok(data)) -> #( - Model( - ..model, - public_entry: PublicEntryLoaded( - data.did, - data.handle, - data.entry, - data.release, - ), - ), - effect.none(), - ) - GotPublicEntry(Error(_)) -> #( - Model(..model, public_entry: PublicEntryFailed), - effect.none(), - ) + // The response carries no handle to key off; fall back to whichever + // /u/:handle route is still open (a no-op if the visitor has since + // navigated away). + GotActorShelf(Error(_)) -> + case model.route { + PublicCrate(handle) -> #( + set_crate(model, ActorCrate(handle), ShelfFailed), + effect.none(), + ) + _ -> #(model, effect.none()) + } GotFeedSkeleton(Ok(data)) -> #( Model(..model, feed: FeedLoaded(data.items, data.cursor, data.fallback)), @@ -1096,13 +1076,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 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, - ), + detail_of(data), ), ), effect.none(), @@ -1164,8 +1138,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { // The button only ever renders on a loaded public crate, but the match // is total: no-op if either state isn't there yet. ToggleFollow -> - case model.public_shelf, model.overlap { - PublicShelfLoaded(did, _, _), Some(overlap) -> + case open_actor_shelf(model), model.overlap { + ShelfLoaded(Actor(did, _), _), Some(overlap) -> case overlap.viewer_follows { True -> #( Model( @@ -1268,13 +1242,12 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { editing: False, confirm_remove: False, confirm_logout: False, - timeline: [], - release_info: None, + entry_detail: EntryDetailLoading, busy: True, ), effect.batch([ leaving_scan(model), - load_timeline(entry_id), + load_entry(None, entry_id, GotEntry), disarm_confirms(), ]), ) @@ -1294,9 +1267,9 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { confirm_remove: False, confirm_logout: False, ) - let shelf_effect = case model.shelf { - ShelfLoaded(_) -> effect.none() - _ -> load_shelf(model.view) + let shelf_effect = case crate_of(model, OwnCrate) { + ShelfLoaded(_, _) -> effect.none() + _ -> load_shelf(None, model.view) } #( updated, @@ -1341,8 +1314,6 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { editing: False, confirm_remove: False, confirm_logout: False, - timeline: [], - release_info: None, browse_adding: None, ) #(updated, effect.batch([leaving_scan(model), disarm_confirms()])) @@ -1353,7 +1324,7 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { // only on entry (never at login) and clears any stale in-flight add. route -> { let scan = scan_for_route(model.scan, model.route, route) - #( + let updated = Model( ..model, route:, @@ -1361,8 +1332,6 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { editing: False, confirm_remove: False, confirm_logout: False, - timeline: [], - release_info: None, browse_adding: None, busy: case route { Browse | EditInbox | EditProposalDetail(_) -> True @@ -1373,13 +1342,9 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { EditInbox | EditProposalDetail(_) -> InboxLoading _ -> model.inbox }, - public_shelf: case route { - PublicCrate(_) -> PublicShelfLoading - _ -> model.public_shelf - }, - public_entry: case route { - PublicRecord(_, _) -> PublicEntryLoading - _ -> model.public_entry + entry_detail: case route { + PublicRecord(_, _) -> EntryDetailLoading + _ -> model.entry_detail }, overlap: case route { PublicCrate(_) -> None @@ -1390,7 +1355,17 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { Feed -> FeedLoading _ -> model.feed }, - ), + ) + // A cached actor crate stays put (stale-while-revalidate): the fetch + // below still fires and refreshes it once it lands, but a fresh + // ShelfLoading only overwrites a key that's never been loaded. + let updated = case route { + PublicCrate(handle) -> + set_crate_loading_if_absent(updated, ActorCrate(handle)) + _ -> updated + } + #( + updated, effect.batch([ leaving_scan(model), disarm_confirms(), @@ -1403,11 +1378,11 @@ fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() PublicCrate(handle) -> effect.batch([ - load_public_shelf(handle), + load_shelf(Some(handle), "current"), overlap_effect(model.auth, handle), ]) PublicRecord(handle, entry_id) -> - load_public_entry(handle, entry_id) + load_entry(Some(handle), entry_id, GotEntry) _ -> effect.none() }, ]), @@ -1458,15 +1433,33 @@ fn leave_proposal_detail( /// re-hydrates a ref that's already loaded. fn hydrate_uncached( items: List(FeedItem), - cache: dict.Dict(#(String, String), model.FeedEntry), + cache: dict.Dict(#(String, String), model.EntryDetail), ) -> 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) }) + |> list.map(fn(ref) { + load_entry(Some(ref.actor), ref.entry_id, GotFeedEntry( + ref.actor, + ref.entry_id, + _, + )) + }) |> effect.batch } +/// The one hydrated-entry payload the record pages and the feed cache share, +/// built from the `getEntry` response. +fn detail_of(data: msg.EntryDetailData) -> EntryDetail { + EntryDetail( + did: data.did, + handle: data.handle, + entry: data.entry, + events: data.events, + release_info: data.release, + ) +} + fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { case inbox { InboxLoaded(cards) -> @@ -1583,7 +1576,7 @@ fn finish_batch(model: Model) -> #(Model, Effect(Msg)) { updated, effect.batch([ modem.push(route.to_path(ScanDone), None, None), - load_shelf(model.view), + load_shelf(None, model.view), ]), ) False -> #(updated, effect.none()) @@ -1671,6 +1664,15 @@ fn leaving_scan(model: Model) -> Effect(Msg) { } } +/// The public crate cached for whichever `/u/:handle` route is currently +/// open, or `ShelfLoading` off that route entirely. +fn open_actor_shelf(model: Model) -> model.Shelf { + case model.route { + PublicCrate(handle) -> crate_of(model, ActorCrate(handle)) + _ -> ShelfLoading + } +} + fn overlap_effect(auth: model.Auth, handle: String) -> Effect(Msg) { case auth { LoggedIn(viewer) if viewer != handle -> load_crate_overlap(handle) @@ -1743,41 +1745,47 @@ fn import_summary(run: model.ImportRun) -> String { } } -/// After an event lands, refresh the shelf and the open timeline (if any). -/// Applies `patch` to the entry `entry_id` in the loaded shelf right away, -/// returning the pre-patch entry (if found) so the caller can revert on a -/// failed write. +/// Applies `patch` to the open record's entry right away (the record page +/// reads it off `entry_detail`), returning the pre-patch entry so the caller +/// can revert on a failed write. A no-op when the record isn't loaded. fn optimistic_patch( model: Model, entry_id: String, patch: fn(Entry) -> Entry, ) -> #(Model, Option(Entry)) { - case list.find(model.entries(model), fn(e) { e.entry_id == entry_id }) { - Error(Nil) -> #(model, None) - Ok(original) -> #( + case model.entry_detail { + EntryDetailLoaded(d) if d.entry.entry_id == entry_id -> #( Model( ..model, - shelf: ShelfLoaded(replace_entry(model.entries(model), patch(original))), + entry_detail: EntryDetailLoaded(EntryDetail(..d, entry: patch(d.entry))), ), - Some(original), + Some(d.entry), ) + _ -> #(model, None) } } -fn replace_entry(items: List(Entry), updated: Entry) -> List(Entry) { - list.map(items, fn(e) { - case e.entry_id == updated.entry_id { - True -> updated - False -> e - } - }) +/// Restore an entry into `entry_detail` (revert of an optimistic patch). +fn set_detail_entry( + detail: model.EntryDetailState, + entry: Entry, +) -> model.EntryDetailState { + case detail { + EntryDetailLoaded(d) if d.entry.entry_id == entry.entry_id -> + EntryDetailLoaded(EntryDetail(..d, entry:)) + _ -> detail + } } +/// After an event lands, refresh the shelf and the open record detail (if any). fn after_write(model: Model) -> Effect(Msg) { case model.selected { Some(entry_id) -> - effect.batch([load_shelf(model.view), load_timeline(entry_id)]) - None -> load_shelf(model.view) + effect.batch([ + load_shelf(None, model.view), + load_entry(None, entry_id, GotEntry), + ]) + None -> load_shelf(None, model.view) } } @@ -1844,9 +1852,8 @@ fn write_error( case error { rsvp.HttpError(resp) if resp.status == 401 -> #( Model( - ..model, + ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), auth: LoggedOut, - shelf: ShelfLoaded([]), busy: False, notice: Some(Notice( Warning, diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 616a247..b246236 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -2,10 +2,11 @@ //// modules. import at_record_web/model.{ - type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, - EditInbox, EditProposalDetail, Failure, Feed, Info, LoggedIn, LoggedOut, - Notice, PressingDetail, PublicCrate, PublicRecord, Record, RecordAmend, Scan, - ScanDone, ScanReview, Settings, Success, Warning, inbox_pending_count, + type Model, type Notice, type NoticeLevel, Add, Browse, Crate, EditInbox, + EditProposalDetail, EntryDetailFailed, EntryDetailLoaded, Failure, Feed, Info, + LoggedIn, LoggedOut, Notice, PressingDetail, PublicCrate, PublicRecord, Record, + RecordAmend, Scan, ScanDone, ScanReview, Settings, Success, Warning, + inbox_pending_count, } import at_record_web/msg.{type Msg, Back, ClearNotice} import at_record_web/pages/add @@ -28,7 +29,7 @@ import at_record_web/route import at_record_web/ui/app_bar as bar import at_record_web/ui/nav import at_record_web/ui/record_detail as rd -import gleam/list +import at_record_web/ui/states import gleam/option.{type Option, None, Some} import lustre/attribute as attr import lustre/element.{type Element, text} @@ -127,10 +128,16 @@ fn page(model: Model) -> Element(Msg) { edit_proposal_page(model, id), ) Record(entry_id) -> - case find_entry(model.entries(model), entry_id) { - Some(entry) -> - sub_page("RECORD", element.none(), record.view(model, entry)) - None -> crate.view(model) + case model.entry_detail { + EntryDetailLoaded(detail) if detail.entry.entry_id == entry_id -> + sub_page("RECORD", element.none(), record.view(model, detail)) + EntryDetailFailed -> + sub_page( + "RECORD", + element.none(), + states.failed_page("Couldn't load that record right now."), + ) + _ -> sub_page("RECORD", element.none(), states.loading_page()) } RecordAmend(entry_id) -> sub_page_to( @@ -191,13 +198,6 @@ fn edit_proposal_page(model: Model, id: String) -> Element(Msg) { } } -fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { - case list.find(items, fn(entry) { entry.entry_id == entry_id }) { - Ok(entry) -> Some(entry) - Error(_) -> None - } -} - fn bottom_bar(model: Model) -> Element(Msg) { let section = route.section(model.route) nav.bottom_bar([ diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 4f50f9e..347292b 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -1,6 +1,7 @@ import at_record_web/model.{ - type Entry, Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Record, - Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, + type Entry, Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Own, + OwnCrate, Record, Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, + blank_form, crate_of, set_crate, } import at_record_web/msg.{ ClearNotice, FormTitle, GotBrowseAdd, GotShelf, GotShelfMore, HandleChanged, @@ -46,7 +47,7 @@ pub fn shelf_500_keeps_auth_and_marks_failed_test() { let #(model, _) = update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(500, [], ""))))) assert model.auth == LoggedIn("alice.test") - assert model.shelf == ShelfFailed + assert crate_of(model, OwnCrate) == ShelfFailed assert case model.notice { Some(Notice(Failure, _)) -> True _ -> False @@ -56,7 +57,7 @@ pub fn shelf_500_keeps_auth_and_marks_failed_test() { pub fn shelf_network_error_keeps_auth_and_marks_failed_test() { let #(model, _) = update(logged_in(), GotShelf(Error(rsvp.NetworkError))) assert model.auth == LoggedIn("alice.test") - assert model.shelf == ShelfFailed + assert crate_of(model, OwnCrate) == ShelfFailed assert case model.notice { Some(Notice(Failure, _)) -> True _ -> False @@ -67,23 +68,24 @@ pub fn shelf_401_clears_to_empty_loaded_test() { let #(model, _) = update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(401, [], ""))))) assert model.auth == LoggedOut - assert model.shelf == ShelfLoaded([]) + assert crate_of(model, OwnCrate) == ShelfLoaded(Own, []) } pub fn shelf_ok_marks_loaded_test() { let #(model, _) = update( logged_in(), - GotShelf(Ok(ShelfData("alice.test", [], dict.new(), None))), + GotShelf(Ok(ShelfData("did:plc:a", "alice.test", [], dict.new(), None))), ) - assert model.shelf == ShelfLoaded([]) + assert crate_of(model, OwnCrate) == ShelfLoaded(Own, []) assert model.busy == False } pub fn retry_shelf_resets_to_loading_and_reloads_test() { - let failed = Model(..logged_in(), shelf: ShelfFailed, busy: False) + let failed = + Model(..set_crate(logged_in(), OwnCrate, ShelfFailed), busy: False) let #(model, effect) = update(failed, RetryShelf) - assert model.shelf == ShelfLoading + assert crate_of(model, OwnCrate) == ShelfLoading assert model.busy == True assert model.notice == None // A real load effect is dispatched (not effect.none()). @@ -93,7 +95,7 @@ pub fn retry_shelf_resets_to_loading_and_reloads_test() { pub fn set_view_resets_to_loading_test() { let seeded = Model(..logged_in(), crate_window: 120) let #(updated, _) = update(seeded, SetView("wanted")) - assert updated.shelf == ShelfLoading + assert crate_of(updated, OwnCrate) == ShelfLoading assert updated.view == "wanted" // Switching filter rewinds the window so the new view starts from the top. assert updated.crate_window == model.crate_window_size @@ -262,7 +264,11 @@ fn many_entries(n: Int) -> List(Entry) { pub fn crate_windows_items_and_shows_load_more_when_more_remain_test() { let seeded = - Model(..logged_in(), shelf: ShelfLoaded(many_entries(31)), crate_window: 30) + set_crate( + Model(..logged_in(), crate_window: 30), + OwnCrate, + ShelfLoaded(Own, many_entries(31)), + ) let html = crate.view(seeded) |> element.to_string assert string.contains(html, "class=\"load-more-count\"") assert string.contains(html, "showing 30 of 31") @@ -271,7 +277,11 @@ pub fn crate_windows_items_and_shows_load_more_when_more_remain_test() { pub fn crate_without_overflow_omits_load_more_test() { let seeded = - Model(..logged_in(), shelf: ShelfLoaded(many_entries(5)), crate_window: 30) + set_crate( + Model(..logged_in(), crate_window: 30), + OwnCrate, + ShelfLoaded(Own, many_entries(5)), + ) let html = crate.view(seeded) |> element.to_string assert !string.contains(html, "load-more-block") } @@ -287,7 +297,10 @@ pub fn show_more_crate_grows_the_window_by_a_page_test() { pub fn got_shelf_resets_the_window_test() { let seeded = Model(..logged_in(), crate_window: 120) let #(updated, _) = - update(seeded, GotShelf(Ok(ShelfData("alice.test", [], dict.new(), None)))) + update( + seeded, + GotShelf(Ok(ShelfData("did:plc:a", "alice.test", [], dict.new(), None))), + ) assert updated.crate_window == model.crate_window_size } @@ -295,7 +308,15 @@ pub fn got_shelf_stores_the_next_cursor_test() { let #(updated, _) = update( logged_in(), - GotShelf(Ok(ShelfData("alice.test", [], dict.new(), Some("owned:e30")))), + GotShelf( + Ok(ShelfData( + "did:plc:a", + "alice.test", + [], + dict.new(), + Some("owned:e30"), + )), + ), ) assert updated.crate_cursor == Some("owned:e30") } @@ -309,11 +330,10 @@ pub fn show_more_crate_only_fetches_once_the_window_catches_up_to_the_cursor_tes |> list.each(fn(row) { let #(fetched, cursor, expect_fetch) = row let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(fetched)), - crate_window: 30, - crate_cursor: cursor, + set_crate( + Model(..logged_in(), crate_window: 30, crate_cursor: cursor), + OwnCrate, + ShelfLoaded(Own, many_entries(fetched)), ) let #(updated, effect) = update(seeded, ShowMoreCrate) assert updated.crate_window == 30 + model.crate_window_size @@ -324,21 +344,25 @@ pub fn show_more_crate_only_fetches_once_the_window_catches_up_to_the_cursor_tes pub fn got_shelf_more_appends_and_advances_the_cursor_test() { let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(2)), - crate_cursor: Some("owned:e1"), - shelf_loading_more: True, + set_crate( + Model( + ..logged_in(), + crate_cursor: Some("owned:e1"), + shelf_loading_more: True, + ), + OwnCrate, + ShelfLoaded(Own, many_entries(2)), ) let next_page = ShelfData( + "did:plc:a", "alice.test", many_entries(4) |> list.drop(2), dict.new(), Some("owned:e3"), ) let #(updated, _) = update(seeded, GotShelfMore(Ok(next_page))) - let assert ShelfLoaded(items) = updated.shelf + let assert ShelfLoaded(_, items) = crate_of(updated, OwnCrate) assert list.map(items, fn(e) { e.entry_id }) == ["e0", "e1", "e2", "e3"] assert updated.crate_cursor == Some("owned:e3") assert updated.shelf_loading_more == False @@ -346,28 +370,35 @@ pub fn got_shelf_more_appends_and_advances_the_cursor_test() { pub fn got_shelf_more_dedupes_by_entry_id_test() { let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(2)), - crate_cursor: Some("owned:e1"), + set_crate( + Model(..logged_in(), crate_cursor: Some("owned:e1")), + OwnCrate, + ShelfLoaded(Own, many_entries(2)), ) // A write shifting the fold between pages must not duplicate the overlap. let overlapping_page = - ShelfData("alice.test", many_entries(3) |> list.drop(1), dict.new(), None) + ShelfData( + "did:plc:a", + "alice.test", + many_entries(3) |> list.drop(1), + dict.new(), + None, + ) let #(updated, _) = update(seeded, GotShelfMore(Ok(overlapping_page))) - let assert ShelfLoaded(items) = updated.shelf + let assert ShelfLoaded(_, items) = crate_of(updated, OwnCrate) assert list.map(items, fn(e) { e.entry_id }) == ["e0", "e1", "e2"] } pub fn got_shelf_more_merges_via_handles_test() { let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(1)), - via_handles: dict.from_list([#("e0", "a.test")]), + set_crate( + Model(..logged_in(), via_handles: dict.from_list([#("e0", "a.test")])), + OwnCrate, + ShelfLoaded(Own, many_entries(1)), ) let next_page = ShelfData( + "did:plc:a", "alice.test", many_entries(2) |> list.drop(1), dict.from_list([#("e1", "b.test")]), @@ -390,11 +421,10 @@ pub fn got_shelf_more_error_clears_loading_and_notices_the_user_test() { pub fn load_more_shows_when_a_cursor_remains_even_without_local_overflow_test() { let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(5)), - crate_window: 30, - crate_cursor: Some("owned:e4"), + set_crate( + Model(..logged_in(), crate_window: 30, crate_cursor: Some("owned:e4")), + OwnCrate, + ShelfLoaded(Own, many_entries(5)), ) let html = crate.view(seeded) |> element.to_string assert string.contains(html, "class=\"load-more-count\"") @@ -402,11 +432,10 @@ pub fn load_more_shows_when_a_cursor_remains_even_without_local_overflow_test() pub fn load_more_renders_a_disabled_loading_state_while_fetching_test() { let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(31)), - crate_window: 30, - shelf_loading_more: True, + set_crate( + Model(..logged_in(), crate_window: 30, shelf_loading_more: True), + OwnCrate, + ShelfLoaded(Own, many_entries(31)), ) let html = crate.view(seeded) |> element.to_string assert string.contains(html, "LOADING MORE") diff --git a/web/test/feed_test.gleam b/web/test/feed_test.gleam index ba37953..e3c585a 100644 --- a/web/test/feed_test.gleam +++ b/web/test/feed_test.gleam @@ -6,11 +6,11 @@ import at_record/gen/feed/get_feed_skeleton.{ ReasonActorBatch, ReasonImport, ReasonSingle, ReasonSubjectConverge, } import at_record_web/model.{ - type FeedEntry, type Model, Feed, FeedEntry, FeedFailed, FeedLoaded, + type EntryDetail, type Model, Entry, EntryDetail, Feed, FeedFailed, FeedLoaded, FeedLoading, Model, } import at_record_web/msg.{ - FeedEntryData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, + EntryDetailData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, GotFeedSkeleton, OnRouteChange, } import at_record_web/pages/feed @@ -67,20 +67,20 @@ fn import_item( ) } -fn fe(handle: String, updated_at: String) -> FeedEntry { - FeedEntry( +fn fe(handle: String, updated_at: String) -> EntryDetail { + EntryDetail( did: "did:" <> handle, handle:, - entry: an_entry(), + entry: Entry(..an_entry(), updated_at:), + events: [], release_info: None, - updated_at:, ) } fn with_feed( items: List(FeedItem), fallback: Bool, - entries: List(#(#(String, String), FeedEntry)), + entries: List(#(#(String, String), EntryDetail)), ) -> Model { Model( ..logged_in(), @@ -271,12 +271,12 @@ pub fn got_feed_skeleton_with_no_items_fires_no_hydration_test() { pub fn got_feed_entry_ok_caches_the_entry_test() { let data = - FeedEntryData( + EntryDetailData( did: "did:a", handle: "alice.test", entry: an_entry(), + events: [], 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")) diff --git a/web/test/login_test.gleam b/web/test/login_test.gleam index a8b1511..87bd82e 100644 --- a/web/test/login_test.gleam +++ b/web/test/login_test.gleam @@ -1,4 +1,4 @@ -import at_record_web/model.{LoggedOut, ShelfLoading} +import at_record_web/model.{LoggedOut, OwnCrate, ShelfLoading, crate_of} import at_record_web/msg.{GotAvatar, GotLogout, Logout} import at_record_web/pages/login import at_record_web/update.{update} @@ -58,7 +58,7 @@ pub fn oauth_error_copy_maps_known_codes_test() { pub fn logout_clears_session_test() { let #(model, _) = update(logged_in(), Logout) assert model.auth == LoggedOut - assert model.shelf == ShelfLoading + assert crate_of(model, OwnCrate) == ShelfLoading } pub fn got_logout_logs_out_test() { diff --git a/web/test/nav_test.gleam b/web/test/nav_test.gleam index 50407f4..57bfc7f 100644 --- a/web/test/nav_test.gleam +++ b/web/test/nav_test.gleam @@ -4,9 +4,9 @@ //// an anchor. import at_record_web/model.{ - Add, Browse, Crate, EditInbox, EditProposalDetail, Feed, Model, PressingDetail, - PressingLoading, PublicCrate, PublicRecord, Record, RecordAmend, Scan, - ScanDone, ScanReview, Settings, ShelfLoaded, + Add, Browse, Crate, EditInbox, EditProposalDetail, Feed, Model, Own, OwnCrate, + PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, + RecordAmend, Scan, ScanDone, ScanReview, Settings, ShelfLoaded, set_crate, } import at_record_web/msg.{Back, OnRouteChange} import at_record_web/route @@ -46,7 +46,11 @@ pub fn route_section_maps_every_drilldown_to_its_tab_test() { pub fn crate_tab_stays_active_when_drilled_into_a_record_test() { let seeded = - Model(..logged_in(), route: Record("e1"), shelf: ShelfLoaded([an_entry()])) + set_crate( + Model(..logged_in(), route: Record("e1")), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), + ) let html = view.view(seeded) |> element.to_string assert string.contains(html, "") } @@ -64,7 +68,11 @@ pub fn browse_tab_goes_active_on_the_pressing_detail_drilldown_test() { pub fn sub_page_back_control_renders_as_a_button_not_a_link_test() { let seeded = - Model(..logged_in(), route: Record("e1"), shelf: ShelfLoaded([an_entry()])) + set_crate( + Model(..logged_in(), route: Record("e1")), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), + ) let html = view.view(seeded) |> element.to_string assert string.contains( html, diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam index 85dc187..69f43ad 100644 --- a/web/test/public_crate_test.gleam +++ b/web/test/public_crate_test.gleam @@ -1,25 +1,30 @@ //// Public-route tests: `/u/:handle` and `/u/:handle/record/:entryId`. import at_record_web/model.{ - type Model, CrateOverlap, LoggedIn, LoggedOut, Model, PublicCrate, - PublicEntryFailed, PublicEntryLoaded, PublicEntryLoading, PublicRecord, - PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, + type Model, Actor, ActorCrate, CrateOverlap, EntryDetailFailed, + EntryDetailLoaded, EntryDetailLoading, LoggedIn, LoggedOut, Model, PublicCrate, + PublicRecord, ShelfFailed, ShelfLoaded, ShelfLoading, crate_of, set_crate, } import at_record_web/msg.{ - CopyRecordLink, CrateOverlapData, GotCrateOverlap, GotFollow, GotPublicEntry, - GotPublicShelf, GotUnfollow, LinkCopied, OnRouteChange, PublicEntryData, - PublicShelfData, ToggleFollow, + CopyRecordLink, CrateOverlapData, EntryDetailData, GotActorShelf, + GotCrateOverlap, GotEntry, GotFollow, GotUnfollow, LinkCopied, OnRouteChange, + ShelfData, ToggleFollow, } import at_record_web/route import at_record_web/update.{update} import at_record_web/view +import gleam/dict import gleam/list import gleam/option.{None, Some} import gleam/string import gleam/uri import lustre/element import rsvp -import support.{an_entry, base, empty_effect, logged_in, unauthorized} +import support.{a_detail, an_entry, base, empty_effect, logged_in, unauthorized} + +fn a_public_shelf(handle: String) -> model.Shelf { + ShelfLoaded(Actor("did:plc:a", handle), [an_entry()]) +} pub fn public_route_round_trips_test() { [ @@ -36,11 +41,10 @@ pub fn public_route_round_trips_test() { pub fn public_crate_renders_without_login_test() { let model = - Model( - ..base(), - auth: LoggedOut, - route: PublicCrate("alice.test"), - public_shelf: PublicShelfLoaded("did:plc:a", "alice.test", [an_entry()]), + set_crate( + Model(..base(), auth: LoggedOut, route: PublicCrate("alice.test")), + ActorCrate("alice.test"), + a_public_shelf("alice.test"), ) let html = view.view(model) |> element.to_string assert string.contains(html, "@alice.test") @@ -54,12 +58,7 @@ pub fn public_record_renders_without_login_test() { ..base(), auth: LoggedOut, route: PublicRecord("alice.test", "e1"), - public_entry: PublicEntryLoaded( - "did:plc:a", - "alice.test", - an_entry(), - None, - ), + entry_detail: EntryDetailLoaded(a_detail()), ) let html = view.view(model) |> element.to_string assert string.contains(html, "Spiderland") @@ -67,20 +66,40 @@ pub fn public_record_renders_without_login_test() { assert !string.contains(html, "LOG IN WITH ATPROTO") } -pub fn on_route_change_to_public_crate_resets_and_loads_test() { - let stale = Model(..base(), public_shelf: PublicShelfFailed) - let #(model, effect) = update(stale, OnRouteChange(PublicCrate("bob.test"))) +pub fn on_route_change_to_an_uncached_public_crate_loads_test() { + let #(model, effect) = update(base(), OnRouteChange(PublicCrate("bob.test"))) assert model.route == PublicCrate("bob.test") - assert model.public_shelf == PublicShelfLoading + assert crate_of(model, ActorCrate("bob.test")) == ShelfLoading assert effect != empty_effect() } +// Revisiting an already-cached profile serves the stale copy immediately +// (no flash back to the loading skeleton) while the route's own fetch effect +// still fires in the background to refresh it. +pub fn on_route_change_to_a_cached_public_crate_keeps_the_cache_and_still_refetches_test() { + let cached = + set_crate(base(), ActorCrate("bob.test"), a_public_shelf("bob.test")) + let #(model, effect) = update(cached, OnRouteChange(PublicCrate("bob.test"))) + assert model.route == PublicCrate("bob.test") + assert crate_of(model, ActorCrate("bob.test")) == a_public_shelf("bob.test") + assert effect != empty_effect() +} + +// A different actor's route entry only touches its own key: an unrelated +// cached crate (e.g. one previously visited under another handle) is left +// alone rather than being wiped. +pub fn on_route_change_to_public_crate_does_not_reset_a_failed_key_test() { + let stale = set_crate(base(), ActorCrate("bob.test"), ShelfFailed) + let #(model, _) = update(stale, OnRouteChange(PublicCrate("bob.test"))) + assert crate_of(model, ActorCrate("bob.test")) == ShelfFailed +} + pub fn on_route_change_to_public_record_resets_and_loads_test() { - let stale = Model(..base(), public_entry: PublicEntryFailed) + let stale = Model(..base(), entry_detail: EntryDetailFailed) let #(model, effect) = update(stale, OnRouteChange(PublicRecord("bob.test", "e9"))) assert model.route == PublicRecord("bob.test", "e9") - assert model.public_entry == PublicEntryLoading + assert model.entry_detail == EntryDetailLoading assert effect != empty_effect() } @@ -88,43 +107,48 @@ pub fn got_public_shelf_ok_loads_items_test() { let #(model, _) = update( base(), - GotPublicShelf( - Ok( - PublicShelfData(did: "did:plc:a", handle: "alice.test", items: [ - an_entry(), - ]), - ), + GotActorShelf( + Ok(ShelfData( + did: "did:plc:a", + handle: "alice.test", + items: [an_entry()], + via_handles: dict.new(), + cursor: None, + )), ), ) - assert model.public_shelf - == PublicShelfLoaded("did:plc:a", "alice.test", [an_entry()]) + assert crate_of(model, ActorCrate("alice.test")) + == a_public_shelf("alice.test") } +// The error carries no handle of its own, so the handler keys off whichever +// /u/:handle route is still open when it lands. pub fn got_public_shelf_error_marks_failed_test() { - let #(model, _) = update(base(), GotPublicShelf(Error(unauthorized()))) - assert model.public_shelf == PublicShelfFailed + let seeded = Model(..base(), route: PublicCrate("alice.test")) + let #(model, _) = update(seeded, GotActorShelf(Error(unauthorized()))) + assert crate_of(model, ActorCrate("alice.test")) == ShelfFailed } -pub fn got_public_entry_ok_loads_entry_test() { +pub fn got_entry_ok_loads_the_detail_test() { let #(model, _) = update( base(), - GotPublicEntry( - Ok(PublicEntryData( + GotEntry( + Ok(EntryDetailData( did: "did:plc:a", handle: "alice.test", entry: an_entry(), + events: [], release: None, )), ), ) - assert model.public_entry - == PublicEntryLoaded("did:plc:a", "alice.test", an_entry(), None) + assert model.entry_detail == EntryDetailLoaded(a_detail()) } -pub fn got_public_entry_error_marks_failed_test() { - let #(model, _) = update(base(), GotPublicEntry(Error(unauthorized()))) - assert model.public_entry == PublicEntryFailed +pub fn got_entry_error_marks_failed_test() { + let #(model, _) = update(base(), GotEntry(Error(unauthorized()))) + assert model.entry_detail == EntryDetailFailed } pub fn copy_record_link_only_fires_when_logged_in_test() { @@ -198,11 +222,10 @@ pub fn got_crate_overlap_error_clears_overlap_test() { } 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()]), + set_crate( + Model(..base(), auth: LoggedIn(viewer_handle), route: PublicCrate(handle)), + ActorCrate(handle), + a_public_shelf(handle), ) } diff --git a/web/test/record_amend_test.gleam b/web/test/record_amend_test.gleam index 5e123a9..47357d6 100644 --- a/web/test/record_amend_test.gleam +++ b/web/test/record_amend_test.gleam @@ -3,7 +3,8 @@ //// shelf lands), and returning to the record on a successful publish. import at_record_web/model.{ - AmendDraft, Crate, Model, RecordAmend, ShelfLoaded, ShelfLoading, blank_amend, + AmendDraft, Crate, Model, Own, OwnCrate, RecordAmend, ShelfLoaded, + ShelfLoading, blank_amend, set_crate, } import at_record_web/msg.{ CoverUploaded, GotAmend, GotShelf, OnRouteChange, ShelfData, @@ -27,7 +28,7 @@ pub fn record_amend_route_round_trips_test() { } pub fn on_route_change_seeds_the_draft_from_the_entrys_snapshot_test() { - let seeded = Model(..logged_in(), shelf: ShelfLoaded([an_entry()])) + let seeded = set_crate(logged_in(), OwnCrate, ShelfLoaded(Own, [an_entry()])) let #(after, _) = update(seeded, OnRouteChange(RecordAmend("e1"))) assert after.route == RecordAmend("e1") assert after.selected == Some("e1") @@ -37,7 +38,7 @@ pub fn on_route_change_seeds_the_draft_from_the_entrys_snapshot_test() { } pub fn on_route_change_blanks_the_draft_when_the_entry_is_unknown_test() { - let seeded = Model(..logged_in(), shelf: ShelfLoaded([an_entry()])) + let seeded = set_crate(logged_in(), OwnCrate, ShelfLoaded(Own, [an_entry()])) let #(after, _) = update(seeded, OnRouteChange(RecordAmend("ghost"))) assert after.amend.title == "" } @@ -46,7 +47,7 @@ pub fn on_route_change_blanks_the_draft_when_the_entry_is_unknown_test() { // fetching; the arm must kick off its own load rather than seed a blank draft // and stay that way forever. pub fn on_route_change_fetches_the_shelf_when_not_yet_loaded_test() { - let seeded = Model(..logged_in(), shelf: ShelfLoading) + let seeded = set_crate(logged_in(), OwnCrate, ShelfLoading) let #(_, effect) = update(seeded, OnRouteChange(RecordAmend("e1"))) assert effect != empty_effect() } @@ -56,12 +57,17 @@ pub fn got_shelf_reseeds_the_draft_only_while_still_on_the_amend_route_test() { |> list.each(fn(row) { let #(route, expected_title, expected_released) = row let seeded = - Model(..logged_in(), route:, shelf: ShelfLoading, amend: blank_amend()) + set_crate( + Model(..logged_in(), route:, amend: blank_amend()), + OwnCrate, + ShelfLoading, + ) let #(after, _) = update( seeded, GotShelf( Ok(ShelfData( + did: "did:plc:a", handle: "alice.test", items: [an_entry()], via_handles: dict.new(), @@ -98,11 +104,10 @@ pub fn record_amend_page_titles_itself_by_the_entrys_provenance_test() { |> list.each(fn(row) { let #(via_handles, expected_title) = row let seeded = - Model( - ..logged_in(), - route: RecordAmend("e1"), - shelf: ShelfLoaded([an_entry()]), - via_handles:, + set_crate( + Model(..logged_in(), route: RecordAmend("e1"), via_handles:), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string assert string.contains(html, expected_title) @@ -111,18 +116,21 @@ pub fn record_amend_page_titles_itself_by_the_entrys_provenance_test() { pub fn record_amend_page_renders_the_seeded_draft_and_the_cover_input_test() { let seeded = - Model( - ..logged_in(), - route: RecordAmend("e1"), - shelf: ShelfLoaded([an_entry()]), - amend: AmendDraft( - title: "Spiderland", - released: "1991", - country: "", - genres: "", - styles: "", - refresh_cover: False, + set_crate( + Model( + ..logged_in(), + route: RecordAmend("e1"), + amend: AmendDraft( + title: "Spiderland", + released: "1991", + country: "", + genres: "", + styles: "", + refresh_cover: False, + ), ), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string assert string.contains(html, "value=\"Spiderland\"") diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam index 32913ac..53b0a09 100644 --- a/web/test/record_test.gleam +++ b/web/test/record_test.gleam @@ -1,6 +1,10 @@ -import at_record_web/model.{type ReleaseInfo, Entry, Model, ReleaseInfo, Source} +import at_record_web/model.{ + type Entry, type EntryDetail, type ReleaseInfo, Entry, EntryDetail, + EntryDetailLoaded, EntryDetailLoading, Model, Record, ReleaseInfo, Source, +} import at_record_web/msg.{ - ArmRemove, DisarmRemove, EntryAction, GotTimeline, OnRouteChange, TimelineData, + type EntryDetailData, ArmRemove, DisarmRemove, EntryAction, EntryDetailData, + GotEntry, OnRouteChange, } import at_record_web/pages/record import at_record_web/update.{update} @@ -8,23 +12,28 @@ import gleam/dict import gleam/option.{None, Some} import gleam/string import lustre/element -import support.{an_entry, logged_in} +import support.{a_detail, an_entry, logged_in} + +/// `a_detail()` with its folded entry swapped for `entry`. +fn detail(entry: Entry) -> EntryDetail { + EntryDetail(..a_detail(), entry:) +} pub fn record_view_shows_the_sleeve_grade_card_when_present_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string + let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains(html, "SLEEVE") assert string.contains(html, "VG+") } pub fn record_view_omits_the_sleeve_grade_card_when_absent_test() { let html = - record.view(logged_in(), Entry(..an_entry(), sleeve_grade: None)) + record.view(logged_in(), detail(Entry(..an_entry(), sleeve_grade: None))) |> element.to_string assert !string.contains(html, "SLEEVE") } pub fn record_view_shows_paid_and_from_rows_when_present_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string + let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains(html, "PAID") assert string.contains(html, "EUR 40.00") assert string.contains(html, "FROM") @@ -35,7 +44,7 @@ pub fn record_view_omits_paid_and_from_rows_when_absent_test() { let html = record.view( logged_in(), - Entry(..an_entry(), price: None, counterparty: None), + detail(Entry(..an_entry(), price: None, counterparty: None)), ) |> element.to_string assert !string.contains(html, "PAID") @@ -45,7 +54,7 @@ pub fn record_view_omits_paid_and_from_rows_when_absent_test() { pub fn record_view_shows_the_provenance_line_when_source_is_present_test() { let source = Source(origin: Some("scan"), provider: Some("discogs")) let html = - record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + record.view(logged_in(), detail(Entry(..an_entry(), source: Some(source)))) |> element.to_string assert string.contains(html, "class=\"layer-provenance\"") assert string.contains(html, "scanned · via Discogs") @@ -54,14 +63,14 @@ pub fn record_view_shows_the_provenance_line_when_source_is_present_test() { pub fn record_view_shows_added_by_hand_for_manual_source_test() { let source = Source(origin: Some("manual"), provider: None) let html = - record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + record.view(logged_in(), detail(Entry(..an_entry(), source: Some(source)))) |> element.to_string assert string.contains(html, "added by hand") } pub fn record_view_omits_the_provenance_line_when_source_is_absent_test() { let html = - record.view(logged_in(), Entry(..an_entry(), source: None)) + record.view(logged_in(), detail(Entry(..an_entry(), source: None))) |> element.to_string assert !string.contains(html, "class=\"layer-provenance\"") } @@ -106,32 +115,43 @@ fn a_release_info() -> ReleaseInfo { ) } -pub fn got_timeline_with_release_stores_release_info_test() { - let #(model, _) = - update( - logged_in(), - GotTimeline(Ok(TimelineData(events: [], release: Some(a_release_info())))), - ) - assert model.release_info == Some(a_release_info()) +fn an_entry_detail(release: option.Option(ReleaseInfo)) -> EntryDetailData { + EntryDetailData( + did: "did:plc:a", + handle: "alice.test", + entry: an_entry(), + events: [], + release:, + ) } -pub fn got_timeline_without_release_leaves_release_info_none_test() { +pub fn got_entry_with_release_stores_release_info_test() { let #(model, _) = - update( - logged_in(), - GotTimeline(Ok(TimelineData(events: [], release: None))), - ) - assert model.release_info == None + update(logged_in(), GotEntry(Ok(an_entry_detail(Some(a_release_info()))))) + let assert EntryDetailLoaded(d) = model.entry_detail + assert d.release_info == Some(a_release_info()) +} + +pub fn got_entry_without_release_leaves_release_info_none_test() { + let #(model, _) = update(logged_in(), GotEntry(Ok(an_entry_detail(None)))) + let assert EntryDetailLoaded(d) = model.entry_detail + assert d.release_info == None +} + +pub fn got_entry_error_marks_the_detail_failed_test() { + let #(model, _) = update(logged_in(), GotEntry(Error(support.unauthorized()))) + assert model.entry_detail == model.EntryDetailFailed } -pub fn route_change_clears_release_info_test() { - let seeded = model.Model(..logged_in(), release_info: Some(a_release_info())) - let #(model, _) = update(seeded, OnRouteChange(model.Crate)) - assert model.release_info == None +// Drilling into a record resets its detail to loading before the fetch lands. +pub fn route_change_to_record_sets_entry_detail_loading_test() { + let seeded = Model(..logged_in(), entry_detail: EntryDetailLoaded(a_detail())) + let #(after, _) = update(seeded, OnRouteChange(Record("e9"))) + assert after.entry_detail == EntryDetailLoading } pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string + let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains(html, "COPY SHARE LINK") assert string.contains(html, "/u/alice.test/record/e1") } @@ -139,7 +159,7 @@ pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { // The fold-out is gone: AMEND/SUGGEST A FIX is now a plain nav link to the // routed amend screen, not a button dispatching a toggle message. pub fn record_view_renders_the_amend_action_as_a_link_to_its_own_route_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string + let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains( html, "AMEND", @@ -149,7 +169,7 @@ pub fn record_view_renders_the_amend_action_as_a_link_to_its_own_route_test() { pub fn record_view_labels_the_amend_link_as_suggest_a_fix_for_an_adopted_entry_test() { let via_handles = dict.from_list([#("e1", "bob.test")]) let html = - record.view(Model(..logged_in(), via_handles:), an_entry()) + record.view(Model(..logged_in(), via_handles:), a_detail()) |> element.to_string assert string.contains( html, diff --git a/web/test/scan_test.gleam b/web/test/scan_test.gleam index ec717b3..c902afe 100644 --- a/web/test/scan_test.gleam +++ b/web/test/scan_test.gleam @@ -1,9 +1,9 @@ import at_record/gen/defs.{Snapshot} import at_record_web/model.{ type Model, type NetworkMatch, Adding, Crate, Entry, Failed, FromDiscogs, - FromNetwork, Matched, Model, NetworkMatch, NetworkMatched, NoMatch, Pending, - Scan, ScanDone, ScanReview, ScanRow, ScanState, ShelfLoaded, Suggestion, - blank_scan, + FromNetwork, Matched, Model, NetworkMatch, NetworkMatched, NoMatch, Own, + OwnCrate, Pending, Scan, ScanDone, ScanReview, ScanRow, ScanState, ShelfLoaded, + Suggestion, blank_scan, set_crate, } import at_record_web/msg.{ AcceptSuggestion, BarcodeDetected, BatchItemDone, GotScanResult, GotScanSeen, @@ -319,19 +319,23 @@ fn an_entry() -> model.Entry { price: None, counterparty: None, source: None, + updated_at: "2026-01-01T00:00:00Z", ) } fn seeded_for_batch() -> Model { - Model( - ..logged_in(), - shelf: ShelfLoaded([an_entry()]), - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), - ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), - ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), - ScanRow(barcode: "b4", outcome: NoMatch([])), - ]), + set_crate( + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), + ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ]), + ), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), ) } diff --git a/web/test/support.gleam b/web/test/support.gleam index f0763ff..aef37fa 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -4,8 +4,9 @@ import at_record/gen/defs.{type Snapshot, Price, Snapshot} import at_record_web/model.{ type DiscogsResult, type Display, type Entry, type Model, Crate, DiscogsResult, - Entry, Grid, LoggedIn, LoggedOut, Model, ShelfLoaded, ShelfLoading, System, - blank_amend, blank_discogs, blank_form, blank_scan, + Entry, Grid, LoggedIn, LoggedOut, Model, Own, OwnCrate, ShelfLoaded, + ShelfLoading, System, blank_amend, blank_discogs, blank_form, blank_scan, + set_crate, } import at_record_web/pages/crate import gleam/dict @@ -33,7 +34,7 @@ pub fn base() -> Model { view: "owned", display: Grid, theme: System, - shelf: ShelfLoaded([]), + crates: dict.from_list([#(OwnCrate, ShelfLoaded(Own, []))]), crate_window: model.crate_window_size, crate_cursor: None, shelf_loading_more: False, @@ -45,8 +46,7 @@ pub fn base() -> Model { selected: None, editing: False, confirm_remove: False, - timeline: [], - release_info: None, + entry_detail: model.EntryDetailLoading, discogs: blank_discogs(), scan: blank_scan(), publishing: False, @@ -60,8 +60,6 @@ pub fn base() -> Model { inbox: model.InboxLoading, confirm_logout: False, ignored_proposals: [], - public_shelf: model.PublicShelfLoading, - public_entry: model.PublicEntryLoading, overlap: None, pressing: model.PressingLoading, feed: model.FeedLoading, @@ -76,13 +74,17 @@ pub fn logged_in() -> Model { } pub fn rendered_empty_crate(view: String) -> String { - Model(..logged_in(), view:, shelf: ShelfLoaded([])) + set_crate(Model(..logged_in(), view:), OwnCrate, ShelfLoaded(Own, [])) |> crate.view |> element.to_string } pub fn rendered_loaded_crate(display: Display) -> String { - Model(..logged_in(), display:, shelf: ShelfLoaded([an_entry()])) + set_crate( + Model(..logged_in(), display:), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), + ) |> crate.view |> element.to_string } @@ -117,6 +119,19 @@ pub fn an_entry() -> Entry { price: Some(Price(amount: 4000, currency: "EUR")), counterparty: Some("Record shop"), source: None, + updated_at: "2026-01-01T00:00:00Z", + ) +} + +/// A minimal loaded `EntryDetail` wrapping `an_entry()`, for the record pages +/// and feed cache; override fields via `EntryDetail(..a_detail(), ..)`. +pub fn a_detail() -> model.EntryDetail { + model.EntryDetail( + did: "did:plc:a", + handle: "alice.test", + entry: an_entry(), + events: [], + release_info: None, ) } -- 2.51.2