From c06b4c5c776c245993e34b3b1690db0291e55462 Mon Sep 17 00:00:00 2001 From: Jonathan Staab Date: Tue, 28 Feb 2023 16:01:38 -0600 Subject: [PATCH] Add zaps --- CHANGELOG.md | 4 + ROADMAP.md | 17 ++-- package-lock.json | Bin 597373 -> 617023 bytes package.json | 3 + src/agent/cmd.ts | 12 ++- src/agent/network.ts | 18 ++-- src/agent/sync.ts | 32 ++++++- src/agent/user.ts | 2 + src/partials/Input.svelte | 9 +- src/util/misc.ts | 19 +++- src/util/nostr.ts | 7 +- src/util/types.ts | 1 + src/views/Settings.svelte | 9 ++ src/views/notes/Note.svelte | 175 +++++++++++++++++++++++++++++++++--- 14 files changed, 273 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 591a14df..d9545ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.15 + +- [x] Add zaps + ## 0.2.14 - [x] Improve paste support diff --git a/ROADMAP.md b/ROADMAP.md index b9fa1410..e9ce8f18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # Current +- [ ] Try adding boxes/separation on feeds based on user feedback - [ ] Strip zero width spaces from compose - [ ] Fix iOS/safari/firefox - [ ] 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/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 - [ ] Add customize icon and route with editable custom view cards using "lists" nip @@ -28,6 +22,15 @@ # 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 - [ ] Add webtorrent support - https://coracle.social/nevent1qqsxgxcsq5vevy4wdty5z5v88nhwp2fc5qgl0ws5rmamn6z72hwv3qcpyfmhxue69uhkummnw3ez6an9wf5kv6t9vsh8wetvd3hhyer9wghxuet5qk6c9q diff --git a/package-lock.json b/package-lock.json index 8a0558ff92ce2c58e8211f702285625a9a448481..0b9816c3c697832fb975495c618d1886431bd5f3 100644 GIT binary patch delta 10160 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 delta 643 zcmX|;T}YEr7{@v1J?FgV)v0r)W=_|{O6QhRBwpo5`GHsjNkxWXh&0JG?7}Xl_KoF7 zG2sy>Vr3u51fket22SsqHzrA>NJd~cfd&~ab=ixA_rD)J|KH=E-4_~59l1>017yhk zfQH|0q}WKw(b%7ibWquE5$Q&B%R{eBFTz@u5}`? zBuHitqSr>d!Vt2ltqq19+zb;{gn=#)fwz=~TBMRy`hyb^0Lnj=j3c1Wa9s9qAg@#-AW2&_7#5i*5p zfPr~31NW||CWx$6CFDkP1GLr>j-@`)WW@d{F?;|+tKyQx8-K;4dgS%$$r@akQ*Y)# z?=NA5t~6={J;!$#$ro|Zo=%gXf0s1jXsq1iP#zG`V85V)EgmHjD)vh|ybOpGsJzY- z;7dSMfjOUB;B&E(3=LDl0vk&*3R+##0DbXVDs+VOGq*MGEQ^Ej9>uur;wiHnJB|Kp zGLQwBtUGyyY}xl*Vj}t{Wd+BK_tJi3r)vbf*wR7d6ggNvD(9>cDg1U4VmCbRzm5J&kv diff --git a/package.json b/package.json index e2dca72c..87e1eb47 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,14 @@ "@fortawesome/fontawesome-free": "^6.2.1", "@noble/secp256k1": "^1.7.0", "@tsconfig/svelte": "^3.0.0", + "bech32": "^2.0.0", + "bolt11": "^1.4.0", "classnames": "^2.3.2", "compressorjs": "^1.1.1", "fuse.js": "^6.6.2", "hurdak": "github:ConsignCloud/hurdak", "husky": "^8.0.3", + "js-lnurl": "^0.5.1", "localforage": "^1.10.0", "localforage-memoryStorageDriver": "^0.9.2", "nostr-tools": "^1.4.1", diff --git a/src/agent/cmd.ts b/src/agent/cmd.ts index 9fac9dcd..0971a02f 100644 --- a/src/agent/cmd.ts +++ b/src/agent/cmd.ts @@ -93,6 +93,16 @@ const createReply = (note, content, mentions = [], topics = []) => { 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 => new PublishableEvent(5, {tags: ids.map(id => ["e", id])}) @@ -119,5 +129,5 @@ class PublishableEvent { export default { updateUser, setRelays, setPetnames, muffle, createRoom, updateRoom, createChatMessage, createDirectMessage, createNote, createReaction, - createReply, deleteEvent, + createReply, requestZap, deleteEvent, PublishableEvent, } diff --git a/src/agent/network.ts b/src/agent/network.ts index ae79e34d..d1cc43e6 100644 --- a/src/agent/network.ts +++ b/src/agent/network.ts @@ -1,5 +1,5 @@ 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 {log} from 'src/util/logger' import {chunk} from 'hurdak/lib/hurdak' @@ -148,7 +148,7 @@ const streamContext = ({notes, onChunk, depth = 0}) => while (events.length > 0 && depth > 0) { const chunk = events.splice(0) const authors = getStalePubkeys(pluck('pubkey', chunk)) - const filter = [{kinds: [1, 7], '#e': pluck('id', chunk)}] as Array + const filter = [{kinds: [1, 7, 9735], '#e': pluck('id', chunk)}] as Array const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren))) // Load authors and reactions in one subscription @@ -171,22 +171,26 @@ const streamContext = ({notes, onChunk, depth = 0}) => ) const applyContext = (notes, context) => { - const [replies, reactions] = partition( - propEq('kind', 1), - context.map(assoc('isContext', true)) - ) + context = 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 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 combinedReactions = reactions.concat(reactionsByParentId[note.id] || []) + const combinedZaps = zaps.concat(zapsByParentId[note.id] || []) return { ...note, replies: uniqBy(prop('id'), combinedReplies).map(annotate), reactions: uniqBy(prop('id'), combinedReactions), + zaps: uniqBy(prop('id'), combinedZaps), } } diff --git a/src/agent/sync.ts b/src/agent/sync.ts index 53346967..a551e867 100644 --- a/src/agent/sync.ts +++ b/src/agent/sync.ts @@ -1,8 +1,9 @@ import {uniq, pick, identity, isEmpty} from 'ramda' import {nip05} from 'nostr-tools' +import {getParams} from 'js-lnurl' import {noop, createMap, ensurePlural, chunk, switcherFn} from 'hurdak/lib/hurdak' 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 database from 'src/agent/database' @@ -45,8 +46,10 @@ const processProfileEvents = async events => { if (e.created_at > (person.kind0_updated_at || 0)) { if (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 { @@ -304,4 +307,29 @@ const verifyNip05 = (pubkey, as) => } }, 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} diff --git a/src/agent/user.ts b/src/agent/user.ts index 945382b7..38566f22 100644 --- a/src/agent/user.ts +++ b/src/agent/user.ts @@ -25,6 +25,7 @@ const anonRelays = synced('agent/user/anonRelays', []) const settings = synced("agent/user/settings", { relayLimit: 20, + defaultZap: 21, showMedia: true, reportAnalytics: true, dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL, @@ -91,6 +92,7 @@ const user = { canPublish, getProfile: () => profileCopy, getPubkey: () => profileCopy?.pubkey, + canZap: () => profileCopy?.zapper, muffle: events => { const muffle = user.getMuffle() diff --git a/src/partials/Input.svelte b/src/partials/Input.svelte index b09fb179..cf3be648 100644 --- a/src/partials/Input.svelte +++ b/src/partials/Input.svelte @@ -1,8 +1,9 @@ -
- + {#if $$slots.before}
{/if} {#if $$slots.after} -
+
{/if} diff --git a/src/util/misc.ts b/src/util/misc.ts index d94be990..f0f4e608 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -1,8 +1,10 @@ +import {bech32} from 'bech32' +import {Buffer} from 'buffer' import {debounce, throttle} from 'throttle-debounce' 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 {writable} from 'svelte/store' -import {isObject} from 'hurdak/lib/hurdak' +import {isObject, round} from 'hurdak/lib/hurdak' import {warn} from 'src/util/logger' export const fuzzy = (data, opts = {}) => { @@ -327,3 +329,18 @@ export const uploadFile = (url, fileObj) => { 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' +} diff --git a/src/util/nostr.ts b/src/util/nostr.ts index 001f61ba..023c743e 100644 --- a/src/util/nostr.ts +++ b/src/util/nostr.ts @@ -1,5 +1,5 @@ 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 {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak' @@ -31,6 +31,9 @@ export class Tags { pubkeys() { return this.type("p").values().all() } + asMeta() { + return fromPairs(this.tags) + } values() { 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 asDisplayEvent = event => - ({replies: [], reactions: [], ...event}) as DisplayEvent + ({replies: [], reactions: [], zaps: [], ...event}) as DisplayEvent export const toHex = (data: string): string | null => { try { diff --git a/src/util/types.ts b/src/util/types.ts index f77c7b04..371a574f 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -28,6 +28,7 @@ export type MyEvent = Event & { export type DisplayEvent = MyEvent & { replies: Array reactions: Array + zaps: Array } export type Room = { diff --git a/src/views/Settings.svelte b/src/views/Settings.svelte index 8ad60a4c..e67e1608 100644 --- a/src/views/Settings.svelte +++ b/src/views/Settings.svelte @@ -46,6 +46,15 @@ in any note.

+
+
+ Default zap amount + +
+

+ The default amount of sats to use when sending a lightning tip. +

+
Max relays per request diff --git a/src/views/notes/Note.svelte b/src/views/notes/Note.svelte index 4a2af146..9071a8e8 100644 --- a/src/views/notes/Note.svelte +++ b/src/views/notes/Note.svelte @@ -1,15 +1,20 @@ @@ -282,12 +395,20 @@
-
+
+
+