Add support for parsing and displaying lnurl invoices

This commit is contained in:
Jonathan Staab 2023-06-13 08:38:53 -07:00
parent 7dd6edaac5
commit a40b9268f6
6 changed files with 125 additions and 82 deletions

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {Filter} from "nostr-tools"
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {Filter} from "nostr-tools"
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {last, equals, partition, always, uniqBy, sortBy, prop} from "ramda" import {last, equals, partition, always, uniqBy, sortBy, prop} from "ramda"
import {fly} from "svelte/transition" import {fly} from "svelte/transition"

View File

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import {pluck} from 'ramda' import type {Filter} from "nostr-tools"
import {Filter} from 'nostr-tools' import {pluck} from "ramda"
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {debounce} from 'throttle-debounce' import {debounce} from "throttle-debounce"
import {createLocalDate} from 'src/util/misc' import {createLocalDate} from "src/util/misc"
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte" import Modal from "src/partials/Modal.svelte"
@ -20,29 +20,29 @@
until: null, until: null,
authors: [], authors: [],
search: "", search: "",
'#t': [], "#t": [],
'#p': [], "#p": [],
} }
let modal = null let modal = null
const applyFilter = debounce(300, () => { const applyFilter = debounce(300, () => {
if (modal !== 'maxi') { if (modal !== "maxi") {
const _filter = {} as Filter const _filter = {} as Filter
if (filter.since) _filter.since = createLocalDate(filter.since).setHours(23, 59, 59, 0) / 1000 if (filter.since) _filter.since = createLocalDate(filter.since).setHours(23, 59, 59, 0) / 1000
if (filter.until) _filter.until = createLocalDate(filter.until).setHours(23, 59, 59, 0) / 1000 if (filter.until) _filter.until = createLocalDate(filter.until).setHours(23, 59, 59, 0) / 1000
if (filter.authors.length > 0) _filter.authors = pluck('pubkey', filter.authors) if (filter.authors.length > 0) _filter.authors = pluck("pubkey", filter.authors)
if (filter.search) _filter.search = filter.search if (filter.search) _filter.search = filter.search
if (filter['#t'].length > 0) _filter['#t'] = pluck('name', filter['#t']) if (filter["#t"].length > 0) _filter["#t"] = pluck("name", filter["#t"])
if (filter['#p'].length > 0) _filter['#p'] = pluck('pubkey', filter['#p']) if (filter["#p"].length > 0) _filter["#p"] = pluck("pubkey", filter["#p"])
onChange(_filter) onChange(_filter)
} }
}) })
const open = () => { const open = () => {
modal = 'maxi' modal = "maxi"
} }
const submit = () => { const submit = () => {
@ -55,71 +55,75 @@
} }
</script> </script>
<div class="flex gap-2 justify-end" in:fly={{y: 20}}> <div class="flex justify-end gap-2" in:fly={{y: 20}}>
<i class="fa fa-search cursor-pointer" on:click={() => {modal = modal ? null : 'mini'}} /> <i
class="fa fa-search cursor-pointer"
on:click={() => {
modal = modal ? null : "mini"
}} />
<i class="fa fa-sliders cursor-pointer" on:click={open} /> <i class="fa fa-sliders cursor-pointer" on:click={open} />
</div> </div>
{#if modal} {#if modal}
<Modal {onEscape} mini={modal === 'mini'}> <Modal {onEscape} mini={modal === "mini"}>
<Content size="lg"> <Content size="lg">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<strong>Search</strong> <strong>Search</strong>
<Input bind:value={filter.search} on:input={applyFilter}> <Input bind:value={filter.search} on:input={applyFilter}>
<i slot="before" class="fa fa-search" /> <i slot="before" class="fa fa-search" />
</Input> </Input>
</div> </div>
{#if modal === 'maxi'} {#if modal === "maxi"}
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<strong>Since</strong> <strong>Since</strong>
<Input type="date" bind:value={filter.since} /> <Input type="date" bind:value={filter.since} />
</div>
<div class="flex flex-col gap-1">
<strong>Until</strong>
<Input type="date" bind:value={filter.until} />
</div>
</div> </div>
<div class="flex flex-col gap-1"> {#if !hide.includes("authors")}
<strong>Until</strong> <div class="flex flex-col gap-1">
<Input type="date" bind:value={filter.until} /> <strong>Authors</strong>
<MultiSelect search={$searchPeople} bind:value={filter.authors}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
</div>
{/if}
{#if !hide.includes("#t")}
<div class="flex flex-col gap-1">
<strong>Topics</strong>
<MultiSelect search={$searchTopics} bind:value={filter["#t"]}>
<div slot="item" let:item>
<div class="-my-1">
#{item.name}
</div>
</div>
</MultiSelect>
</div>
{/if}
{#if !hide.includes("#p")}
<div class="flex flex-col gap-1">
<strong>Mentions</strong>
<MultiSelect search={$searchPeople} bind:value={filter["#p"]}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
</div>
{/if}
<div class="flex justify-end">
<Anchor type="button-accent" on:click={submit}>Apply Filters</Anchor>
</div> </div>
</div>
{#if !hide.includes('authors')}
<div class="flex flex-col gap-1">
<strong>Authors</strong>
<MultiSelect search={$searchPeople} bind:value={filter.authors}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
</div>
{/if} {/if}
{#if !hide.includes('#t')} </Content>
<div class="flex flex-col gap-1"> </Modal>
<strong>Topics</strong>
<MultiSelect search={$searchTopics} bind:value={filter['#t']}>
<div slot="item" let:item>
<div class="-my-1">
#{item.name}
</div>
</div>
</MultiSelect>
</div>
{/if}
{#if !hide.includes('#p')}
<div class="flex flex-col gap-1">
<strong>Mentions</strong>
<MultiSelect search={$searchPeople} bind:value={filter['#p']}>
<div slot="item" let:item>
<div class="-my-1">
<PersonBadge inert person={getPersonWithFallback(item.pubkey)} />
</div>
</div>
</MultiSelect>
</div>
{/if}
<div class="flex justify-end">
<Anchor type="button-accent" on:click={submit}>Apply Filters</Anchor>
</div>
{/if}
</Content>
</Modal>
{/if} {/if}

View File

@ -6,6 +6,7 @@
import {displayPerson, parseContent, getLabelQuality, displayRelay, Tags} from "src/util/nostr" import {displayPerson, parseContent, getLabelQuality, displayRelay, Tags} from "src/util/nostr"
import {modal} from "src/partials/state" import {modal} from "src/partials/state"
import MediaSet from "src/partials/MediaSet.svelte" import MediaSet from "src/partials/MediaSet.svelte"
import QRCode from "src/partials/QRCode.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
@ -29,15 +30,22 @@
let rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null let rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = [] const links = []
const invoices = []
const ranges = [] const ranges = []
// Find links and preceding whitespace // Find links and preceding whitespace
for (let i = 0; i < content.length; i++) { for (let i = 0; i < content.length; i++) {
const {type, value} = content[i] const {type, value} = content[i]
if (type === "link" && !value.startsWith("ws")) { if (type === "link") {
links.push(value) links.push(value)
}
if (type === "lnurl") {
invoices.push(value)
}
if (["link", "lnurl"].includes(type) && !value.startsWith("ws")) {
const prev = content[i - 1] const prev = content[i - 1]
const next = content[i + 1] const next = content[i + 1]
@ -197,6 +205,11 @@
{" "} {" "}
{/each} {/each}
</p> </p>
{#if invoices.length > 0}
<div on:click|stopPropagation>
<QRCode fullWidth onClick="copy" code={invoices[0]} />
</div>
{/if}
{#if showMedia && links.length > 0} {#if showMedia && links.length > 0}
<div on:click|stopPropagation> <div on:click|stopPropagation>
<MediaSet {links} /> <MediaSet {links} />

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import QRCode from "qrcode" import QRCode from "qrcode"
import {onMount} from "svelte" import {onMount} from "svelte"
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
@ -7,6 +8,8 @@
import {toast} from "src/partials/state" import {toast} from "src/partials/state"
export let code export let code
export let onClick = "navigate"
export let fullWidth = false
let canvas let canvas
@ -21,11 +24,20 @@
</script> </script>
<div <div
class="m-auto flex max-w-sm flex-col gap-4 rounded border border-solid border-gray-6 bg-gray-8 p-4"> class={cx("rounded-xl border border-solid border-gray-6 bg-gray-8 p-4", {
<Anchor external href={code}> "m-auto max-w-sm": !fullWidth,
<canvas class="m-auto rounded" bind:this={canvas} /> })}>
</Anchor> <div class="m-auto flex max-w-sm flex-col gap-4">
<Input value={code}> <Anchor
<button slot="after" class="fa fa-copy" on:click={copy} /> external
</Input> href={onClick === "navigate" ? code : null}
on:click={onClick === "copy" ? copy : null}>
<canvas class="m-auto rounded-xl" bind:this={canvas} />
</Anchor>
{#if onClick === "navigate"}
<Input value={code}>
<button slot="after" class="fa fa-copy" on:click={copy} />
</Input>
{/if}
</div>
</div> </div>

View File

@ -8,7 +8,7 @@
const className = cx( const className = cx(
$$props.class, $$props.class,
"rounded shadow-inset py-2 px-4 pr-10 w-full bg-input text-black", "rounded-xl shadow-inset py-2 px-4 pr-10 w-full bg-input text-black",
"placeholder:text-gray-5 border border-solid border-gray-3" "placeholder:text-gray-5 border border-solid border-gray-3"
) )
</script> </script>

View File

@ -265,6 +265,14 @@ export const parseContent = ({content, tags = []}) => {
} }
} }
const parseLNUrl = () => {
const lnurl = first(text.match(/^lnbc[\d\w]+/i))
if (lnurl) {
return ["lnurl", lnurl, lnurl]
}
}
const parseUrl = () => { const parseUrl = () => {
const raw = first(text.match(/^([a-z\+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^\.!?,:\s]/gi)) const raw = first(text.match(/^([a-z\+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^\.!?,:\s]/gi))
@ -292,7 +300,13 @@ export const parseContent = ({content, tags = []}) => {
} }
while (text) { while (text) {
const part = parseNewline() || parseMention() || parseTopic() || parseBech32() || parseUrl() const part =
parseNewline() ||
parseMention() ||
parseTopic() ||
parseBech32() ||
parseUrl() ||
parseLNUrl()
if (part) { if (part) {
if (buffer) { if (buffer) {