diff --git a/package.json b/package.json index f0861eb6..2695f0c6 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@capacitor/keyboard": "^7.0.0", "@capacitor/push-notifications": "^7.0.1", "@capawesome/capacitor-badge": "^7.0.1", + "@getalby/sdk": "^5.1.0", "@poppanator/sveltekit-svg": "^4.2.1", "@sentry/browser": "^8.35.0", "@sveltejs/adapter-static": "^3.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a9bb837..e635e955 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/commands.ts b/src/app/commands.ts index cb013437..173fa3b6 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -1,3 +1,4 @@ +import {nwc} from "@getalby/sdk" import * as nip19 from "nostr-tools/nip19" import {get} from "svelte/store" import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib" @@ -55,6 +56,8 @@ import { getThunkError, } from "@welshman/app" import { + wallet, + getWebLn, PROTECTED, userMembership, INDEXER_RELAYS, @@ -443,3 +446,19 @@ export const makeAlert = async (params: AlertParams) => { export const publishAlert = async (params: AlertParams) => publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) + +export const payInvoice = async (invoice: string) => { + const $wallet = get(wallet) + + if (!$wallet) { + throw new Error("No wallet is connected") + } + + if ($wallet.type === "nwc") { + return new nwc.NWCClient($wallet.info).payInvoice({invoice}) + } else if ($wallet.type === "webln") { + return getWebLn() + .enable() + .then(() => getWebLn().sendPayment(invoice)) + } +} diff --git a/src/app/components/ChannelMessage.svelte b/src/app/components/ChannelMessage.svelte index fa03213e..1d9da7ce 100644 --- a/src/app/components/ChannelMessage.svelte +++ b/src/app/components/ChannelMessage.svelte @@ -11,6 +11,7 @@ import ThunkStatus from "@app/components/ThunkStatus.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte" + import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte" import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte" import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte" import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte" @@ -94,6 +95,7 @@ +{/if} diff --git a/src/app/components/ProfileLink.svelte b/src/app/components/ProfileLink.svelte index 5279abae..b9cbd66d 100644 --- a/src/app/components/ProfileLink.svelte +++ b/src/app/components/ProfileLink.svelte @@ -9,14 +9,15 @@ type Props = { pubkey: string url?: string + class?: string unstyled?: boolean } - const {pubkey, url, unstyled}: Props = $props() + const {pubkey, url, unstyled, ...props}: Props = $props() const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) - diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 14fac6ed..e4e263f7 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -1,9 +1,10 @@ -{#if $reactions.length > 0 || $reports.length > 0} +{#if $reactions.length > 0 || $zaps.length || $reports.length > 0}
{#if url && $reports.length > 0} {/if} + {#each groupedZaps.entries() as [key, zaps]} + {@const amount = sum(zaps.map(zap => zap.invoiceAmount))} + {@const pubkeys = zaps.map(zap => zap.request.pubkey)} + {@const isOwn = $pubkey && pubkeys.includes($pubkey)} + {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} + {@const tooltip = `${info} zapped`} + + {/each} {#each groupedReactions.entries() as [key, events]} {@const pubkeys = events.map(e => e.pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)} diff --git a/src/app/components/WalletConnect.svelte b/src/app/components/WalletConnect.svelte new file mode 100644 index 00000000..b52385e0 --- /dev/null +++ b/src/app/components/WalletConnect.svelte @@ -0,0 +1,155 @@ + + +
+ + {#snippet title()} +
Connect a Wallet
+ {/snippet} + {#snippet info()} + Use Nostr Wallet Connect to send Bitcoin payments over Lightning. + {/snippet} +
+ {#if getWebLn()} + + Or + {/if} + + {#snippet label()} + Connection Secret* + {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} + You can find this in any wallet that supports + Nostr Wallet Connect. + {/snippet} + + + + + +
diff --git a/src/app/components/WalletDisconnect.svelte b/src/app/components/WalletDisconnect.svelte new file mode 100644 index 00000000..16835d09 --- /dev/null +++ b/src/app/components/WalletDisconnect.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/app/components/Zap.svelte b/src/app/components/Zap.svelte new file mode 100644 index 00000000..3f4218ae --- /dev/null +++ b/src/app/components/Zap.svelte @@ -0,0 +1,167 @@ + + +
+ + {#snippet title()} +
Send a Zap
+ {/snippet} + {#snippet info()} +
To
+ {/snippet} +
+ + {#snippet label()} + Emoji Reaction + {/snippet} + {#snippet input()} +
+ + {content} + +
+ {/snippet} +
+ + {#snippet label()} + Amount + {/snippet} + {#snippet input()} +
+ +
+ {/snippet} +
+ + + + + +
diff --git a/src/app/state.ts b/src/app/state.ts index 57610a52..50010cc2 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -37,6 +37,7 @@ import { CLIENT_AUTH, AUTH_JOIN, REACTION, + ZAP_REQUEST, ZAP_RESPONSE, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE, @@ -134,7 +135,7 @@ export const REACTION_KINDS = [REACTION, ZAP_RESPONSE] export const NIP46_PERMS = "nip44_encrypt,nip44_decrypt," + - [CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION] + [CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST] .map(k => `sign_event:${k}`) .join(",") @@ -346,6 +347,39 @@ export const { load: makeOutboxLoader(SETTINGS), }) +// Wallets + +export type WebLNInfo = { + methods?: string[] + supports?: string[] + version?: string + node?: { + alias: string + } +} + +export type NWCInfo = { + lud16: string + secret: string + relayUrl: string + walletPubkey: string + nostrWalletConnectUrl: string +} + +export type Wallet = + | { + type: "webln" + info: WebLNInfo + } + | { + type: "nwc" + info: NWCInfo + } + +export const wallet = synced("wallet", undefined) + +export const getWebLn = () => (window as any).webln + // Alerts export type Alert = { diff --git a/src/assets/icons/QR Code.svg b/src/assets/icons/QR Code.svg new file mode 100644 index 00000000..6eb3412d --- /dev/null +++ b/src/assets/icons/QR Code.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/Wallet.svg b/src/assets/icons/Wallet.svg new file mode 100644 index 00000000..a8b57f70 --- /dev/null +++ b/src/assets/icons/Wallet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/components/EmojiButton.svelte b/src/lib/components/EmojiButton.svelte index 4e931f2f..714c3933 100644 --- a/src/lib/components/EmojiButton.svelte +++ b/src/lib/components/EmojiButton.svelte @@ -20,7 +20,7 @@ if (popover) { const {x, y, width, height} = popover.popper.getBoundingClientRect() - if (!between([x, x + width], clientX) || !between([y - 30, y + height + 30], clientY)) { + if (!between([x, x + width], clientX) || !between([y - 100, y + height + 100], clientY)) { popover.hide() } } diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte index 39321de0..518986a0 100644 --- a/src/lib/components/Icon.svelte +++ b/src/lib/components/Icon.svelte @@ -68,6 +68,7 @@ import Pallete2 from "@assets/icons/Pallete 2.svg?dataurl" import Paperclip from "@assets/icons/Paperclip.svg?dataurl" import Plain from "@assets/icons/Plain.svg?dataurl" + import QRCode from "@assets/icons/QR Code.svg?dataurl" import QuestionSquare from "@assets/icons/Question Square.svg?dataurl" import RemoteControllerMinimalistic from "@assets/icons/Remote Controller Minimalistic.svg?dataurl" import Rocket2 from "@assets/icons/Rocket 2.svg?dataurl" @@ -90,6 +91,7 @@ import UserHeart from "@assets/icons/User Heart.svg?dataurl" import UserCircle from "@assets/icons/User Circle.svg?dataurl" import UserRounded from "@assets/icons/User Rounded.svg?dataurl" + import Wallet from "@assets/icons/Wallet.svg?dataurl" import Widget from "@assets/icons/Widget.svg?dataurl" import WidgetAdd from "@assets/icons/Widget Add.svg?dataurl" import WiFiRouterRound from "@assets/icons/Wi-Fi Router Round.svg?dataurl" @@ -168,6 +170,7 @@ "pallete-2": Pallete2, paperclip: Paperclip, plain: Plain, + "qr-code": QRCode, "question-square": QuestionSquare, reply: Reply, "remote-controller-minimalistic": RemoteControllerMinimalistic, @@ -190,6 +193,7 @@ "user-heart": UserHeart, "user-circle": UserCircle, "user-rounded": UserRounded, + wallet: Wallet, widget: Widget, "widget-add": WidgetAdd, "wifi-router-round": WiFiRouterRound, diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index 2f7f753c..00da258c 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -28,6 +28,11 @@ Profile
+
+ + Wallet + +
Relays diff --git a/src/routes/settings/wallet/+page.svelte b/src/routes/settings/wallet/+page.svelte new file mode 100644 index 00000000..15bd9300 --- /dev/null +++ b/src/routes/settings/wallet/+page.svelte @@ -0,0 +1,87 @@ + + +
+
+
+ + + Wallet + + {#if $wallet} +
+ + Connected +
+ {:else} + + {/if} +
+
+ {#if $wallet} + {#if $wallet?.type === "webln"} + {@const {node, version} = $wallet.info} +
+

+ Connected to {node?.alias || version || "unknown wallet"} + via {$wallet.type} +

+

+ Balance: + {#await getWebLn() + ?.enable() + .then(() => getWebLn().getBalance())} + + {:then res} + {new Intl.NumberFormat(LOCALE).format(res?.balance || 0)} + {:catch} + [unknown] + {/await} + sats +

+
+ {:else if $wallet.type === "nwc"} + {@const {lud16, relayUrl, nostrWalletConnectUrl} = $wallet.info} +
+

+ Connected to {lud16} via {displayRelayUrl(relayUrl)} +

+

+ Balance: + {#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()} + + {:then res} + {new Intl.NumberFormat(LOCALE).format(res?.balance || 0)} + {:catch} + [unknown] + {/await} + sats +

+
+ {/if} + + {:else} +

No wallet connected

+ {/if} +
+
+
diff --git a/src/routes/spaces/[relay]/[room]/+page.svelte b/src/routes/spaces/[relay]/[room]/+page.svelte index 0555183a..5a3ff0f8 100644 --- a/src/routes/spaces/[relay]/[room]/+page.svelte +++ b/src/routes/spaces/[relay]/[room]/+page.svelte @@ -13,6 +13,7 @@ MESSAGE, DELETE, REACTION, + ZAP_RESPONSE, ROOM_ADD_USER, ROOM_REMOVE_USER, } from "@welshman/util" @@ -226,7 +227,9 @@ element: element!, relays: [url], feedFilters: [filter], - subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}], + subscriptionFilters: [ + {kinds: [DELETE, REACTION, ZAP_RESPONSE, MESSAGE], "#h": [room], since: now()}, + ], initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]), onExhausted: () => { loadingEvents = false diff --git a/src/routes/spaces/[relay]/calendar/+page.svelte b/src/routes/spaces/[relay]/calendar/+page.svelte index 773d1a84..c07b739a 100644 --- a/src/routes/spaces/[relay]/calendar/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/+page.svelte @@ -5,7 +5,7 @@ import {page} from "$app/stores" import {now, last, formatTimestampAsDate} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {REACTION, DELETE, EVENT_TIME, getTagValue} from "@welshman/util" + import {REACTION, ZAP_RESPONSE, DELETE, EVENT_TIME, getTagValue} from "@welshman/util" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" @@ -93,7 +93,9 @@ onMount(() => { const feedFilters = [{kinds: [EVENT_TIME]}] - const subscriptionFilters = [{kinds: [DELETE, REACTION, EVENT_TIME], since: now()}] + const subscriptionFilters = [ + {kinds: [DELETE, REACTION, ZAP_RESPONSE, EVENT_TIME], since: now()}, + ] ;({events, cleanup} = makeCalendarFeed({ element: element!, diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index d5610946..183a1574 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -5,7 +5,7 @@ import type {Readable} from "svelte/store" import {now, formatTimestampAsDate} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" - import {makeEvent, MESSAGE, DELETE, REACTION} from "@welshman/util" + import {makeEvent, MESSAGE, DELETE, REACTION, ZAP_RESPONSE} from "@welshman/util" import {pubkey, publishThunk} from "@welshman/app" import {slide, fade, fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" @@ -174,7 +174,7 @@ element: element!, relays: [url], feedFilters: [filter], - subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], since: now()}], + subscriptionFilters: [{kinds: [DELETE, REACTION, ZAP_RESPONSE, MESSAGE], since: now()}], initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]), onExhausted: () => { loadingEvents = false diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index 684144dd..695f35e8 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -3,7 +3,15 @@ import {page} from "$app/stores" import {sortBy, max, nthEq} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {THREAD, REACTION, DELETE, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util" + import { + THREAD, + REACTION, + ZAP_RESPONSE, + DELETE, + COMMENT, + getListTags, + getPubkeyTagValues, + } from "@welshman/util" import {userMutes} from "@welshman/app" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" @@ -49,7 +57,7 @@ relays: [url], feedFilters: [{kinds: [THREAD, COMMENT]}], subscriptionFilters: [ - {kinds: [THREAD, REACTION, DELETE]}, + {kinds: [THREAD, REACTION, ZAP_RESPONSE, DELETE]}, {kinds: [COMMENT], "#K": [String(THREAD)]}, ], initialEvents: getEventsForUrl(url, [{kinds: [THREAD, COMMENT], limit: 10}]),