diff --git a/lexicons/dev/mokkenstorm/crate/catalog/listEditProposals.sdl b/lexicons/dev/mokkenstorm/crate/catalog/listEditProposals.sdl index fbba85f..9b183be 100644 --- a/lexicons/dev/mokkenstorm/crate/catalog/listEditProposals.sdl +++ b/lexicons/dev/mokkenstorm/crate/catalog/listEditProposals.sdl @@ -18,6 +18,8 @@ type proposalRow { rationale: String /// The target release's current title. releaseTitle: String! + /// How many crates have adopted the release this proposal targets (owned + wanted, excluding gone), same tally as `listReleases`. Best-effort; omitted when the target release isn't in the catalog index. + subjectAdoptionCount: Int targetUri: AtUri! /// at-uri of the edit proposal record, in the proposer's repo. uri: AtUri! diff --git a/server/src/at_record_server/edit_inbox.gleam b/server/src/at_record_server/edit_inbox.gleam index ced6863..7eb64da 100644 --- a/server/src/at_record_server/edit_inbox.gleam +++ b/server/src/at_record_server/edit_inbox.gleam @@ -11,6 +11,7 @@ import at_record/gen/catalog/edit as catalog_edit import at_record/gen/catalog/list_edit_proposals.{type ProposalRow, ProposalRow} import at_record/gen/catalog/release as catalog_release import at_record/gen/defs +import at_record_server/catalog/source as catalog_source import at_record_server/catalog_deps.{type Deps} import at_record_server/handle_resolver import at_record_server/oauth/sessions.{type OauthSession} @@ -91,15 +92,34 @@ pub fn list_proposals( client: Client, session: OauthSession, own: List(OwnRelease), + variant_source: catalog_source.Source, ) -> List(ProposalRow) { + let adoption_count = subject_adoption_count(variant_source) cap_releases(own) - |> list.flat_map(proposals_for(deps, client, session, _)) + |> list.flat_map(proposals_for(deps, client, session, adoption_count, _)) // Constellation may return the same linking record more than once. |> unique_by_uri |> list.sort(fn(a, b) { string.compare(b.created_at, a.created_at) }) |> cap_rows(proposal_cap, "merged proposals") } +/// A release uri's adoption count from the same catalog index the browse +/// pipeline reads (owned + wanted, excluding gone; see +/// `catalog_index.adoption_count`), or `None` when the release hasn't been +/// indexed yet (best-effort: ingestion lag, not an error). +fn subject_adoption_count( + variant_source: catalog_source.Source, +) -> fn(String) -> Option(Int) { + let indexed = + variant_source.releases() |> list.map(fn(r) { r.uri }) |> set.from_list + fn(uri) { + case set.contains(indexed, uri) { + True -> Some(variant_source.adoption_count(uri)) + False -> None + } + } +} + fn unique_by_uri(rows: List(ProposalRow)) -> List(ProposalRow) { rows |> list.fold(#(set.new(), []), fn(acc, row) { @@ -142,6 +162,7 @@ fn proposals_for( deps: Deps, client: Client, session: OauthSession, + adoption_count: fn(String) -> Option(Int), owned: OwnRelease, ) -> List(ProposalRow) { case deps.backlinks(owned.ref.uri, edit_backlink_source) { @@ -160,7 +181,8 @@ fn proposals_for( // (amending your own record never files one), but drop them defensively. |> list.filter(fn(b) { b.did != session.did }) |> list.filter_map(fn(b) { - to_proposal(deps, client, owned, b) |> option.to_result(Nil) + to_proposal(deps, client, adoption_count, owned, b) + |> option.to_result(Nil) }) } } @@ -168,6 +190,7 @@ fn proposals_for( fn to_proposal( deps: Deps, client: Client, + adoption_count: fn(String) -> Option(Int), owned: OwnRelease, backlink: constellation.Backlink, ) -> Option(ProposalRow) { @@ -189,6 +212,7 @@ fn to_proposal( fields: Some(fields), rationale: edit.rationale, created_at: edit.created_at, + subject_adoption_count: adoption_count(owned.ref.uri), )) } } diff --git a/server/src/at_record_server/handlers/edit_inbox.gleam b/server/src/at_record_server/handlers/edit_inbox.gleam index 9f915b7..6459225 100644 --- a/server/src/at_record_server/handlers/edit_inbox.gleam +++ b/server/src/at_record_server/handlers/edit_inbox.gleam @@ -39,7 +39,14 @@ pub fn list_proposals(req: Request, ctx: Context) -> Response { use id, session <- require_session(req, ctx) use client, session <- with_pds_client(ctx, id, session) let own = edit_inbox.own_current_releases(client, session) - let proposals = edit_inbox.list_proposals(ctx.catalog, client, session, own) + let proposals = + edit_inbox.list_proposals( + ctx.catalog, + client, + session, + own, + ctx.variant_source, + ) let query = wisp.get_query(req) let cursor = list.key_find(query, "cursor") |> option.from_result let limit = diff --git a/server/test/edit_inbox_test.gleam b/server/test/edit_inbox_test.gleam index fc97280..a2e1b56 100644 --- a/server/test/edit_inbox_test.gleam +++ b/server/test/edit_inbox_test.gleam @@ -2,6 +2,8 @@ import at_record/gen/catalog/edit as catalog_edit import at_record/gen/catalog/list_edit_proposals.{ProposalRow} import at_record/gen/catalog/release as catalog_release import at_record/gen/defs.{CatalogRef} +import at_record_server/catalog/row.{type BrowseRow, BrowseRow} +import at_record_server/catalog/source as catalog_source import at_record_server/catalog_deps.{type Deps, Deps} import at_record_server/edit_inbox.{type OwnRelease, OwnRelease} import at_record_server/oauth/sessions.{type OauthSession} @@ -111,6 +113,43 @@ fn backlink(did: String, n: Int) -> Backlink { ) } +/// A minimal `BrowseRow` fixture: only `uri` varies, everything else is a +/// placeholder since these tests only exercise index membership. +fn indexed_row(uri: String) -> BrowseRow { + BrowseRow( + uri:, + cid: "bafyindexed", + title: "Title", + artist_display: None, + genres: [], + styles: [], + released: None, + country: None, + cover: None, + thumb_url: None, + discogs_id: None, + created_at: "2026-01-01T00:00:00Z", + publisher_did: "did:plc:pub", + publisher_handle: "pub.test", + publisher_pds: "https://pds.test", + supersedes: None, + based_on: None, + ) +} + +/// A `Source` whose `releases` reports exactly `uris` as indexed, so a +/// `subject_adoption_count` lookup against one of them falls through to +/// `count`. +fn indexed_source( + uris: List(String), + count: fn(String) -> Int, +) -> catalog_source.Source { + catalog_source.Source( + releases: fn() { list.map(uris, indexed_row) }, + adoption_count: count, + ) +} + /// Deps whose `backlinks` returns one backlink per own release (via `n`) and /// whose `fetch_edit` looks the matching edit record up from `edits`. fn deps_with( @@ -159,7 +198,13 @@ pub fn own_authored_proposal_is_excluded_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) assert rows == [] } @@ -177,7 +222,13 @@ pub fn foreign_release_entity_proposal_is_included_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) let assert [ ProposalRow( proposer_did:, @@ -204,6 +255,56 @@ pub fn foreign_release_entity_proposal_is_included_test() { )) } +pub fn subject_adoption_count_is_populated_when_the_target_release_is_indexed_test() { + let owned = own_release(1, None) + let deps = + deps_with(fn(_subject) { [backlink(other_did, 1)] }, fn(_uri) { + Some(#( + "bafyedit1", + edit_record( + "release", + owned.ref.uri, + Some(release_fields("Better Title")), + ), + )) + }) + let rows = + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + indexed_source([owned.ref.uri], fn(_) { 3 }), + ) + let assert [ProposalRow(subject_adoption_count:, ..)] = rows + assert subject_adoption_count == Some(3) +} + +pub fn subject_adoption_count_is_omitted_when_the_target_release_is_not_indexed_test() { + let owned = own_release(1, None) + let deps = + deps_with(fn(_subject) { [backlink(other_did, 1)] }, fn(_uri) { + Some(#( + "bafyedit1", + edit_record( + "release", + owned.ref.uri, + Some(release_fields("Better Title")), + ), + )) + }) + let rows = + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) + let assert [ProposalRow(subject_adoption_count:, ..)] = rows + assert subject_adoption_count == None +} + pub fn proposer_handle_resolution_failure_degrades_silently_test() { let owned = own_release(1, None) let deps = @@ -218,7 +319,13 @@ pub fn proposer_handle_resolution_failure_degrades_silently_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) let assert [ProposalRow(proposer_handle:, ..)] = rows assert proposer_handle == None } @@ -233,7 +340,13 @@ pub fn non_release_entity_proposal_is_dropped_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) assert rows == [] } @@ -251,7 +364,13 @@ pub fn proposal_targeting_a_different_release_is_dropped_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) assert rows == [] } @@ -265,7 +384,13 @@ pub fn a_failed_backlinks_lookup_skips_that_release_rather_than_failing_test() { release_mbid: fn(_, _) { panic as "release_mbid must not be called" }, ) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) assert rows == [] } @@ -301,7 +426,13 @@ pub fn release_fanout_is_capped_newest_first_test() { }, ) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), owns) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + owns, + support.empty_variant_source(), + ) assert list.length(rows) == edit_inbox.release_fanout_cap } @@ -317,6 +448,12 @@ pub fn merged_proposal_cap_applies_across_a_single_release_test() { )) }) let rows = - edit_inbox.list_proposals(deps, no_network_client(), session(), [owned]) + edit_inbox.list_proposals( + deps, + no_network_client(), + session(), + [owned], + support.empty_variant_source(), + ) assert list.length(rows) == edit_inbox.proposal_cap } diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index ec358d6..7d90abf 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -710,6 +710,7 @@ fn edit_proposal_decoder() -> decode.Decoder(EditProposal) { current: proposal_fields_or_blank(row.current), fields: proposal_fields_or_blank(row.fields), created_at: row.created_at, + subject_adoption_count: row.subject_adoption_count, ) }) } diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 294d9ac..8128706 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -450,6 +450,9 @@ pub type EditProposal { current: ProposalFields, fields: ProposalFields, created_at: String, + /// How many crates have adopted the target release; best-effort, absent + /// when the server hasn't indexed it yet. + subject_adoption_count: Option(Int), ) } diff --git a/web/src/at_record_web/pages/edit_proposal.gleam b/web/src/at_record_web/pages/edit_proposal.gleam index 527aec1..a0558c4 100644 --- a/web/src/at_record_web/pages/edit_proposal.gleam +++ b/web/src/at_record_web/pages/edit_proposal.gleam @@ -8,6 +8,7 @@ import at_record_web/model.{ } import at_record_web/msg.{type Msg, ApplyProposal, IgnoreProposal} import at_record_web/pages/edit_inbox +import at_record_web/plural import at_record_web/route import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov @@ -195,7 +196,7 @@ fn old_value(old: Option(String)) -> Element(Msg) { fn version_context_panel(p: EditProposal) -> Element(Msg) { html.div([attr.class("version-context")], [ - version_row("YOURS", ctl.Owned, "current version"), + version_row("YOURS", ctl.Owned, yours_caption(p.subject_adoption_count)), version_row( "THEIRS", ctl.Wanted, @@ -204,6 +205,13 @@ fn version_context_panel(p: EditProposal) -> Element(Msg) { ]) } +fn yours_caption(subject_adoption_count: Option(Int)) -> String { + case subject_adoption_count { + Some(count) -> "current version · in " <> plural.count_noun(count, "crate") + None -> "current version" + } +} + fn version_row( label: String, variant: ctl.Variant, diff --git a/web/test/edit_inbox_test.gleam b/web/test/edit_inbox_test.gleam index 36f5c7b..d923212 100644 --- a/web/test/edit_inbox_test.gleam +++ b/web/test/edit_inbox_test.gleam @@ -39,6 +39,7 @@ fn an_edit_proposal(uri: String) -> EditProposal { styles: None, ), created_at: "2026-02-01T00:00:00Z", + subject_adoption_count: None, ) } diff --git a/web/test/edit_proposal_test.gleam b/web/test/edit_proposal_test.gleam index 093f835..a4aa359 100644 --- a/web/test/edit_proposal_test.gleam +++ b/web/test/edit_proposal_test.gleam @@ -30,6 +30,7 @@ fn an_edit_proposal(uri: String) -> EditProposal { styles: None, ), created_at: "2026-02-01T00:00:00Z", + subject_adoption_count: None, ) } @@ -110,6 +111,38 @@ pub fn view_renders_the_version_context_panel_test() { assert string.contains(html, "superseding version · minted 2026-02-01") } +pub fn yours_caption_shows_the_adoption_count_when_present_test() { + let proposal = + EditProposal( + ..an_edit_proposal("at://proposer/edit/1"), + subject_adoption_count: Some(4), + ) + let html = rendered(proposal) + assert string.contains(html, "current version · in 4 crates") +} + +pub fn yours_caption_uses_the_singular_for_one_crate_test() { + let proposal = + EditProposal( + ..an_edit_proposal("at://proposer/edit/1"), + subject_adoption_count: Some(1), + ) + let html = rendered(proposal) + assert string.contains(html, "current version · in 1 crate") + assert !string.contains(html, "1 crates") +} + +pub fn yours_caption_omits_the_adoption_count_when_absent_test() { + let proposal = + EditProposal( + ..an_edit_proposal("at://proposer/edit/1"), + subject_adoption_count: None, + ) + let html = rendered(proposal) + assert string.contains(html, "current version") + assert !string.contains(html, "crates") +} + pub fn view_wires_the_adopt_and_ignore_actions_test() { let html = rendered(an_edit_proposal("at://proposer/edit/1")) assert string.contains(html, "ADOPT THIS VERSION") diff --git a/web/test/prefs_test.gleam b/web/test/prefs_test.gleam index b8ea882..3614f4a 100644 --- a/web/test/prefs_test.gleam +++ b/web/test/prefs_test.gleam @@ -36,6 +36,7 @@ fn a_proposal(uri: String, cid: String) -> EditProposal { styles: None, ), created_at: "2026-02-01T00:00:00Z", + subject_adoption_count: None, ) }