From f72d4b92acf1f7b9fc62ca1a87cfa2c756766c88 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Jul 2026 16:15:11 -0400 Subject: [PATCH] feat(content): seller-picked payment methods + music always in the bottom bar + video PiP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paid sharing: AccessControl::Paid gains an accepted-methods list (lightning/onchain/ecash/fedimint; empty = all, back-compat). Sellers pick methods in ShareModal, gated on what the node can actually receive (LND running/channel open, ecash wallet, fedimint joined) with an ⓘ that explains exactly how to enable a missing rail. Enforced server-side (the invoice/onchain mints refuse non-accepted methods; the serve gate only honors tokens/hashes for accepted rails) and the buyer's pay modal only offers what the seller accepts. - Purchased music: the two remaining lightbox paths now use the bottom-bar player — the immediate post-ecash-purchase viewer and the Paid Files tab's window.open. - Picture-in-picture buttons on the peer video player and the cloud media lightbox (utils/pip.ts; Chromium/Safari, no-op elsewhere). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/handler/content.rs | 22 ++- core/archipelago/src/api/rpc/content.rs | 19 ++- core/archipelago/src/content_server.rs | 28 +++- .../src/components/cloud/MediaLightbox.vue | 24 ++- neode-ui/src/components/cloud/ShareModal.vue | 138 +++++++++++++++++- neode-ui/src/utils/pip.ts | 19 +++ neode-ui/src/views/Cloud.vue | 9 +- neode-ui/src/views/PeerFiles.vue | 63 ++++++-- 8 files changed, 299 insertions(+), 23 deletions(-) create mode 100644 neode-ui/src/utils/pip.ts diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index 0b06b1db..bdc39ec2 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -188,7 +188,7 @@ impl ApiHandler { } }; let price_sats = match &item.access { - content_server::AccessControl::Paid { price_sats } => *price_sats, + content_server::AccessControl::Paid { price_sats, .. } => *price_sats, _ => { // Not a paid item — no invoice to issue. return Ok(build_response( @@ -198,6 +198,13 @@ impl ApiHandler { )); } }; + if !content_server::method_accepted(&item.access, "lightning") { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#), + )); + } let memo = format!("Archipelago peer file {content_id}"); match self @@ -315,7 +322,18 @@ impl ApiHandler { .unwrap_or_default(); let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { Some(i) => match &i.access { - content_server::AccessControl::Paid { price_sats } => *price_sats, + content_server::AccessControl::Paid { price_sats, .. } => { + if !content_server::method_accepted(&i.access, "onchain") { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from( + r#"{"error":"The seller does not accept on-chain payment for this item"}"#, + ), + )); + } + *price_sats + } _ => { return Ok(build_response( StatusCode::BAD_REQUEST, diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 6d647955..cf7b63ad 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -179,7 +179,24 @@ impl RpcHandler { if price == 0 { return Err(anyhow::anyhow!("Paid content requires price_sats > 0")); } - AccessControl::Paid { price_sats: price } + // Optional list of payment methods the sharer accepts. + // Absent/empty = all methods (backward compatible). + const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"]; + let accepted: Vec = params + .get("accepted_methods") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| m.as_str()) + .filter(|m| KNOWN_METHODS.contains(m)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + AccessControl::Paid { + price_sats: price, + accepted, + } } _ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)), }; diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 3265b2e9..65cf5844 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -51,9 +51,25 @@ pub enum AccessControl { PeersOnly, Paid { price_sats: u64, + /// Payment methods the sharer accepts: "lightning", "onchain", + /// "ecash", "fedimint". Empty = everything — which is also what + /// catalogs written before this field deserialize to. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + accepted: Vec, }, } +/// Does the sharer accept this payment method for the item? Empty list = +/// all methods (pre-field catalogs and "no preference"). +pub fn method_accepted(access: &AccessControl, method: &str) -> bool { + match access { + AccessControl::Paid { accepted, .. } => { + accepted.is_empty() || accepted.iter().any(|m| m == method) + } + _ => true, + } +} + #[derive(Debug, Default, Serialize, Deserialize)] pub struct ContentCatalog { pub items: Vec, @@ -269,20 +285,26 @@ pub async fn serve_content( // Check access control match &item.access { - AccessControl::Paid { price_sats } => { + AccessControl::Paid { price_sats, .. } => { // Two ways to satisfy payment: // (a) a valid ecash token (the local-wallet fast path), or // (b) a Lightning-invoice payment hash this node issued and has // since confirmed settled (the "pay from any wallet" path, #46). + // Each path only counts when the sharer accepts that method. let mut authorized = false; if let Some(token) = payment_token { - if verify_payment_token(data_dir, token, *price_sats).await { + if (method_accepted(&item.access, "ecash") + || method_accepted(&item.access, "fedimint")) + && verify_payment_token(data_dir, token, *price_sats).await + { authorized = true; } } if !authorized { if let Some(hash) = invoice_hash { - if crate::content_invoice::is_paid_for(hash, id).await { + if method_accepted(&item.access, "lightning") + && crate::content_invoice::is_paid_for(hash, id).await + { authorized = true; } } diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index 251cb568..5c282fb7 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -17,11 +17,24 @@

{{ currentItem?.name }}

- +
+ + +
@@ -111,6 +124,7 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue' import type { FileBrowserItem } from '@/api/filebrowser-client' import { getFileCategory } from '@/composables/useFileType' +import { pipSupported, togglePip } from '@/utils/pip' const props = defineProps<{ items: FileBrowserItem[] diff --git a/neode-ui/src/components/cloud/ShareModal.vue b/neode-ui/src/components/cloud/ShareModal.vue index 5af2e80f..34c3d10d 100644 --- a/neode-ui/src/components/cloud/ShareModal.vue +++ b/neode-ui/src/components/cloud/ShareModal.vue @@ -87,6 +87,47 @@ /> + + +
+

Payments you accept

+
+ +
+

+ Pick at least one payment method buyers can use. +

+
+ + + +
+
+

{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node

+ +
+
    +
  • {{ line }}
  • +
@@ -109,7 +150,7 @@ + +