Add NoteActions

This commit is contained in:
Jonathan Staab 2023-04-11 17:18:37 -05:00
parent 9920655666
commit 7d1c2999eb
6 changed files with 509 additions and 496 deletions

View File

@ -4,6 +4,8 @@
- Split out Note pieces
- Move global modals to child components?
- Combine app/agent, rename app2
- [ ] Improve topic suggestions and rendering
- [ ] Add topic search
- [ ] Relays bounty
- [ ] Ability to create custom feeds
- [ ] Add global/following/network tabs to relay detail

View File

@ -5,7 +5,6 @@ import {partition, sortBy, prop, always, pluck, without, is} from "ramda"
import {throttle} from "throttle-debounce"
import {writable} from "svelte/store"
import {ensurePlural, noop, createMap} from "hurdak/lib/hurdak"
import {log} from "src/util/logger"
import {Tags} from "src/util/nostr"
import user from "src/agent/user"
@ -185,13 +184,7 @@ export const watch = (names, f) => {
return store
}
export const dropAll = async () => {
for (const table of Object.values(registry)) {
await table.drop()
log(`Successfully dropped table ${table.name}`)
}
}
export const dropAll = () => new Promise(resolve => loki.deleteDatabase(resolve))
// ----------------------------------------------------------------------------
// Domain-specific collections

View File

