mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-11 11:27:03 +00:00
Add lightning invoice payments
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# Current
|
||||||
|
|
||||||
|
* Switch back to indexeddb to fix memory and performance
|
||||||
|
* Add pay invoice functionality
|
||||||
|
|
||||||
# 1.5.3
|
# 1.5.3
|
||||||
|
|
||||||
* Add space edit form
|
* Add space edit form
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"@capacitor/push-notifications": "^7.0.3",
|
"@capacitor/push-notifications": "^7.0.3",
|
||||||
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||||
"@capawesome/capacitor-badge": "^7.0.1",
|
"@capawesome/capacitor-badge": "^7.0.1",
|
||||||
|
"@getalby/lightning-tools": "^6.0.0",
|
||||||
"@getalby/sdk": "^5.1.2",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sentry/browser": "^8.55.0",
|
"@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 getWebLn = () => (window as any).webln
|
||||||
|
|
||||||
export const payInvoice = async (invoice: string) => {
|
export const payInvoice = async (invoice: string, msats?: number) => {
|
||||||
const $session = session.get()
|
const $session = session.get()
|
||||||
|
|
||||||
if (!$session?.wallet) {
|
if (!$session?.wallet) {
|
||||||
@@ -600,8 +600,11 @@ export const payInvoice = async (invoice: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($session.wallet.type === "nwc") {
|
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") {
|
} else if ($session.wallet.type === "webln") {
|
||||||
|
if (msats) throw new Error("Unable to pay zero invoices with webln")
|
||||||
return getWebLn()
|
return getWebLn()
|
||||||
.enable()
|
.enable()
|
||||||
.then(() => getWebLn().sendPayment(invoice))
|
.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 {Preferences} from "@capacitor/preferences"
|
||||||
import {Encoding, Filesystem, Directory} from "@capacitor/filesystem"
|
import {Filesystem, Directory} from "@capacitor/filesystem"
|
||||||
import {IDB} from "@lib/indexeddb"
|
import {IDB} from "@lib/indexeddb"
|
||||||
|
|
||||||
export const kv = call(() => {
|
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 {throttled, freshness} from "@welshman/store"
|
||||||
import {
|
import {
|
||||||
ALERT_ANDROID,
|
ALERT_ANDROID,
|
||||||
|
|||||||
@@ -5,11 +5,38 @@
|
|||||||
|
|
||||||
const {onscan} = $props()
|
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 video: HTMLVideoElement
|
||||||
let scanner: QrScanner
|
let scanner: QrScanner
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
let cameras = $state<QrScanner.Camera[]>([])
|
||||||
|
let camera = $state<string>("")
|
||||||
|
|
||||||
onMount(() => {
|
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), {
|
scanner = new QrScanner(video, r => onscan(r.data), {
|
||||||
returnDetailedScanResult: true,
|
returnDetailedScanResult: true,
|
||||||
})
|
})
|
||||||
@@ -22,11 +49,21 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</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}
|
{#if loading}
|
||||||
<p class="py-20">
|
<p class="py-20">
|
||||||
<Spinner loading>Loading your camera...</Spinner>
|
<Spinner loading>Loading your camera...</Spinner>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<video class="m-auto rounded" class:h-0={loading} bind:this={video}></video>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {openDB, deleteDB} from "idb"
|
import {openDB, deleteDB} from "idb"
|
||||||
import type {IDBPDatabase} from "idb"
|
import type {IDBPDatabase} from "idb"
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import type {Unsubscriber} 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 type {Maybe} from "@welshman/lib"
|
||||||
import {withGetter} from "@welshman/store"
|
|
||||||
|
|
||||||
export type IDBAdapter = {
|
export type IDBAdapter = {
|
||||||
name: string
|
name: string
|
||||||
@@ -83,11 +81,11 @@ export class IDB {
|
|||||||
// If we're closing, ignore any lingering requests
|
// If we're closing, ignore any lingering requests
|
||||||
if ([IDBStatus.Closed, IDBStatus.Closing].includes(this.status)) return
|
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[]> =>
|
getAll = async <T>(table: string): Promise<T[]> => {
|
||||||
this._withIDBP(async idbp => {
|
const result = await this._withIDBP(async idbp => {
|
||||||
const tx = idbp.transaction(table, "readwrite")
|
const tx = idbp.transaction(table, "readwrite")
|
||||||
const store = tx.objectStore(table)
|
const store = tx.objectStore(table)
|
||||||
const result = await store.getAll()
|
const result = await store.getAll()
|
||||||
@@ -97,6 +95,9 @@ export class IDB {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return result || []
|
||||||
|
}
|
||||||
|
|
||||||
bulkPut = async <T>(table: string, data: Iterable<T>) =>
|
bulkPut = async <T>(table: string, data: Iterable<T>) =>
|
||||||
this._withIDBP(async idbp => {
|
this._withIDBP(async idbp => {
|
||||||
const tx = idbp.transaction(table, "readwrite")
|
const tx = idbp.transaction(table, "readwrite")
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import {LOCALE} from "@welshman/lib"
|
import {LOCALE} from "@welshman/lib"
|
||||||
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
||||||
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import WalletPay from "@app/components/WalletPay.svelte"
|
||||||
import WalletConnect from "@app/components/WalletConnect.svelte"
|
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||||
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
|
||||||
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
const walletLud16 = $derived(
|
const walletLud16 = $derived(
|
||||||
$session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined,
|
$session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pay = () => pushModal(WalletPay)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="content column gap-4">
|
<div class="content column gap-4">
|
||||||
@@ -118,4 +122,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<Button class="btn btn-primary" onclick={pay}>
|
||||||
|
<Icon icon={Bolt} />
|
||||||
|
Pay With Lightning
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ config({path: ".env.template"})
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 1847,
|
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: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user