mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-29 08:21:20 +00:00
Make zaps smoother with bitcoin-connect
This commit is contained in:
parent
92fd4fd8b1
commit
6c45b2fba0
@ -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
|
||||
|
@ -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,
|
||||
})}>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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[]) =>
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user