@ -1,47 +1,23 @@
<script lang="ts">
import cx from "classnames"
import {nip19} from "nostr-tools"
import {sortBy, identity, find, sum, last, whereEq, pluck, reject, propEq} from "ramda"
import {find, last} from "ramda"
import {onMount} from "svelte"
import {tweened} from "svelte/motion"
import {quantify} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {Tags, displayRelay, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
import {
stringToHue,
hsl,
formatTimestamp,
now,
tryJson,
formatSats,
fetchJson,
} from "src/util/misc"
import {isMobile, copyToClipboard} from "src/util/html"
import {invoiceAmount} from "src/util/lightning"
import QRCode from "src/partials/QRCode.svelte"
import OverflowMenu from "src/partials/OverflowMenu.svelte"
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import CopyValue from "src/partials/CopyValue.svelte"
import Content from "src/partials/Content.svelte"
import Badge from "src/partials/Badge.svelte"
import {findRootId, findReplyId, displayPerson} from "src/util/nostr"
import {formatTimestamp} from "src/util/misc"
import {isMobile} from "src/util/html"
import Popover from "src/partials/Popover.svelte"
import Modal from "src/partials/Modal.svelte"
import Anchor from "src/partials/Anchor.svelte"
import PersonCircle from "src/app2/shared/PersonCircle.svelte"
import PersonSummary from "src/app2/shared/PersonSummary.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import NoteReply from "src/app2/shared/NoteReply.svelte"
import {toast, modal} from "src/app/ui"
import NoteActions from "src/app2/shared/NoteActions.svelte"
import {modal} from "src/app/ui"
import Card from "src/partials/Card.svelte"
import user from "src/agent/user"
import pool from "src/agent/pool"
import keys from "src/agent/keys"
import network from "src/agent/network"
import {getEventPublishRelays, getRelaysForEventParent} from "src/agent/relays"
import {getRelaysForEventParent} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import cmd from "src/agent/cmd"
import {routes} from "src/app/ui"
import NoteContent from "src/app2/shared/NoteContent.svelte"
@ -54,78 +30,22 @@
export let showContext = false
export let invertColors = false
let zap = null
let reply = null
let actions = null
let visibleNotes = []
let showDetails = false
let collapsed = false
const {profile, canPublish, mutes} = user
const {mutes} = user
const timestamp = formatTimestamp(note.created_at)
const borderColor = invertColors ? "gray-6" : "gray-7"
const showEntire = anchorId === note.id
const interactive = !anchorId || !showEntire
const person = watch("people", () => getPersonWithFallback(note.pubkey))
const nevent = nip19.neventEncode({id: note.id, relays: [note.seen_on]})
const bech32Note = nip19.noteEncode(note.id)
const author = watch("people", () => getPersonWithFallback(note.pubkey))
let likes, zaps, like, border, childrenContainer, noteContainer, canZap, actions
let muted = false
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const likesCount = tweened(0, {interpolate})
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate})
let border, childrenContainer, noteContainer
$: muted = find(m => m[1] === note.id, $mutes)
$: likes = note.reactions.filter(n => isLike(n.content))
$: zaps = note.zaps
.map(zap => {
const zapMeta = Tags.from(zap).asMeta()
return tryJson(() => ({
...zap,
invoiceAmount: invoiceAmount(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 {invoiceAmount, request} = zap
const reqMeta = Tags.from(request).asMeta()
// Verify that the zapper actually sent the requested amount (if it was supplied)
if (reqMeta.amount && parseInt(reqMeta.amount) !== invoiceAmount) {
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)
$: zapped = find(z => z.request.pubkey === $profile?.pubkey, zaps)
$: $likesCount = likes.length
$: $zapsTotal = sum(zaps.map(zap => zap.invoiceAmount)) / 1000
$: $repliesCount = note.replies.length
$: canZap = $person?.zapper && $person?.pubkey !== user.getPubkey()
$: visibleNotes = note.replies.filter(r => {
if (feedRelay && !r.seen_on.includes(feedRelay.url)) {
return false
@ -134,29 +54,6 @@
return showContext ? true : !r.isContext
})
$: {
actions = []
actions.push({label: "Share", icon: "share-nodes", onClick: copyLink})
actions.push({label: "Quote", icon: "quote-left", onClick: quote})
if (muted) {
actions.push({label: "Unmute", icon: "microphone", onClick: unmute})
} else {
actions.push({label: "Mute", icon: "microphone-slash", onClick: mute})
}
if (pool.forceUrls.length === 0) {
actions.push({
label: "Details",
icon: "info",
onClick: () => {
showDetails = true
},
})
}
}
const onClick = e => {
const target = e.target as HTMLElement
@ -177,112 +74,6 @@
modal.set({type: "note/detail", note: {id: findRootId(note)}, relays})
}
const mute = async () => {
user.addMute("e", note.id)
}
const unmute = async () => {
user.removeMute(note.id)
}
const react = async content => {
const relays = getEventPublishRelays(note)
const [event] = await cmd.createReaction(note, content).publish(relays)
likes = likes.concat(event)
}
const deleteReaction = e => {
cmd.deleteEvent([e.id]).publish(getEventPublishRelays(note))
likes = reject(propEq("pubkey", $profile.pubkey), likes)
}
const startZap = async () => {
zap = {
amount: user.getSetting("defaultZap"),
message: "",
invoice: null,
loading: false,
startedAt: now(),
confirmed: false,
}
}
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}`)
// If they closed the dialog before fetch resolved, we're done
if (!zap) {
return
}
if (!res.pr) {
throw new Error(JSON.stringify(res))
}
zap.invoice = res.pr
zap.loading = false
// Open up alby or whatever
const {webln} = window as {webln?: any}
if (webln) {
await webln.enable()
try {
webln.sendPayment(zap.invoice)
} catch (e) {
warn(e)
}
}
// 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)
zap.confirmed = true
setTimeout(cleanupZap, 1000)
},
})
}
const cleanupZap = () => {
if (zap) {
zap.sub?.then(s => s.unsub())
zap = null
}
}
const copyLink = () => {
const nevent = nip19.neventEncode({id: note.id, relays: note.seen_on})
copyToClipboard("nostr:" + nevent)
toast.show("info", "Note link copied to clipboard!")
}
const quote = () => {
const nevent = nip19.neventEncode({id: note.id, relays: note.seen_on})
modal.set({type: "note/create", nevent})
}
const setBorderHeight = () => {
const getHeight = e => e?.getBoundingClientRect().height || 0
@ -306,278 +97,132 @@
}
onMount(() => {
const interval = setInterval(setBorderHeight, 300)
const interval = setInterval(setBorderHeight, 400)
return () => {
clearInterval(interval)
cleanupZap()
actions.cleanupZap()
}
})
</script>
{#if $person}
<div bind:this={noteContainer} class="note group relative">
<Card class="relative flex gap-4" on:click={onClick} {interactive} {invertColors}>
{#if !showParent}
<div
class={`absolute -ml-4 h-px w-4 bg-${borderColor} z-10`}
style="left: 0px; top: 27px;" />
{/if}
<div>
<Anchor class="text-lg font-bold" href={routes.person($person.pubkey)}>
<PersonCircle size={10} person={$person} />
</Anchor>
</div>
<div class="flex min-w-0 flex-grow flex-col gap-2">
<div class="flex flex-col items-start justify-between sm:flex-row sm:items-center">
<Popover triggerType={isMobile ? "click" : "mouseenter"}>
<div slot="trigger">
<Anchor
type="unstyled"
class="flex items-center gap-2 pr-16 text-lg font-bold sm:pr-0"
href={isMobile ? null : routes.person($person.pubkey)}>
<span>{displayPerson($person)}</span>
{#if $person.verified_as}
<i class="fa fa-circle-check text-sm text-accent" />
{/if}
</Anchor>
</div>
<div slot="tooltip">
<PersonSummary pubkey={$person.pubkey} />
</div>
</Popover>
<Anchor
href={"/" + nip19.neventEncode({id: note.id, relays: note.seen_on})}
class="text-sm text-gray-1"
type="unstyled">
{timestamp}
</Anchor>
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
{#if findReplyId(note) && showParent}
<small class="text-gray-1">
<i class="fa fa-code-merge" />
<Anchor on:click={goToParent}>View Parent</Anchor>
</small>
{/if}
{#if findRootId(note) && findRootId(note) !== findReplyId(note) && showParent}
<small class="text-gray-1">
<i class="fa fa-code-pull-request" />
<Anchor on:click={goToRoot}>View Thread</Anchor>
</small>
{/if}
</div>
{#if muted}
<p class="border-l-2 border-solid border-gray-6 pl-4 text-gray-1">
You have muted this note.
</p>
{:else}
<NoteContent {note} {showEntire} />
{/if}
<div class="flex justify-between text-gray-1">
<div
class={cx("flex", {
"pointer-events-none opacity-75": !$canPublish || muted,
})}>
<button class="w-16 text-left" on:click|stopPropagation={reply.start}>
<i class="fa fa-reply cursor-pointer" />
{$repliesCount}
</button>
<button
class="w-16 text-left"
class:text-accent={like}
on:click|stopPropagation={() => (like ? deleteReaction(like) : react("+"))}>
<i
class={cx("fa fa-heart cursor-pointer", {
"fa-beat fa-beat-custom": like,
})} />
{$likesCount}
</button>
<button
class={cx("w-20 text-left", {
"pointer-events-none opacity-50": !canZap,
})}
class:text-accent={zapped}
on:click|stopPropagation={startZap}>
<i class="fa fa-bolt cursor-pointer" />
{formatSats($zapsTotal)}
</button>
</div>
<div on:click|stopPropagation class="flex items-center">
{#if pool.forceUrls.length === 0}
<!-- Mobile version -->
<div
style="transform: scale(-1, 1)"
class="absolute top-0 right-0 m-3 grid grid-cols-3 gap-2 sm:hidden">
{#each sortBy(identity, note.seen_on) as url, i}
<div class={`cursor-pointer order-${3 - (i % 3)}`}>
<div
class="h-3 w-3 rounded-full border border-solid border-gray-6"
style={`background: ${hsl(stringToHue(url))}`}
on:click={() => setFeedRelay?.({url})} />
</div>
{/each}
</div>
<!-- Desktop version -->
<div
class={cx("hidden transition-opacity sm:flex", {
"opacity-0 group-hover:opacity-100": !showEntire,
})}>
{#each sortBy(identity, note.seen_on) as url, i}
<Popover triggerType="mouseenter" interactive={false}>
<div slot="trigger" class="cursor-pointer p-1">
<div
class="h-3 w-3 rounded-full border border-solid border-gray-6"
style={`background: ${hsl(stringToHue(url))}`}
on:click={() => setFeedRelay?.({url})} />
</div>
<div slot="tooltip">{displayRelay({url})}</div>
</Popover>
{/each}
</div>
{/if}
<div class="ml-2">
<OverflowMenu {actions} />
</div>
</div>
</div>
</div>
</div>
</Card>
</div>
<NoteReply bind:this={reply} {note} {borderColor} />
{#if !reply?.isActive() && visibleNotes.length > 0 && !showEntire && depth > 0 && !muted}
<div class="relative -mt-4">
<div
class="absolute top-0 right-0 z-10 -mt-4 -mr-2 flex h-6 w-6 cursor-pointer items-center
justify-center rounded-full border border-solid border-gray-7 bg-gray-8 text-gray-3"
on:click={() => {
collapsed = !collapsed
}}>
<Popover triggerType="mouseenter">
<div bind:this={noteContainer} class="note group relative">
<Card class="relative flex gap-4" on:click={onClick} {interactive} {invertColors}>
{#if !showParent}
<div class={`absolute -ml-4 h-px w-4 bg-${borderColor} z-10`} style="left: 0px; top: 27px;" />
{/if}
<div>
<Anchor class="text-lg font-bold" href={routes.person($author.pubkey)}>
<PersonCircle size={10} person={$author} />
</Anchor>
</div>
<div class="flex min-w-0 flex-grow flex-col gap-2">
<div class="flex flex-col items-start justify-between sm:flex-row sm:items-center">
<Popover triggerType={isMobile ? "click" : "mouseenter"}>
<div slot="trigger">
{#if collapsed}
<i class="fa fa-xs fa-up-right-and-down-left-from-center" />
{:else}
<i class="fa fa-xs fa-down-left-and-up-right-to-center" />
{/if}
</div>
<div slot="tooltip">
{collapsed ? "Show replies" : "Hide replies"}
</div>
</Popover>
</div>
</div>
{/if}
{#if !collapsed && visibleNotes.length > 0 && depth > 0 && !muted}
<div class="relative">
<div class={`absolute w-px bg-${borderColor} z-10 -mt-4 ml-4 h-0`} bind:this={border} />
<div class="note-children relative ml-8 flex flex-col gap-4" bind:this={childrenContainer}>
{#if !showEntire && note.replies.length > visibleNotes.length}
<button class="ml-5 cursor-pointer py-2 text-gray-1" on:click={onClick}>
<i class="fa fa-up-down pr-2 text-sm" />
Show {quantify(
note.replies.length - visibleNotes.length,
"other reply",
"more replies"
)}
</button>
{/if}
{#each visibleNotes as r (r.id)}
<svelte:self
showParent={false}
note={r}
depth={depth - 1}
{feedRelay}
{setFeedRelay}
{invertColors}
{anchorId}
{showContext} />
{/each}
</div>
</div>
{/if}
{#if showDetails}
<Modal
onEscape={() => {
showDetails = false
}}>
<Content>
{#if zaps.length > 0}
<h1 class="staatliches text-2xl">Zapped By</h1>
<div class="grid grid-cols-2 gap-2">
{#each zaps as zap}
<div class="flex flex-col gap-1">
<Badge person={getPersonWithFallback(zap.request.pubkey)} />
<span class="ml-6 text-sm text-gray-5"
>{formatSats(zap.invoiceAmount / 1000)} sats</span>
</div>
{/each}
</div>
{/if}
{#if likes.length > 0}
<h1 class="staatliches text-2xl">Liked By</h1>
<div class="grid grid-cols-2 gap-2">
{#each likes as like}
<Badge person={getPersonWithFallback(like.pubkey)} />
{/each}
</div>
{/if}
<h1 class="staatliches text-2xl">Relays</h1>
<p>This note was found on {quantify(note.seen_on.length, "relay")} below.</p>
<div class="flex flex-col gap-2">
{#each note.seen_on as url}
<RelayCard theme="black" relay={{url}} />
{/each}
</div>
<h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Identifier" value={nevent} />
<CopyValue label="Event ID (note)" value={bech32Note} />
<CopyValue label="Event ID (hex)" value={note.id} />
</Content>
</Modal>
{/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.confirmed}
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-champagne-glasses" />
<p>Success! Zap confirmed.</p>
</div>
{:else if zap.invoice}
<QRCode code={zap.invoice} />
<p class="text-center text-gray-1">
Copy or scan using a lightning wallet to pay your zap.
</p>
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-circle-notch fa-spin" />
Waiting for confirmation...
</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
type="unstyled"
class="flex items-center gap-2 pr-16 text-lg font-bold sm:pr-0"
href={isMobile ? null : routes.person($author.pubkey)}>
<span>{displayPerson($author)}</span>
{#if $author.verified_as}
<i class="fa fa-circle-check text-sm text-accent" />
{/if}
</Anchor>
</div>
<div slot="tooltip">
<PersonSummary pubkey={$author.pubkey} />
</div>
</Popover>
<Anchor
href={"/" + nip19.neventEncode({id: note.id, relays: note.seen_on})}
class="text-sm text-gray-1"
type="unstyled">
{timestamp}
</Anchor>
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
{#if findReplyId(note) && showParent}
<small class="text-gray-1">
<i class="fa fa-code-merge" />
<Anchor on:click={goToParent}>View Parent</Anchor>
</small>
{/if}
{#if findRootId(note) && findRootId(note) !== findReplyId(note) && showParent}
<small class="text-gray-1">
<i class="fa fa-code-pull-request" />
<Anchor on:click={goToRoot}>View Thread</Anchor>
</small>
{/if}
</div>
{#if muted}
<p class="border-l-2 border-solid border-gray-6 pl-4 text-gray-1">
You have muted this note.
</p>
{:else}
<NoteContent {note} {showEntire} />
{/if}
</Content>
</Modal>
{/if}
<NoteActions
bind:this={actions}
{note}
{author}
{reply}
{muted}
{setFeedRelay}
{showEntire} />
</div>
</div>
</Card>
</div>
<NoteReply bind:this={reply} {note} {borderColor} />
{#if !reply?.isActive() && visibleNotes.length > 0 && !showEntire && depth > 0 && !muted}
<div class="relative -mt-4">
<div
class="absolute top-0 right-0 z-10 -mt-4 -mr-2 flex h-6 w-6 cursor-pointer items-center
justify-center rounded-full border border-solid border-gray-7 bg-gray-8 text-gray-3"
on:click={() => {
collapsed = !collapsed
}}>
<Popover triggerType="mouseenter">
<div slot="trigger">
{#if collapsed}
<i class="fa fa-xs fa-up-right-and-down-left-from-center" />
{:else}
<i class="fa fa-xs fa-down-left-and-up-right-to-center" />
{/if}
</div>
<div slot="tooltip">
{collapsed ? "Show replies" : "Hide replies"}
</div>
</Popover>
</div>
</div>
{/if}
{#if !collapsed && visibleNotes.length > 0 && depth > 0 && !muted}
<div class="relative">
<div class={`absolute w-px bg-${borderColor} z-10 -mt-4 ml-4 h-0`} bind:this={border} />
<div class="note-children relative ml-8 flex flex-col gap-4" bind:this={childrenContainer}>
{#if !showEntire && note.replies.length > visibleNotes.length}
<button class="ml-5 cursor-pointer py-2 text-gray-1" on:click={onClick}>
<i class="fa fa-up-down pr-2 text-sm" />
Show {quantify(note.replies.length - visibleNotes.length, "other reply", "more replies")}
</button>
{/if}
{#each visibleNotes as r (r.id)}
<svelte:self
showParent={false}
note={r}
depth={depth - 1}
{feedRelay}
{setFeedRelay}
{invertColors}
{anchorId}
{showContext} />
{/each}
</div>
</div>
{/if}

View File

@ -0,0 +1,324 @@
<script lang="ts">
import cx from "classnames"
import {nip19} from "nostr-tools"
import {tweened} from "svelte/motion"
import {find, identity, propEq, pathEq, sum, pluck, sortBy} from "ramda"
import {warn} from "src/util/logger"
import {copyToClipboard} from "src/util/html"
import {stringToHue, fetchJson, now, formatSats, hsl} from "src/util/misc"
import {displayRelay, isLike, displayPerson, processZaps} from "src/util/nostr"
import {quantify, first} from "hurdak/lib/hurdak"
import Popover from "src/partials/Popover.svelte"
import QRCode from "src/partials/QRCode.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
import Anchor from "src/partials/Anchor.svelte"
import OverflowMenu from "src/partials/OverflowMenu.svelte"
import CopyValue from "src/partials/CopyValue.svelte"
import Badge from "src/partials/Badge.svelte"
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import {getEventPublishRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/db"
import network from "src/agent/network"
import pool from "src/agent/pool"
import user from "src/agent/user"
import keys from "src/agent/keys"
import cmd from "src/agent/cmd"
import {toast, modal} from "src/app/ui"
export let note
export let author
export let reply
export let muted
export let showEntire
export let setFeedRelay
const {canPublish} = user
const bech32Note = nip19.noteEncode(note.id)
const nevent = nip19.neventEncode({id: note.id, relays: [note.seen_on]})
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const likesCount = tweened(0, {interpolate})
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate})
const copyLink = () => {
const nevent = nip19.neventEncode({id: note.id, relays: note.seen_on})
copyToClipboard("nostr:" + nevent)
toast.show("info", "Note link copied to clipboard!")
}
const quote = () => modal.set({type: "note/create", nevent})
const mute = () => user.addMute("e", note.id)
const unmute = () => user.removeMute(note.id)
const react = async content => {
like = first(await cmd.createReaction(note, content).publish(getEventPublishRelays(note)))
}
const deleteReaction = e => {
cmd.deleteEvent([e.id]).publish(getEventPublishRelays(note))
like = null
}
const startZap = async () => {
draftZap = {
amount: user.getSetting("defaultZap"),
message: "",
invoice: null,
loading: false,
startedAt: now(),
confirmed: false,
}
}
const loadZapInvoice = async () => {
draftZap.loading = true
const {zapper, lnurl} = $author
const amount = draftZap.amount * 1000
const relays = getEventPublishRelays(note)
const urls = pluck("url", relays)
const publishable = cmd.requestZap(urls, draftZap.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}`)
// If they closed the dialog before fetch resolved, we're done
if (!draftZap) {
return
}
if (!res.pr) {
throw new Error(JSON.stringify(res))
}
draftZap.invoice = res.pr
draftZap.loading = false
// Open up alby or whatever
const {webln} = window as {webln?: any}
if (webln) {
await webln.enable()
try {
webln.sendPayment(draftZap.invoice)
} catch (e) {
warn(e)
}
}
// Listen for the zap confirmation
draftZap.sub = network.listen({
relays,
filter: {
kinds: [9735],
authors: [zapper.nostrPubkey],
"#p": [$author.pubkey],
since: draftZap.startedAt - 10,
},
onChunk: chunk => {
zap = first(chunk)
draftZap.confirmed = true
setTimeout(cleanupZap, 1000)
},
})
}
export const cleanupZap = () => {
if (draftZap) {
draftZap.sub?.then(s => s.unsub())
draftZap = null
}
}
let like, likes, allLikes, zap, zaps, allZaps
let actions = []
let draftZap = null
let showDetails = false
$: likes = note.reactions.filter(n => isLike(n.content))
$: like = like || find(propEq("pubkey", user.getPubkey()), likes)
$: allLikes = like ? likes.filter(n => n.id !== like?.id).concat(like) : likes
$: $likesCount = allLikes.length
$: zaps = processZaps(note.zaps, $author)
$: zap = zap || find(pathEq(["request", "pubkey"], user.getPubkey()), zaps)
$: allZaps = zap ? zaps.filter(n => n.id !== zap?.id).concat(processZaps([zap], $author)) : zaps
$: $zapsTotal = sum(pluck("invoiceAmount", allZaps)) / 1000
$: canZap = $author?.zapper && $author?.pubkey !== user.getPubkey()
$: $repliesCount = note.replies.length
$: {
actions = []
actions.push({label: "Share", icon: "share-nodes", onClick: copyLink})
actions.push({label: "Quote", icon: "quote-left", onClick: quote})
if (muted) {
actions.push({label: "Unmute", icon: "microphone", onClick: unmute})
} else {
actions.push({label: "Mute", icon: "microphone-slash", onClick: mute})
}
if (pool.forceUrls.length === 0) {
actions.push({
label: "Details",
icon: "info",
onClick: () => {
showDetails = true
},
})
}
}
</script>
<div class="flex justify-between text-gray-1">
<div
class={cx("flex", {
"pointer-events-none opacity-75": !$canPublish || muted,
})}>
<button class="w-16 text-left" on:click|stopPropagation={reply.start}>
<i class="fa fa-reply cursor-pointer" />
{$repliesCount}
</button>
<button
class="w-16 text-left"
class:text-accent={like}
on:click|stopPropagation={() => (like ? deleteReaction(like) : react("+"))}>
<i
class={cx("fa fa-heart cursor-pointer", {
"fa-beat fa-beat-custom": like,
})} />
{$likesCount}
</button>
<button
class={cx("w-20 text-left", {
"pointer-events-none opacity-50": !canZap,
})}
class:text-accent={zap}
on:click|stopPropagation={startZap}>
<i class="fa fa-bolt cursor-pointer" />
{formatSats($zapsTotal)}
</button>
</div>
<div on:click|stopPropagation class="flex items-center">
{#if pool.forceUrls.length === 0}
<!-- Mobile version -->
<div
style="transform: scale(-1, 1)"
class="absolute top-0 right-0 m-3 grid grid-cols-3 gap-2 sm:hidden">
{#each sortBy(identity, note.seen_on) as url, i}
<div class={`cursor-pointer order-${3 - (i % 3)}`}>
<div
class="h-3 w-3 rounded-full border border-solid border-gray-6"
style={`background: ${hsl(stringToHue(url))}`}
on:click={() => setFeedRelay?.({url})} />
</div>
{/each}
</div>
<!-- Desktop version -->
<div
class={cx("hidden transition-opacity sm:flex", {
"opacity-0 group-hover:opacity-100": !showEntire,
})}>
{#each sortBy(identity, note.seen_on) as url, i}
<Popover triggerType="mouseenter" interactive={false}>
<div slot="trigger" class="cursor-pointer p-1">
<div
class="h-3 w-3 rounded-full border border-solid border-gray-6"
style={`background: ${hsl(stringToHue(url))}`}
on:click={() => setFeedRelay?.({url})} />
</div>
<div slot="tooltip">{displayRelay({url})}</div>
</Popover>
{/each}
</div>
{/if}
<div class="ml-2">
<OverflowMenu {actions} />
</div>
</div>
</div>
{#if draftZap}
<Modal onEscape={cleanupZap}>
<Content size="lg">
<div class="text-center">
<h1 class="staatliches text-2xl">Send a zap</h1>
<p>to {displayPerson($author)}</p>
</div>
{#if draftZap.confirmed}
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-champagne-glasses" />
<p>Success! Zap confirmed.</p>
</div>
{:else if draftZap.invoice}
<QRCode code={draftZap.invoice} />
<p class="text-center text-gray-1">
Copy or scan using a lightning wallet to pay your zap.
</p>
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-circle-notch fa-spin" />
Waiting for confirmation...
</div>
{:else}
<Textarea bind:value={draftZap.message} placeholder="Add an optional message" />
<div class="flex items-center gap-2">
<label class="flex-grow">Custom amount:</label>
<Input bind:value={draftZap.amount}>
<i slot="before" class="fa fa-bolt" />
<span slot="after" class="-mt-1">sats</span>
</Input>
<Anchor loading={draftZap.loading} type="button-accent" on:click={loadZapInvoice}>
Zap!
</Anchor>
</div>
{/if}
</Content>
</Modal>
{/if}
{#if showDetails}
<Modal
onEscape={() => {
showDetails = false
}}>
<Content>
{#if zaps.length > 0}
<h1 class="staatliches text-2xl">Zapped By</h1>
<div class="grid grid-cols-2 gap-2">
{#each zaps as zap}
<div class="flex flex-col gap-1">
<Badge person={getPersonWithFallback(zap.request.pubkey)} />
<span class="ml-6 text-sm text-gray-5"
>{formatSats(zap.invoiceAmount / 1000)} sats</span>
</div>
{/each}
</div>
{/if}
{#if likes.length > 0}
<h1 class="staatliches text-2xl">Liked By</h1>
<div class="grid grid-cols-2 gap-2">
{#each likes as like}
<Badge person={getPersonWithFallback(like.pubkey)} />
{/each}
</div>
{/if}
<h1 class="staatliches text-2xl">Relays</h1>
<p>This note was found on {quantify(note.seen_on.length, "relay")} below.</p>
<div class="flex flex-col gap-2">
{#each note.seen_on as url}
<RelayCard theme="black" relay={{url}} />
{/each}
</div>
<h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Identifier" value={nevent} />
<CopyValue label="Event ID (note)" value={bech32Note} />
<CopyValue label="Event ID (hex)" value={note.id} />
</Content>
</Modal>
{/if}

View File

@ -79,7 +79,7 @@
const onBodyClick = e => {
const target = e.target as HTMLElement
if (container?.contains(target)) {
if (container && !container.contains(target)) {
reset()
}
}
@ -88,13 +88,17 @@
<svelte:body on:click={onBodyClick} />
{#if data}
<div transition:slide class="note-reply relative z-10 flex flex-col gap-1" bind:this={container}>
<div
transition:slide
class="note-reply relative z-10 flex flex-col gap-1"
bind:this={container}
on:click|stopPropagation>
<div class={`border border-${borderColor} rounded border-solid`}>
<div class="bg-gray-7" class:rounded-b={data.mentions.length === 0}>
<Compose bind:this={reply} onSubmit={send}>
<button
slot="addon"
type="submit"
on:click={send}
class="flex cursor-pointer flex-col justify-center gap-2 border-l border-solid
border-gray-7 p-4 py-8 text-gray-3 transition-all hover:bg-accent">
<i class="fa fa-paper-plane fa-xl" />
@ -130,7 +134,7 @@
{displayPerson(getPersonWithFallback(p))}
</div>
{:else}
<div class="text-gray-1 inline-block">No mentions</div>
<div class="text-gray-3 inline-block py-2">No mentions</div>
{/each}
<div class="-mb-2" />
</div>

View File

@ -2,6 +2,8 @@ import type {DisplayEvent} from "src/util/types"
import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from "ramda"
import {nip19} from "nostr-tools"
import {ensurePlural, ellipsize, first} from "hurdak/lib/hurdak"
import {tryJson} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning"
export const personKinds = [0, 2, 3, 10001, 10002]
export const userKinds = personKinds.concat([10000])
@ -213,8 +215,9 @@ export const parseContent = ({content, tags = []}) => {
try {
const entity = bech32Match[0].replace("nostr:", "")
const {type, data} = nip19.decode(entity) as {type: string; data: object}
const value = type === "note" ? {id: data} : data
push(`nostr:${type}`, bech32Match[0], {...data, entity})
push(`nostr:${type}`, bech32Match[0], {...value, entity})
continue
} catch (e) {
console.log(e)
@ -265,3 +268,45 @@ export const parseContent = ({content, tags = []}) => {
return result
}
export const processZaps = (zaps, author) =>
zaps
.map(zap => {
const zapMeta = Tags.from(zap).asMeta()
return tryJson(() => ({
...zap,
invoiceAmount: invoiceAmount(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 === author.pubkey) {
return false
}
const {invoiceAmount, request} = zap
const reqMeta = Tags.from(request).asMeta()
// Verify that the zapper actually sent the requested amount (if it was supplied)
if (reqMeta.amount && parseInt(reqMeta.amount) !== invoiceAmount) {
return false
}
// If the sending client provided an lnurl tag, verify that too
if (reqMeta.lnurl && reqMeta.lnurl !== author.lnurl) {
return false
}
// Verify that the zap note actually came from the recipient's zapper
if (author.zapper?.nostrPubkey !== zap.pubkey) {
return false
}
return true
})