Make zaps smoother with bitcoin-connect

This commit is contained in:
Jon Staab 2024-05-22 14:46:33 -07:00
parent 92fd4fd8b1
commit 6c45b2fba0
9 changed files with 104 additions and 242 deletions

View File

@ -1,5 +1,10 @@
# Changelog
# 0.4.6
- [x] Improve search results loading indicator
- [x] Make zaps prettier with bitcoin-connect
# 0.4.5
- [x] Accept npubs in people input

View File

@ -61,7 +61,7 @@
<div
id="page"
class={cx("relative pb-32 text-neutral-100 lg:pt-16", {
"lg:ml-60": !isFeedPage,
"lg:ml-72": !isFeedPage,
"lg:ml-[33rem]": isFeedPage,
"pointer-events-none": $menuIsOpen,
})}>

View File

@ -8,7 +8,6 @@
import ImageInput from "src/partials/ImageInput.svelte"
import AltColor from "src/partials/AltColor.svelte"
import Chip from "src/partials/Chip.svelte"
import Card from "src/partials/Card.svelte"
import Compose from "src/app/shared/Compose.svelte"
import NsecWarning from "src/app/shared/NsecWarning.svelte"
import NoteOptions from "src/app/shared/NoteOptions.svelte"

View File

@ -1,61 +0,0 @@
<script lang="ts">
import QRCode from "src/partials/QRCode.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Input from "src/partials/Input.svelte"
import {collectInvoice, displayPubkey} from "src/engine"
export let zap
let attemptingToPay = false
const collect = async () => {
attemptingToPay = true
await collectInvoice(zap.invoice)
}
</script>
<div class="flex flex-col gap-2">
{#if zap.invoice}
<div class="flex items-center justify-center gap-2">
{#if zap.status === "pending"}
<i class="fa fa-circle-notch fa-spin" />
{#if zap.isTip}
Tipping
{:else}
Paying
{/if}
{:else if zap.status === "success"}
<i class="fa fa-check text-success" /> Paid
{/if}
{zap.amount} sats to {displayPubkey(zap.pubkey)}
</div>
<QRCode code={zap.invoice} onClick={collect}>
<div slot="below" let:copy class="flex gap-1">
<Input value={zap.invoice} class="flex-grow">
<button slot="after" class="fa fa-copy" on:click={copy} />
</Input>
{#if zap.status === "pending"}
<Anchor button accent on:click={collect} disabled={attemptingToPay} class="w-24">
{#if attemptingToPay}
<i class="fa fa-circle-notch fa-spin" />
{:else}
Pay
{/if}
</Anchor>
{:else}
<Anchor button accent disabled>Paid!</Anchor>
{/if}
</div>
</QRCode>
{:else if zap.status === "error:zapper"}
<div class="flex aspect-square items-center justify-center">
<p>Failed to find a zap provider for {displayPubkey(zap.pubkey)}.</p>
</div>
{:else}
<div class="flex aspect-square items-center justify-center">
<Spinner />
</div>
{/if}
</div>

View File

@ -1,46 +1,32 @@
<script context="module" lang="ts">
type Zap = {
relay: string
pubkey: string
amount: number
status: string
relays?: string[]
invoice?: string
isTip?: boolean
}
</script>
<script lang="ts">
import {sortBy, uniqBy, uniq, whereEq, filter, map, reject} from "ramda"
import {doPipe} from "hurdak"
import {onDestroy} from "svelte"
import {init, launchPaymentModal, onModalClosed} from "@getalby/bitcoin-connect"
import {sortBy, uniqBy, filter, map, reject} from "ramda"
import {doPipe, Fetch} from "hurdak"
import {now} from "@welshman/lib"
import {Tags} from "@welshman/util"
import {createEvent} from "@welshman/util"
import {generatePrivateKey} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
import Toggle from "src/partials/Toggle.svelte"
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import ZapInvoice from "src/app/shared/ZapInvoice.svelte"
import {router} from "src/app/util/router"
import {env, subscribe, hints, getSetting, derivePerson, requestZap} from "src/engine"
import {env, load, hints, signer, getSetting, derivePerson} from "src/engine"
export let splits
export let eid = null
export let anonymous = false
export let callback = null
let sub
let closing = false
let stage = "message"
let message = ""
let zaps: Zap[] = []
let loading = false
let zaps = []
let totalWeight = 0
let totalAmount = getSetting("default_zap")
$: {
totalWeight = 0
zaps = doPipe<Zap[]>(splits, [
zaps = doPipe(splits, [
reject((s: string[]) => s.length < 4 || s[1].length !== 64 || !s[3].match(/\d+(\.\d+)?$/)),
map((s: string[]) => [...s.slice(1, 3), parseFloat(s[3])]),
filter((s: any[]) => s[2] && s[2] > 0),
@ -56,8 +42,8 @@
amount: Math.round(totalAmount * (parseFloat(weight) / totalWeight)),
status: "pending",
})),
sortBy((split: Zap) => -split.amount),
(zaps: Zap[]) => {
sortBy((split: any) => -split.amount),
(zaps: any[]) => {
const percent = getSetting("platform_zap_split")
// Add our platform split on top as a "tip"
@ -71,82 +57,87 @@
})
}
return zaps
},
])
}
const next = async () => {
stage = "invoice"
zaps.forEach(async (zap, i) => {
const msg = i === 0 ? message : ""
// Add our zapper and relay hints
return zaps.map((zap, i) => {
const content = i === 0 ? message : ""
const {zapper} = derivePerson(zap.pubkey).get()
if (!zapper?.lnurl) {
zaps[i].status = "error:zapper"
return
}
const relays = hints
.merge([hints.PublishMessage(zap.pubkey), hints.fromRelays([zap.relay])])
.getUrls()
zaps[i].relays = relays
zaps[i].invoice = await requestZap(msg, zap.amount, {
eid,
relays,
anonymous,
lnurl: zapper.lnurl,
pubkey: zap.pubkey,
return {...zap, zapper, relays, content}
})
})
const successfulZaps = zaps.filter(z => !z.status.startsWith("error"))
const allRelays = uniq(successfulZaps.flatMap(z => z.relays))
const authors = successfulZaps.map(z => derivePerson(z.pubkey).get().zapper.nostrPubkey)
const mentions = successfulZaps.map(z => z.pubkey)
sub = subscribe({
relays: allRelays,
filters: [{kinds: [9735], authors, "#p": mentions, since: now() - 30}],
onEvent: e => {
zaps = zaps.map(z => {
if (z.invoice === Tags.fromEvent(e).get("bolt11")?.value()) {
z.status = "success"
}
return z
})
callback?.(e)
},
})
filter((zap: any) => zap.zapper?.lnurl),
// Request our invoice
map(async (zap: any) => {
const {amount, zapper, relays, content, pubkey} = zap
const msats = amount * 1000
const tags = [
["relays", ...relays],
["amount", msats.toString()],
["lnurl", zapper.lnurl],
["p", pubkey],
]
if (eid) {
tags.push(["e", eid])
}
const prev = () => {
zaps = zaps.map(zap => ({...zap, invoice: null, status: "pending"}))
stage = "message"
if (anonymous) {
tags.push(["anon"])
}
const done = () => {
const template = createEvent(9734, {content, tags})
const signedTemplate = anonymous
? await signer.get().signWithKey(template, generatePrivateKey())
: await signer.get().signAsUser(template)
const zapString = encodeURI(JSON.stringify(signedTemplate))
const qs = `?amount=${msats}&nostr=${zapString}&lnurl=${zapper.lnurl}`
const res = await tryCath(() => Fetch.fetchJson(zapper.callback + qs))
return {...zap, invoice: res?.pr}
}),
])
}
const confirmZap = async () => {
// Show loading immediately
loading = true
const since = now() - 30
const preppedZaps = await Promise.all(zaps)
// Close the router once we can show the next modal
router.pop()
for (const {invoice, relays, zapper, pubkey} of preppedZaps) {
if (!invoice) {
continue
}
$: {
if (!closing && zaps.length > 0 && zaps.every(whereEq({status: "success"}))) {
closing = true
done()
}
}
launchPaymentModal({invoice})
onDestroy(() => {
sub?.close()
await new Promise<void>(resolve => {
const unsub = onModalClosed(() => {
resolve()
unsub()
})
})
load({
relays,
onEvent: callback,
filters: [{kinds: [9735], authors: [zapper.nostrPubkey], "#p": [pubkey], since}],
})
}
}
// Initialize bitcoin connect
init({appName: import.meta.env.VITE_APP_NAME})
</script>
{#if zaps.length > 0}
{#if stage === "message"}
<h1 class="staatliches text-2xl">Send a zap</h1>
<Textarea bind:value={message} placeholder="Send a message with your zap (optional)" />
<Input bind:value={totalAmount}>
@ -161,24 +152,5 @@
<Toggle bind:value={anonymous} />
</div>
</FieldInline>
<Anchor button accent on:click={next}>Zap!</Anchor>
{:else}
{@const partitionAt = Math.ceil(zaps.length * 0.3)}
<div class="grid gap-8" class:sm:grid-cols-5={zaps.length > 1}>
<div class="flex flex-col gap-8" class:sm:col-span-3={zaps.length > 1}>
{#each zaps.slice(0, partitionAt) as zap (zap.pubkey)}
<ZapInvoice {zap} />
{/each}
</div>
<div class="flex flex-col gap-8" class:sm:col-span-2={zaps.length > 1}>
{#each zaps.slice(partitionAt) as zap (zap.pubkey)}
<ZapInvoice {zap} />
{/each}
</div>
</div>
<div class="flex gap-2">
<Anchor button on:click={prev}><i class="fa fa-arrow-left" /> Back</Anchor>
<Anchor button accent class="flex-grow" on:click={done}>Done zapping</Anchor>
</div>
{/if}
<Anchor button accent {loading} on:click={confirmZap}>Zap!</Anchor>
{/if}

View File

@ -1,5 +1,4 @@
import crypto from "crypto"
import * as bc from "@getalby/bitcoin-connect"
import {cached, nth, groupBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
@ -30,7 +29,6 @@ import {
without,
} from "ramda"
import {stripExifData, blobToFile} from "src/util/html"
import {warn} from "src/util/logger"
import {joinPath} from "src/util/misc"
import {appDataKeys, generatePrivateKey, getPublicKey} from "src/util/nostr"
import {makeFollowList, editFollowList, createFollowList, readFollowList} from "src/domain"
@ -53,7 +51,6 @@ import {
fetchHandle,
forcePlatformRelays,
getClientTags,
getZapperForPubkey,
groupAdminKeys,
groupSharedKeys,
groups,
@ -300,56 +297,6 @@ export const publishReview = (content, tags, relays = null) =>
relays,
})
// Zaps
export const requestZap = async (
content,
amount,
{pubkey, relays, eid = null, lnurl = null, anonymous = false},
) => {
const zapper = await getZapperForPubkey(pubkey, lnurl)
if (!zapper) {
throw new Error("Can't zap without a zapper")
}
const msats = amount * 1000
const tags = [
...getClientTags(),
["relays", ...relays],
["amount", msats.toString()],
["lnurl", zapper.lnurl],
["p", pubkey],
]
if (eid) {
tags.push(["e", eid])
}
if (anonymous) {
tags.push(["anon"])
}
const template = createEvent(9734, {content, tags})
const zap = anonymous
? await signer.get().signWithKey(template, generatePrivateKey())
: await signer.get().signAsUser(template)
const zapString = encodeURI(JSON.stringify(zap))
const qs = `?amount=${msats}&nostr=${zapString}&lnurl=${zapper.lnurl}`
const res = await Fetch.fetchJson(zapper.callback + qs)
if (!res.pr) {
warn(JSON.stringify(res))
}
return res?.pr
}
// Initialize bitcoin connect
bc.init({appName: import.meta.env.VITE_APP_NAME})
export const collectInvoice = invoice => bc.launchPaymentModal({invoice})
// Groups
// Key state management

View File

@ -1221,14 +1221,6 @@ export const getZapper = cached({
getValue: ([lnurl]) => fetchZapper(lnurl),
})
export const getZapperForPubkey = async (pubkey, lnurl = null) => {
const zapper = people.key(pubkey).get()?.zapper
// Allow the caller to specify a lnurl override, but don't fetch
// if we already know it
return zapper?.lnurl === lnurl ? zapper : await getZapper(lnurl)
}
// Network
export const addRepostFilters = (filters: Filter[]) =>

View File

@ -67,14 +67,23 @@
{#if tag === "a"}
<a {style} class={className} on:click={onClick} href={externalHref || href} {target}>
{#if loading}
<i class="fa fa-circle-notch fa-spin fa-sm" />
{/if}
<slot />
</a>
{:else if tag === "button"}
<button {style} class={className} on:click={onClick} {type}>
{#if loading}
<i class="fa fa-circle-notch fa-spin fa-sm" />
{/if}
<slot />
</button>
{:else}
<svelte:element this={tag} {style} class={className} on:click={onClick}>
{#if loading}
<i class="fa fa-circle-notch fa-spin fa-sm" />
{/if}
<slot />
</svelte:element>
{/if}

View File

@ -1,6 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {identity} from "ramda"
export let initialValue: string | number = ""
export let value = initialValue
@ -17,7 +16,7 @@
const onInput = e => {
if (parse) {
value = parse(e.target.value)
} else if (['range', 'number'].includes($$props.type)) {
} else if (["range", "number"].includes($$props.type)) {
value = parseFloat(e.target.value)
} else {
value = e.target.value