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">
import type {Filter} from "nostr-tools"
import {onMount, onDestroy} from "svelte"
import {Filter} from "nostr-tools"
import {debounce} from "throttle-debounce"
import {last, equals, partition, always, uniqBy, sortBy, prop} from "ramda"
import {fly} from "svelte/transition"

View File

@ -1,9 +1,9 @@
<script lang="ts">
import {pluck} from 'ramda'
import {Filter} from 'nostr-tools'
import type {Filter} from "nostr-tools"
import {pluck} from "ramda"
import {fly} from "svelte/transition"
import {debounce} from 'throttle-debounce'
import {createLocalDate} from 'src/util/misc'
import {debounce} from "throttle-debounce"
import {createLocalDate} from "src/util/misc"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
@ -20,29 +20,29 @@
until: null,
authors: [],
search: "",
'#t': [],
'#p': [],
"#t": [],
"#p": [],
}
let modal = null
const applyFilter = debounce(300, () => {
if (modal !== 'maxi') {
if (modal !== "maxi") {
const _filter = {} as Filter
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.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['#t'].length > 0) _filter['#t'] = pluck('name', filter['#t'])
if (filter['#p'].length > 0) _filter['#p'] = pluck('pubkey', filter['#p'])
if (filter["#t"].length > 0) _filter["#t"] = pluck("name", filter["#t"])
if (filter["#p"].length > 0) _filter["#p"] = pluck("pubkey", filter["#p"])
onChange(_filter)
}
})
const open = () => {
modal = 'maxi'
modal = "maxi"
}
const submit = () => {
@ -55,71 +55,75 @@
}
</script>
<div class="flex gap-2 justify-end" in:fly={{y: 20}}>
<i class="fa fa-search cursor-pointer" on:click={() => {modal = modal ? null : 'mini'}} />
<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-sliders cursor-pointer" on:click={open} />
</div>
{#if modal}
<Modal {onEscape} mini={modal === 'mini'}>
<Content size="lg">
<div class="flex flex-col gap-1">
<strong>Search</strong>
<Input bind:value={filter.search} on:input={applyFilter}>
<i slot="before" class="fa fa-search" />
</Input>
</div>
{#if modal === 'maxi'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<strong>Since</strong>
<Input type="date" bind:value={filter.since} />
<Modal {onEscape} mini={modal === "mini"}>
<Content size="lg">
<div class="flex flex-col gap-1">
<strong>Search</strong>
<Input bind:value={filter.search} on:input={applyFilter}>
<i slot="before" class="fa fa-search" />
</Input>
</div>
{#if modal === "maxi"}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<strong>Since</strong>
<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 class="flex flex-col gap-1">
<strong>Until</strong>
<Input type="date" bind:value={filter.until} />
{#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 !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>
{#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 !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>
{/if}
</Content>
</Modal>
</Content>
</Modal>
{/if}

View File

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

View File

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

View File

@ -8,7 +8,7 @@
const className = cx(
$$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"
)
</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 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) {
const part = parseNewline() || parseMention() || parseTopic() || parseBech32() || parseUrl()
const part =
parseNewline() ||
parseMention() ||
parseTopic() ||
parseBech32() ||
parseUrl() ||
parseLNUrl()
if (part) {
if (buffer) {