- 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 <noreply@anthropic.com>
403 lines
17 KiB
Vue
403 lines
17 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<div class="share-modal-backdrop" @click.self="$emit('close')">
|
|
<div class="share-modal glass-card">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
|
|
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
|
|
</div>
|
|
</div>
|
|
<button class="share-modal-close" @click="$emit('close')">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Share Toggle -->
|
|
<div class="share-modal-row">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
|
|
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
|
|
</div>
|
|
<ToggleSwitch v-model="shared" />
|
|
</div>
|
|
|
|
<!-- Access Type (only when shared) -->
|
|
<div v-if="shared" class="mt-4 space-y-3">
|
|
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
|
|
<div class="share-access-options">
|
|
<button
|
|
class="share-access-option"
|
|
:class="{ 'share-access-option-active': accessType === 'free' }"
|
|
@click="accessType = 'free'"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span class="text-sm font-medium">Free</span>
|
|
<span class="text-xs text-white/40">Open access</span>
|
|
</button>
|
|
<button
|
|
class="share-access-option"
|
|
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
|
|
@click="accessType = 'peers_only'"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
</svg>
|
|
<span class="text-sm font-medium">Peers Only</span>
|
|
<span class="text-xs text-white/40">Authenticated</span>
|
|
</button>
|
|
<button
|
|
class="share-access-option"
|
|
:class="{ 'share-access-option-active': accessType === 'paid' }"
|
|
@click="accessType = 'paid'"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
<span class="text-sm font-medium">Paid</span>
|
|
<span class="text-xs text-white/40">Earn sats</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Price Input (only for paid) -->
|
|
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
|
|
<div class="share-price-icon">
|
|
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<input
|
|
v-model.number="priceSats"
|
|
type="number"
|
|
min="1"
|
|
max="1000000"
|
|
placeholder="Price in sats"
|
|
class="share-price-input"
|
|
/>
|
|
<span class="share-price-unit">sats</span>
|
|
</div>
|
|
|
|
<!-- Accepted payment methods (only for paid) — gated on what this
|
|
node can actually receive; unavailable rails are disabled with
|
|
an ⓘ that explains how to enable them. -->
|
|
<div v-if="accessType === 'paid'" class="mt-3">
|
|
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
|
|
<div class="space-y-2">
|
|
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
|
|
<div class="flex-1 flex items-center gap-2 min-w-0">
|
|
<p class="text-sm text-white/90">{{ m.label }}</p>
|
|
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking…</span>
|
|
<button
|
|
v-else-if="!capability[m.key]"
|
|
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
|
|
title="Why is this unavailable?"
|
|
@click="adviceFor = m.key"
|
|
>i</button>
|
|
</div>
|
|
<ToggleSwitch
|
|
:model-value="acceptedSet.has(m.key)"
|
|
:disabled="!capability[m.key]"
|
|
:aria-label="`Accept ${m.label}`"
|
|
@update:model-value="toggleMethod(m.key, $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
|
|
Pick at least one payment method buyers can use.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advice modal for an unavailable payment method -->
|
|
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
|
|
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
|
|
</div>
|
|
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
|
|
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Status messages -->
|
|
<div v-if="saving" class="share-modal-status mt-4">
|
|
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
|
<span class="text-sm text-white/60">Saving...</span>
|
|
</div>
|
|
<div v-if="errorMsg" class="share-modal-error mt-4">
|
|
<span class="text-sm text-red-400">{{ errorMsg }}</span>
|
|
</div>
|
|
<div v-if="successMsg" class="share-modal-success mt-4">
|
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span class="text-sm text-green-400">{{ successMsg }}</span>
|
|
</div>
|
|
|
|
<!-- Action -->
|
|
<div class="flex justify-end gap-3 mt-5">
|
|
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
|
<button
|
|
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
|
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
|
|
@click="save"
|
|
>
|
|
{{ shared ? 'Share' : 'Stop Sharing' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
|
|
|
const props = defineProps<{
|
|
filename: string
|
|
filepath: string
|
|
isDir: boolean
|
|
existingItemId?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
saved: []
|
|
}>()
|
|
|
|
const shared = ref(false)
|
|
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
|
|
const priceSats = ref<number>(100)
|
|
const saving = ref(false)
|
|
const errorMsg = ref<string | null>(null)
|
|
const successMsg = ref<string | null>(null)
|
|
|
|
// --- Accepted payment methods, gated on what this node can actually receive ---
|
|
|
|
const PAY_METHODS = [
|
|
{ key: 'lightning', label: 'Lightning' },
|
|
{ key: 'onchain', label: 'On-chain' },
|
|
{ key: 'ecash', label: 'Cashu ecash' },
|
|
{ key: 'fedimint', label: 'Fedimint' },
|
|
] as const
|
|
type PayMethod = (typeof PAY_METHODS)[number]['key']
|
|
|
|
// undefined = probe in flight; then true/false per method.
|
|
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
|
|
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
|
|
const acceptedSet = ref<Set<PayMethod>>(new Set())
|
|
const adviceFor = ref<PayMethod | null>(null)
|
|
// Only default-select capable methods when the item had no saved list.
|
|
let acceptedLoadedFromItem = false
|
|
|
|
function toggleMethod(key: PayMethod, on: boolean) {
|
|
const next = new Set(acceptedSet.value)
|
|
if (on) next.add(key)
|
|
else next.delete(key)
|
|
acceptedSet.value = next
|
|
}
|
|
|
|
/** Probe the node's rails and build advice for the unavailable ones. */
|
|
async function probeCapabilities() {
|
|
// Lightning + on-chain both live on LND.
|
|
try {
|
|
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
|
|
method: 'lnd.getinfo', timeout: 8000,
|
|
})
|
|
capability.value.onchain = true
|
|
const channels = info?.num_active_channels ?? 0
|
|
capability.value.lightning = channels > 0
|
|
if (channels === 0) {
|
|
adviceLines.value.lightning = [
|
|
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
|
|
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
|
|
'Once the channel is active, come back and enable Lightning here.',
|
|
]
|
|
}
|
|
} catch {
|
|
capability.value.lightning = false
|
|
capability.value.onchain = false
|
|
adviceLines.value.lightning = [
|
|
'The Lightning (LND) app isn\'t running on this node.',
|
|
'Install/start Lightning from the App Store, let it sync, then open a channel.',
|
|
]
|
|
adviceLines.value.onchain = [
|
|
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
|
|
'Install/start Lightning from the App Store — no channel needed for on-chain.',
|
|
]
|
|
}
|
|
try {
|
|
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
|
|
capability.value.ecash = true
|
|
} catch {
|
|
capability.value.ecash = false
|
|
adviceLines.value.ecash = [
|
|
'The Cashu ecash wallet isn\'t set up on this node.',
|
|
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
|
|
]
|
|
}
|
|
try {
|
|
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
|
|
capability.value.fedimint = true
|
|
} catch {
|
|
capability.value.fedimint = false
|
|
adviceLines.value.fedimint = [
|
|
'This node hasn\'t joined a Fedimint federation.',
|
|
'Install the Fedimint app and join (or create) a federation, then enable it here.',
|
|
]
|
|
}
|
|
// Defaults: everything the node can receive — unless the item already
|
|
// carried an explicit list. Never auto-enable an incapable rail.
|
|
if (!acceptedLoadedFromItem) {
|
|
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
|
|
} else {
|
|
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
|
|
}
|
|
}
|
|
|
|
// If we have an existing item, load its state
|
|
|
|
/** Catalog entries store the slash-stripped path; props carry a leading
|
|
* slash (filepath) or just the basename (filename). Normalize both sides —
|
|
* the old exact compare never matched, so every re-share created a brand
|
|
* new priced entry and buyers could pay twice for one file (2026-07-22). */
|
|
function matchesThisFile(catalogFilename: string): boolean {
|
|
const strip = (v: string) => v.replace(/^\/+/, '')
|
|
return (
|
|
strip(catalogFilename) === strip(props.filepath || '') ||
|
|
strip(catalogFilename) === strip(props.filename || '')
|
|
)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const res = await rpcClient.call<{ items: Array<{
|
|
id: string
|
|
filename: string
|
|
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
|
|
availability: string | { allpeers?: unknown; nobody?: unknown }
|
|
}> }>({ method: 'content.list-mine' })
|
|
const match = res.items.find(
|
|
(i) => matchesThisFile(i.filename)
|
|
)
|
|
if (match) {
|
|
shared.value = true
|
|
const access = match.access
|
|
if (typeof access === 'string') {
|
|
if (access === 'free') accessType.value = 'free'
|
|
else if (access === 'peersonly') accessType.value = 'peers_only'
|
|
} else if (access && typeof access === 'object') {
|
|
if ('paid' in access && access.paid) {
|
|
accessType.value = 'paid'
|
|
priceSats.value = access.paid.price_sats || 100
|
|
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
|
|
acceptedLoadedFromItem = true
|
|
acceptedSet.value = new Set(
|
|
access.paid.accepted.filter((m): m is PayMethod =>
|
|
PAY_METHODS.some((p) => p.key === m),
|
|
),
|
|
)
|
|
}
|
|
} else if ('peersonly' in access) {
|
|
accessType.value = 'peers_only'
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
|
}
|
|
void probeCapabilities()
|
|
})
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
errorMsg.value = null
|
|
successMsg.value = null
|
|
|
|
try {
|
|
if (!shared.value) {
|
|
// Find and remove from catalog
|
|
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
|
method: 'content.list-mine',
|
|
})
|
|
const match = res.items.find(
|
|
(i) => matchesThisFile(i.filename)
|
|
)
|
|
if (match) {
|
|
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
|
}
|
|
successMsg.value = 'Sharing disabled'
|
|
} else {
|
|
// Check if already in catalog
|
|
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
|
method: 'content.list-mine',
|
|
})
|
|
let itemId = res.items.find(
|
|
(i) => matchesThisFile(i.filename)
|
|
)?.id
|
|
|
|
// Add if not in catalog
|
|
if (!itemId) {
|
|
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
|
|
const mimeMap: Record<string, string> = {
|
|
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
|
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
|
|
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
|
|
}
|
|
const addRes = await rpcClient.call<{ item: { id: string } }>({
|
|
method: 'content.add',
|
|
params: {
|
|
filename: (props.filepath || props.filename).replace(/^\/+/, ''),
|
|
mime_type: mimeMap[ext] || 'application/octet-stream',
|
|
description: '',
|
|
},
|
|
})
|
|
itemId = addRes.item.id
|
|
}
|
|
|
|
// Set pricing
|
|
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
|
if (accessType.value === 'paid') {
|
|
pricingParams.price_sats = priceSats.value
|
|
pricingParams.accepted_methods = [...acceptedSet.value]
|
|
}
|
|
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
|
|
|
// Set availability to all peers
|
|
await rpcClient.call({
|
|
method: 'content.set-availability',
|
|
params: { id: itemId, availability: 'all_peers' },
|
|
})
|
|
|
|
const label =
|
|
accessType.value === 'paid'
|
|
? `Shared for ${priceSats.value} sats`
|
|
: accessType.value === 'peers_only'
|
|
? 'Shared with peers'
|
|
: 'Shared (free)'
|
|
successMsg.value = label
|
|
}
|
|
|
|
setTimeout(() => emit('saved'), 800)
|
|
} catch (e) {
|
|
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|