This commit is contained in:
Jonathan Staab 2023-02-28 16:01:38 -06:00
parent d033b4040b
commit c06b4c5c77
14 changed files with 273 additions and 35 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.2.15
- [x] Add zaps
## 0.2.14 ## 0.2.14
- [x] Improve paste support - [x] Improve paste support

View File

@ -1,5 +1,6 @@
# Current # Current
- [ ] Try adding boxes/separation on feeds based on user feedback
- [ ] Strip zero width spaces from compose - [ ] Strip zero width spaces from compose
- [ ] Fix iOS/safari/firefox - [ ] Fix iOS/safari/firefox
- [ ] Make the note relays button modal make sense, one relay with no explanation is not good - [ ] Make the note relays button modal make sense, one relay with no explanation is not good
@ -13,13 +14,6 @@
- https://github.com/seaweedfs/seaweedfs - https://github.com/seaweedfs/seaweedfs
- https://github.com/cubefs/cubefs - https://github.com/cubefs/cubefs
# Lightning
- [ ] Linkify invoices
- [ ] Linkify bech32 entities w/ NIP 21 https://github.com/nostr-protocol/nips/blob/master/21.md
- [ ] Support invoices, tips, zaps https://twitter.com/jb55/status/1604131336247476224
- nevent1qqsd0x0xzfwtppu0n52ngw0zhynlwv0sjsr77aflcpufms2wrl3v8mspr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqs7amnwvaz7tmwdaehgu3wd4hk6d7ewgp
# Custom views # Custom views
- [ ] Add customize icon and route with editable custom view cards using "lists" nip - [ ] Add customize icon and route with editable custom view cards using "lists" nip
@ -28,6 +22,15 @@
# More # More
- [ ] Linkify invoices
- [ ] Linkify bech32 entities w/ NIP 21 https://github.com/nostr-protocol/nips/blob/master/21.md
- [ ] Person zaps
- [ ] Add dynamic title tag
- [ ] Collapsible thread view
- [ ] Split inbox into replies + everything else
- [ ] Show more link on long notes
- [ ] Show popover on delayed hover rather than click (on mobile, keep it click)
- [ ] Light mode
- [ ] Mute threads http://localhost:5173/nevent1qqsyz8x6r0cu7l6vwlcjhf8qhxyjtdykvuervkc3t3mfggse4qtwt0gpyfmhxue69uhkummnw3ezumrfvfjhyarpwdc8y6tddaexg6t4d5hxxmmdnhxvea - [ ] Mute threads http://localhost:5173/nevent1qqsyz8x6r0cu7l6vwlcjhf8qhxyjtdykvuervkc3t3mfggse4qtwt0gpyfmhxue69uhkummnw3ezumrfvfjhyarpwdc8y6tddaexg6t4d5hxxmmdnhxvea
- [ ] Add webtorrent support - [ ] Add webtorrent support
- https://coracle.social/nevent1qqsxgxcsq5vevy4wdty5z5v88nhwp2fc5qgl0ws5rmamn6z72hwv3qcpyfmhxue69uhkummnw3ez6an9wf5kv6t9vsh8wetvd3hhyer9wghxuet5qk6c9q - https://coracle.social/nevent1qqsxgxcsq5vevy4wdty5z5v88nhwp2fc5qgl0ws5rmamn6z72hwv3qcpyfmhxue69uhkummnw3ez6an9wf5kv6t9vsh8wetvd3hhyer9wghxuet5qk6c9q

BIN
package-lock.json generated

Binary file not shown.

View File

