mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-09 18:37:02 +00:00
Add lightning invoice payments
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
# Current
|
||||
|
||||
* Switch back to indexeddb to fix memory and performance
|
||||
* Add pay invoice functionality
|
||||
|
||||
# 1.5.3
|
||||
|
||||
* Add space edit form
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@capacitor/push-notifications": "^7.0.3",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||
"@capawesome/capacitor-badge": "^7.0.1",
|
||||
"@getalby/lightning-tools": "^6.0.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
111
src/app/components/WalletPay.svelte
Normal file
111
src/app/components/WalletPay.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {session} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Scanner from "@lib/components/Scanner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {payInvoice} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onScan = debounce(1000, async (data: string) => {
|
||||
invoice = new Invoice({pr: data})
|
||||
sats = invoice.satoshi || 0
|
||||
})
|
||||
|
||||
const confirm = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await payInvoice(invoice!.paymentRequest, sats * 1000)
|
||||
|
||||
pushToast({message: `Payment sent!`})
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
const message = String(e).replace(/^.*Error: /, "")
|
||||
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to send payment: ${message}`,
|
||||
})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
let invoice: Invoice | undefined = $state()
|
||||
let sats = $state(0)
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Pay with Lightning</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
Use your Nostr wallet to send Bitcoin payments over lightning.
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#if invoice}
|
||||
<div class="card2 bg-alt flex flex-col gap-2">
|
||||
{#if $session?.wallet?.type === "webln" && invoice.satoshi === 0}
|
||||
<p class="text-sm opacity-75">
|
||||
Uh oh! It looks like your current wallet doesn't support invoices without an amount. See
|
||||
if you can get a lightning invoice with a pre-set amount.
|
||||
</p>
|
||||
{:else}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Amount (satoshis)
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow justify-end">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input
|
||||
bind:value={sats}
|
||||
type="number"
|
||||
class="w-14"
|
||||
disabled={invoice!.satoshi > 0} />
|
||||
</label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<p class="text-sm opacity-75">
|
||||
You're about to pay a bitcoin lightning invoice with the following description:
|
||||
<strong>{invoice.description || "[no description]"}</strong>"
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Scanner onscan={onScan} />
|
||||
<p class="text-center text-sm opacity-75">
|
||||
To make a payment, scan a lightning invoice with your camera.
|
||||
</p>
|
||||
{/if}
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="btn btn-primary" onclick={confirm} disabled={!invoice || sats === 0 || loading}>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon={Bolt} />
|
||||
{/if}
|
||||
Confirm Payment
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
@@ -592,7 +592,7 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
||||
|
||||
export const getWebLn = () => (window as any).webln
|
||||
|
||||
export const payInvoice = async (invoice: string) => {
|
||||
export const payInvoice = async (invoice: string, msats?: number) => {
|
||||
const $session = session.get()
|
||||
|
||||
if (!$session?.wallet) {
|
||||
@@ -600,8 +600,11 @@ export const payInvoice = async (invoice: string) => {
|
||||
}
|
||||
|
||||
if ($session.wallet.type === "nwc") {
|
||||
return new nwc.NWCClient($session.wallet.info).payInvoice({invoice})
|
||||
const params: {invoice: string; amount?: number} = {invoice}
|
||||
if (msats) params.amount = msats
|
||||
return new nwc.NWCClient($session.wallet.info).payInvoice(params)
|
||||
} else if ($session.wallet.type === "webln") {
|
||||
if (msats) throw new Error("Unable to pay zero invoices with webln")
|
||||
return getWebLn()
|
||||
.enable()
|
||||
.then(() => getWebLn().sendPayment(invoice))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {reject, call, identity} from "@welshman/lib"
|
||||
import {call} from "@welshman/lib"
|
||||
import {Preferences} from "@capacitor/preferences"
|
||||
import {Encoding, Filesystem, Directory} from "@capacitor/filesystem"
|
||||
import {Filesystem, Directory} from "@capacitor/filesystem"
|
||||
import {IDB} from "@lib/indexeddb"
|
||||
|
||||
export const kv = call(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib"
|
||||
import {on, throttle, fromPairs, batch} from "@welshman/lib"
|
||||
import {throttled, freshness} from "@welshman/store"
|
||||
import {
|
||||
ALERT_ANDROID,
|
||||
|
||||
@@ -5,11 +5,38 @@
|
||||
|
||||
const {onscan} = $props()
|
||||
|
||||
const changeCamera = async () => {
|
||||
if (camera && scanner) {
|
||||
loading = true
|
||||
try {
|
||||
await scanner.setCamera(camera)
|
||||
} catch (error) {
|
||||
console.error("Failed to switch camera:", error)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let video: HTMLVideoElement
|
||||
let scanner: QrScanner
|
||||
let loading = $state(true)
|
||||
let cameras = $state<QrScanner.Camera[]>([])
|
||||
let camera = $state<string>("")
|
||||
|
||||
onMount(() => {
|
||||
QrScanner.listCameras(true)
|
||||
.then(async () => {
|
||||
cameras = await QrScanner.listCameras(true)
|
||||
|
||||
if (cameras.length > 0) {
|
||||
camera = cameras[0].id
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to list cameras:", error)
|
||||
})
|
||||
|
||||
scanner = new QrScanner(video, r => onscan(r.data), {
|
||||
returnDetailedScanResult: true,
|
||||
})
|
||||
@@ -22,11 +49,21 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="bg-alt flex min-h-48 w-full flex-col items-center justify-center rounded p-px">
|
||||
<div class="bg-alt relative flex min-h-48 w-full flex-col items-center justify-center rounded p-px">
|
||||
{#if loading}
|
||||
<p class="py-20">
|
||||
<Spinner loading>Loading your camera...</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
<video class="m-auto rounded" class:h-0={loading} bind:this={video}></video>
|
||||
{#if cameras.length > 1}
|
||||
<select
|
||||
class="select select-bordered select-sm absolute bottom-1 right-1"
|
||||
bind:value={camera}
|
||||
onchange={changeCamera}>
|
||||
{#each cameras as camera}
|
||||
<option value={camera.id}>{camera.label || `Camera ${camera.id}`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {openDB, deleteDB} from "idb"
|
||||
import type {IDBPDatabase} from "idb"
|
||||
import {writable} from "svelte/store"
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {call, defer} from "@welshman/lib"
|
||||
import {call} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {withGetter} from "@welshman/store"
|
||||
|
||||
export type IDBAdapter = {
|
||||
name: string
|
||||
@@ -83,11 +81,11 @@ export class IDB {
|
||||
// If we're closing, ignore any lingering requests
|
||||
if ([IDBStatus.Closed, IDBStatus.Closing].includes(this.status)) return
|
||||
|
||||
return f(await this.idbp)
|
||||
return f(await this.idbp!)
|
||||
}
|
||||
|
||||
getAll = async <T>(table: string): Promise<T[]> =>
|
||||
this._withIDBP(async idbp => {
|
||||
getAll = async <T>(table: string): Promise<T[]> => {
|
||||
const result = await this._withIDBP(async idbp => {
|
||||
const tx = idbp.transaction(table, "readwrite")
|
||||
const store = tx.objectStore(table)
|
||||
const result = await store.getAll()
|
||||
@@ -97,6 +95,9 @@ export class IDB {
|
||||
return result
|
||||
})
|
||||
|
||||
return result || []
|
||||
}
|
||||
|
||||
bulkPut = async <T>(table: string, data: Iterable<T>) =>
|
||||
this._withIDBP(async idbp => {
|
||||
const tx = idbp.transaction(table, "readwrite")
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import {LOCALE} from "@welshman/lib"
|
||||
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
||||
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import WalletPay from "@app/components/WalletPay.svelte"
|
||||
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
||||
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
||||
@@ -27,6 +29,8 @@
|
||||
const walletLud16 = $derived(
|
||||
$session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined,
|
||||
)
|
||||
|
||||
const pay = () => pushModal(WalletPay)
|
||||
</script>
|
||||
|
||||
<div class="content column gap-4">
|
||||
@@ -118,4 +122,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-center py-12">
|
||||
<Button class="btn btn-primary" onclick={pay}>
|
||||
<Icon icon={Bolt} />
|
||||
Pay With Lightning
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,15 @@ config({path: ".env.template"})
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 1847,
|
||||
// host: "0.0.0.0",
|
||||
// strictPort: true,
|
||||
// allowedHosts: ["coracle-client.ngrok.io"],
|
||||
// hmr: {
|
||||
// protocol: "wss",
|
||||
// host: "coracle-client.ngrok.io",
|
||||
// clientPort: 443,
|
||||
// },
|
||||
// cors: true,
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
|
||||
Reference in New Issue
Block a user