@ -30,11 +30,14 @@
"@fortawesome/fontawesome-free": "^6.2.1", "@fortawesome/fontawesome-free": "^6.2.1",
"@noble/secp256k1": "^1.7.0", "@noble/secp256k1": "^1.7.0",
"@tsconfig/svelte": "^3.0.0", "@tsconfig/svelte": "^3.0.0",
"bech32": "^2.0.0",
"bolt11": "^1.4.0",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"compressorjs": "^1.1.1", "compressorjs": "^1.1.1",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"hurdak": "github:ConsignCloud/hurdak", "hurdak": "github:ConsignCloud/hurdak",
"husky": "^8.0.3", "husky": "^8.0.3",
"js-lnurl": "^0.5.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"localforage-memoryStorageDriver": "^0.9.2", "localforage-memoryStorageDriver": "^0.9.2",
"nostr-tools": "^1.4.1", "nostr-tools": "^1.4.1",

View File

@ -93,6 +93,16 @@ const createReply = (note, content, mentions = [], topics = []) => {
return new PublishableEvent(1, {content, tags}) return new PublishableEvent(1, {content, tags})
} }
const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
const tags = [["relays", ...relays], ["amount", amount], ["lnurl", lnurl], ["p", pubkey]]
if (eventId) {
tags.push(["e", eventId])
}
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids => const deleteEvent = ids =>
new PublishableEvent(5, {tags: ids.map(id => ["e", id])}) new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
@ -119,5 +129,5 @@ class PublishableEvent {
export default { export default {
updateUser, setRelays, setPetnames, muffle, createRoom, updateRoom, updateUser, setRelays, setPetnames, muffle, createRoom, updateRoom,
createChatMessage, createDirectMessage, createNote, createReaction, createChatMessage, createDirectMessage, createNote, createReaction,
createReply, deleteEvent, createReply, requestZap, deleteEvent, PublishableEvent,
} }

View File

@ -1,5 +1,5 @@
import type {MyEvent} from 'src/util/types' import type {MyEvent} from 'src/util/types'
import {partition, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda' import {assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda'
import {personKinds, findReplyId} from 'src/util/nostr' import {personKinds, findReplyId} from 'src/util/nostr'
import {log} from 'src/util/logger' import {log} from 'src/util/logger'
import {chunk} from 'hurdak/lib/hurdak' import {chunk} from 'hurdak/lib/hurdak'
@ -148,7 +148,7 @@ const streamContext = ({notes, onChunk, depth = 0}) =>
while (events.length > 0 && depth > 0) { while (events.length > 0 && depth > 0) {
const chunk = events.splice(0) const chunk = events.splice(0)
const authors = getStalePubkeys(pluck('pubkey', chunk)) const authors = getStalePubkeys(pluck('pubkey', chunk))
const filter = [{kinds: [1, 7], '#e': pluck('id', chunk)}] as Array<object> const filter = [{kinds: [1, 7, 9735], '#e': pluck('id', chunk)}] as Array<object>
const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren))) const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren)))
// Load authors and reactions in one subscription // Load authors and reactions in one subscription
@ -171,22 +171,26 @@ const streamContext = ({notes, onChunk, depth = 0}) =>
) )
const applyContext = (notes, context) => { const applyContext = (notes, context) => {
const [replies, reactions] = partition( context = context.map(assoc('isContext', true))
propEq('kind', 1),
context.map(assoc('isContext', true)) const replies = context.filter(propEq('kind', 1))
) const reactions = context.filter(propEq('kind', 7))
const zaps = context.filter(propEq('kind', 9735))
const repliesByParentId = groupBy(findReplyId, replies) const repliesByParentId = groupBy(findReplyId, replies)
const reactionsByParentId = groupBy(findReplyId, reactions) const reactionsByParentId = groupBy(findReplyId, reactions)
const zapsByParentId = groupBy(findReplyId, zaps)
const annotate = ({replies = [], reactions = [], ...note}) => { const annotate = ({replies = [], reactions = [], zaps = [], ...note}) => {
const combinedReplies = replies.concat(repliesByParentId[note.id] || []) const combinedReplies = replies.concat(repliesByParentId[note.id] || [])
const combinedReactions = reactions.concat(reactionsByParentId[note.id] || []) const combinedReactions = reactions.concat(reactionsByParentId[note.id] || [])
const combinedZaps = zaps.concat(zapsByParentId[note.id] || [])
return { return {
...note, ...note,
replies: uniqBy(prop('id'), combinedReplies).map(annotate), replies: uniqBy(prop('id'), combinedReplies).map(annotate),
reactions: uniqBy(prop('id'), combinedReactions), reactions: uniqBy(prop('id'), combinedReactions),
zaps: uniqBy(prop('id'), combinedZaps),
} }
} }

View File

@ -1,8 +1,9 @@
import {uniq, pick, identity, isEmpty} from 'ramda' import {uniq, pick, identity, isEmpty} from 'ramda'
import {nip05} from 'nostr-tools' import {nip05} from 'nostr-tools'
import {getParams} from 'js-lnurl'
import {noop, createMap, ensurePlural, chunk, switcherFn} from 'hurdak/lib/hurdak' import {noop, createMap, ensurePlural, chunk, switcherFn} from 'hurdak/lib/hurdak'
import {log} from 'src/util/logger' import {log} from 'src/util/logger'
import {now, sleep, tryJson, timedelta, shuffle, hash} from 'src/util/misc' import {hexToBech32, now, sleep, tryJson, timedelta, shuffle, hash} from 'src/util/misc'
import {Tags, roomAttrs, personKinds, isRelay, isShareableRelay, normalizeRelayUrl} from 'src/util/nostr' import {Tags, roomAttrs, personKinds, isRelay, isShareableRelay, normalizeRelayUrl} from 'src/util/nostr'
import database from 'src/agent/database' import database from 'src/agent/database'
@ -45,8 +46,10 @@ const processProfileEvents = async events => {
if (e.created_at > (person.kind0_updated_at || 0)) { if (e.created_at > (person.kind0_updated_at || 0)) {
if (kind0.nip05) { if (kind0.nip05) {
verifyNip05(e.pubkey, kind0.nip05) verifyNip05(e.pubkey, kind0.nip05)
}
kind0.nip05_updated_at = e.created_at if (kind0.lud16 || kind0.lud06) {
verifyZapper(e.pubkey, kind0.lud16 || kind0.lud06)
} }
return { return {
@ -304,4 +307,29 @@ const verifyNip05 = (pubkey, as) =>
} }
}, noop) }, noop)
const verifyZapper = async (pubkey, address) => {
// Try to parse it as a lud06 LNURL
let zapper = await getParams(address) as any
let lnurl = address
// If that failed, try to parse it as a lud16 address
if (zapper.status === 'ERROR' && address.includes('@')) {
const [name, domain] = address.split('@')
if (!domain || !name) {
return
}
const url = `https://${domain}/.well-known/lnurlp/${name}`
const res = await fetch(url)
zapper = await res.json()
lnurl = hexToBech32('lnurl', url)
}
if (zapper?.allowsNostr && zapper?.nostrPubkey) {
database.people.patch({pubkey, zapper, lnurl})
}
}
export default {processEvents} export default {processEvents}

View File

@ -25,6 +25,7 @@ const anonRelays = synced('agent/user/anonRelays', [])
const settings = synced("agent/user/settings", { const settings = synced("agent/user/settings", {
relayLimit: 20, relayLimit: 20,
defaultZap: 21,
showMedia: true, showMedia: true,
reportAnalytics: true, reportAnalytics: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL, dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
@ -91,6 +92,7 @@ const user = {
canPublish, canPublish,
getProfile: () => profileCopy, getProfile: () => profileCopy,
getPubkey: () => profileCopy?.pubkey, getPubkey: () => profileCopy?.pubkey,
canZap: () => profileCopy?.zapper,
muffle: events => { muffle: events => {
const muffle = user.getMuffle() const muffle = user.getMuffle()

View File

@ -1,8 +1,9 @@
<script> <script lang="ts">
import cx from "classnames" import cx from "classnames"
export let initialValue: string | number = ""
export let wrapperClass = "" export let wrapperClass = ""
export let value = "" export let value = initialValue
const className = cx( const className = cx(
$$props.class, $$props.class,
@ -12,14 +13,14 @@
</script> </script>
<div class={cx(wrapperClass, "relative")}> <div class={cx(wrapperClass, "relative")}>
<input {...$$props} class={className} bind:value /> <input {...$$props} class={className} bind:value on:change on:input />
{#if $$slots.before} {#if $$slots.before}
<div class="absolute top-0 left-0 pt-3 px-3 text-dark flex gap-2"> <div class="absolute top-0 left-0 pt-3 px-3 text-dark flex gap-2">
<slot name="before" /> <slot name="before" />
</div> </div>
{/if} {/if}
{#if $$slots.after} {#if $$slots.after}
<div class="absolute top-0 right-0 pt-3 px-3 text-dark flex gap-2 bg-light"> <div class="absolute top-0 right-0 pt-3 px-3 text-dark flex gap-2 bg-light rounded m-px">
<slot name="after" /> <slot name="after" />
</div> </div>
{/if} {/if}

View File

@ -1,8 +1,10 @@
import {bech32} from 'bech32'
import {Buffer} from 'buffer'
import {debounce, throttle} from 'throttle-debounce' import {debounce, throttle} from 'throttle-debounce'
import {aperture, path as getPath, allPass, pipe, isNil, complement, equals, is, pluck, sum, identity, sortBy} from "ramda" import {aperture, path as getPath, allPass, pipe, isNil, complement, equals, is, pluck, sum, identity, sortBy} from "ramda"
import Fuse from "fuse.js/dist/fuse.min.js" import Fuse from "fuse.js/dist/fuse.min.js"
import {writable} from 'svelte/store' import {writable} from 'svelte/store'
import {isObject} from 'hurdak/lib/hurdak' import {isObject, round} from 'hurdak/lib/hurdak'
import {warn} from 'src/util/logger' import {warn} from 'src/util/logger'
export const fuzzy = (data, opts = {}) => { export const fuzzy = (data, opts = {}) => {
@ -327,3 +329,18 @@ export const uploadFile = (url, fileObj) => {
return fetchJson(url, {method: 'POST', body}) return fetchJson(url, {method: 'POST', body})
} }
export const hexToBech32 = (prefix, hex) =>
bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
export const bech32ToHex = b32 =>
Buffer.from(bech32.fromWords(bech32.decode(b32).words)).toString('hex')
export const formatSats = sats => {
const formatter = new Intl.NumberFormat()
if (sats < 1_000) return formatter.format(sats)
if (sats < 1_000_000) return formatter.format(round(1, sats / 1000)) + 'K'
if (sats < 100_000_000) return formatter.format(round(1, sats / 1_000_000)) + 'MM'
return formatter.format(round(2, sats / 100_000_000)) + 'BTC'
}

View File

@ -1,5 +1,5 @@
import type {DisplayEvent} from 'src/util/types' import type {DisplayEvent} from 'src/util/types'
import {last, identity, objOf, prop, flatten, uniq} from 'ramda' import {fromPairs, last, identity, objOf, prop, flatten, uniq} from 'ramda'
import {nip19} from 'nostr-tools' import {nip19} from 'nostr-tools'
import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak' import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak'
@ -31,6 +31,9 @@ export class Tags {
pubkeys() { pubkeys() {
return this.type("p").values().all() return this.type("p").values().all()
} }
asMeta() {
return fromPairs(this.tags)
}
values() { values() {
return new Tags(this.tags.map(t => t[1])) return new Tags(this.tags.map(t => t[1]))
} }
@ -118,7 +121,7 @@ export const normalizeRelayUrl = url => url.replace(/\/+$/, '').toLowerCase().tr
export const roomAttrs = ['name', 'about', 'picture'] export const roomAttrs = ['name', 'about', 'picture']
export const asDisplayEvent = event => export const asDisplayEvent = event =>
({replies: [], reactions: [], ...event}) as DisplayEvent ({replies: [], reactions: [], zaps: [], ...event}) as DisplayEvent
export const toHex = (data: string): string | null => { export const toHex = (data: string): string | null => {
try { try {

View File

@ -28,6 +28,7 @@ export type MyEvent = Event & {
export type DisplayEvent = MyEvent & { export type DisplayEvent = MyEvent & {
replies: Array<MyEvent> replies: Array<MyEvent>
reactions: Array<MyEvent> reactions: Array<MyEvent>
zaps: Array<MyEvent>
} }
export type Room = { export type Room = {

View File

@ -46,6 +46,15 @@
in any note. in any note.
</p> </p>
</div> </div>
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<strong>Default zap amount</strong>
<Input bind:value={values.defaultZap} />
</div>
<p class="text-sm text-light">
The default amount of sats to use when sending a lightning tip.
</p>
</div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex justify-between"> <div class="flex justify-between">
<strong>Max relays per request</strong> <strong>Max relays per request</strong>

View File

@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
import cx from 'classnames' import cx from 'classnames'
import bolt11 from 'bolt11'
import QRCode from 'qrcode'
import {nip19} from 'nostr-tools' import {nip19} from 'nostr-tools'
import {find, last, whereEq, without, uniq, pluck, reject, propEq} from 'ramda' import {find, sum, last, whereEq, without, uniq, pluck, reject, propEq} from 'ramda'
import {onMount} from 'svelte' import {onMount} from 'svelte'
import {tweened} from 'svelte/motion' import {tweened} from 'svelte/motion'
import {slide} from 'svelte/transition' import {slide} from 'svelte/transition'
import {navigate} from 'svelte-routing' import {navigate} from 'svelte-routing'
import {quantify} from 'hurdak/lib/hurdak' import {quantify} from 'hurdak/lib/hurdak'
import {Tags, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr" import {Tags, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
import {extractUrls} from "src/util/html" import {formatTimestamp, now, tryJson, stringToColor, formatSats, fetchJson} from 'src/util/misc'
import {extractUrls, copyToClipboard} from "src/util/html"
import ImageCircle from 'src/partials/ImageCircle.svelte' import ImageCircle from 'src/partials/ImageCircle.svelte'
import Input from 'src/partials/Input.svelte'
import Textarea from 'src/partials/Textarea.svelte'
import Content from 'src/partials/Content.svelte' import Content from 'src/partials/Content.svelte'
import PersonSummary from 'src/views/person/PersonSummary.svelte' import PersonSummary from 'src/views/person/PersonSummary.svelte'
import Popover from 'src/partials/Popover.svelte' import Popover from 'src/partials/Popover.svelte'
@ -19,10 +24,11 @@
import Anchor from 'src/partials/Anchor.svelte' import Anchor from 'src/partials/Anchor.svelte'
import {toast, modal} from "src/app/ui" import {toast, modal} from "src/app/ui"
import {renderNote} from "src/app" import {renderNote} from "src/app"
import {formatTimestamp, stringToColor} from 'src/util/misc'
import Compose from "src/partials/Compose.svelte" import Compose from "src/partials/Compose.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import user from 'src/agent/user' import user from 'src/agent/user'
import keys from 'src/agent/keys'
import network from 'src/agent/network'
import {getEventPublishRelays, getRelaysForEventParent} from 'src/agent/relays' import {getEventPublishRelays, getRelaysForEventParent} from 'src/agent/relays'
import database from 'src/agent/database' import database from 'src/agent/database'
import cmd from 'src/agent/cmd' import cmd from 'src/agent/cmd'
@ -39,6 +45,7 @@
const getDefaultReplyMentions = () => const getDefaultReplyMentions = () =>
without([user.getPubkey()], uniq(Tags.from(note).type("p").values().all().concat(note.pubkey))) without([user.getPubkey()], uniq(Tags.from(note).type("p").values().all().concat(note.pubkey)))
let zap = null
let reply = null let reply = null
let replyMentions = getDefaultReplyMentions() let replyMentions = getDefaultReplyMentions()
let replyContainer = null let replyContainer = null
@ -51,25 +58,67 @@
const interactive = !anchorId || !showEntire const interactive = !anchorId || !showEntire
const person = database.watch('people', () => database.getPersonWithFallback(note.pubkey)) const person = database.watch('people', () => database.getPersonWithFallback(note.pubkey))
let likes, flags, like, flag, border, childrenContainer, noteContainer let likes, flags, zaps, like, flag, border, childrenContainer, noteContainer, canZap, zapCanvas
const interpolate = (a, b) => t => a + Math.round((b - a) * t) const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const likesCount = tweened(0, {interpolate}) const likesCount = tweened(0, {interpolate})
const flagsCount = tweened(0, {interpolate}) const flagsCount = tweened(0, {interpolate})
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate}) const repliesCount = tweened(0, {interpolate})
$: { $: likes = note.reactions.filter(n => isLike(n.content))
likes = note.reactions.filter(n => isLike(n.content)) $: flags = note.reactions.filter(whereEq({content: '-'}))
flags = note.reactions.filter(whereEq({content: '-'})) $: zaps = note.zaps
} .map(zap => {
const zapMeta = Tags.from(zap).asMeta()
return tryJson(() => ({
...zap,
invoice: bolt11.decode(zapMeta.bolt11),
request: JSON.parse(zapMeta.description),
}))
})
.filter(zap => {
if (!zap) {
return false
}
// Don't count zaps that the user sent himself
if (zap.request.pubkey === $person.pubkey) {
return false
}
const {invoice, request} = zap
const reqMeta = Tags.from(request).asMeta()
// Verify that the zapper actually sent the requested amount (if it was supplied)
if (reqMeta.amount && reqMeta.amount !== parseInt(invoice.millisatoshis)) {
return false
}
// If the sending client provided an lnurl tag, verify that too
if (reqMeta.lnurl && reqMeta.lnurl !== $person?.lnurl) {
return false
}
// Verify that the zap note actually came from the recipient's zapper
if ($person.zapper?.nostrPubkey !== zap.pubkey) {
return false
}
return true
})
$: like = find(whereEq({pubkey: $profile?.pubkey}), likes) $: like = find(whereEq({pubkey: $profile?.pubkey}), likes)
$: flag = find(whereEq({pubkey: $profile?.pubkey}), flags) $: flag = find(whereEq({pubkey: $profile?.pubkey}), flags)
$: zapped = find(z => z.request.pubkey === $profile?.pubkey, zaps)
$: $likesCount = likes.length $: $likesCount = likes.length
$: $flagsCount = flags.length $: $flagsCount = flags.length
$: $zapsTotal = sum(zaps.map(zap => zap.invoice.satoshis))
$: $repliesCount = note.replies.length $: $repliesCount = note.replies.length
$: visibleNotes = note.replies.filter(r => showContext ? true : !r.isContext) $: visibleNotes = note.replies.filter(r => showContext ? true : !r.isContext)
$: canZap = $person?.zapper && user.canZap()
$: zapCanvas && zap && QRCode.toCanvas(zapCanvas, zap.invoice)
const onClick = e => { const onClick = e => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
@ -173,6 +222,67 @@
} }
} }
const startZap = async () => {
zap = {
amount: user.getSetting('defaultZap'),
message: '',
invoice: null,
loading: false,
startedAt: now(),
}
}
const loadZapInvoice = async () => {
zap.loading = true
const {zapper, lnurl} = $person
const amount = zap.amount * 1000
const relays = getEventPublishRelays(note)
const urls = pluck('url', relays)
const publishable = cmd.requestZap(urls, zap.message, note.pubkey, note.id, amount, lnurl)
const event = encodeURI(JSON.stringify(await keys.sign(publishable.event)))
const res = await fetchJson(
`${zapper.callback}?amount=${amount}&nostr=${event}&lnurl=${lnurl}`
)
zap.invoice = res.pr
zap.loading = false
// Open up alby or whatever
const {webln} = (window as {webln?: any})
if (webln) {
await webln.enable()
webln.sendPayment(zap.invoice)
}
// Listen for the zap confirmation
zap.sub = network.listen({
relays,
filter: {
kinds: [9735],
authors: [zapper.nostrPubkey],
'#p': [$person.pubkey],
since: zap.startedAt,
},
onChunk: chunk => {
note.zaps = note.zaps.concat(chunk)
cleanupZap()
},
})
}
const copyZapInvoice = () => {
copyToClipboard(zap.invoice)
}
const cleanupZap = () => {
if (zap) {
zap.sub?.then(s => s.unsub())
zap = null
}
}
const onBodyClick = e => { const onBodyClick = e => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
@ -207,7 +317,10 @@
onMount(() => { onMount(() => {
const interval = setInterval(setBorderHeight, 300) const interval = setInterval(setBorderHeight, 300)
return () => clearInterval(interval) return () => {
clearInterval(interval)
cleanupZap()
}
}) })
</script> </script>
@ -282,12 +395,20 @@
<button class="fa fa-reply cursor-pointer" on:click={startReply} /> <button class="fa fa-reply cursor-pointer" on:click={startReply} />
{$repliesCount} {$repliesCount}
</div> </div>
<div class={cx('w-16', {'text-accent': like})}> <div class="w-16" class:text-accent={like}>
<button <button
class={cx('fa fa-heart cursor-pointer', {'fa-beat fa-beat-custom': like})} class={cx('fa fa-heart cursor-pointer', {'fa-beat fa-beat-custom': like})}
on:click={() => like ? deleteReaction(like) : react("+")} /> on:click={() => like ? deleteReaction(like) : react("+")} />
{$likesCount} {$likesCount}
</div> </div>
<div class="w-20" class:text-accent={zapped}>
<button
class={cx("fa fa-bolt cursor-pointer", {
'pointer-events-none opacity-50': !canZap,
})}
on:click={startZap} />
{formatSats($zapsTotal)}
</div>
<div class="w-16"> <div class="w-16">
<button class="fa fa-flag cursor-pointer" on:click={() => react("-")} /> <button class="fa fa-flag cursor-pointer" on:click={() => react("-")} />
{$flagsCount} {$flagsCount}
@ -359,4 +480,36 @@
</Modal> </Modal>
{/if} {/if}
{#if zap}
<Modal onEscape={cleanupZap}>
<Content size="lg">
<div class="text-center">
<h1 class="staatliches text-2xl">Send a zap</h1>
<p>to {displayPerson($person)}</p>
</div>
{#if zap.invoice}
<canvas class="m-auto" bind:this={zapCanvas} />
<Input value={zap.invoice}>
<button slot="after" class="fa fa-copy" on:click={copyZapInvoice} />
</Input>
<div class="text-center text-light">
Copy or scan using a lightning wallet to pay your zap.
</div>
{:else}
<Textarea bind:value={zap.message} placeholder="Add an optional message" />
<div class="flex items-center gap-2">
<label class="flex-grow">Custom amount:</label>
<Input bind:value={zap.amount}>
<i slot="before" class="fa fa-bolt" />
<span slot="after" class="-mt-1">sats</span>
</Input>
<Anchor loading={zap.loading} type="button-accent" on:click={loadZapInvoice}>
Zap!
</Anchor>
</div>
{/if}
</Content>
</Modal>
{/if}
{/if} {/if}