From 9cad54c97c393f02fc94e816723c7783b500cc43 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 18:53:49 -0600 Subject: [PATCH 1/7] Nostr Zaps --- src/actions/interactions.ts | 37 ++++++++++++++++++++++++++++ src/components/status-action-bar.tsx | 25 ++++++++++++++++++- src/normalizers/status.ts | 1 + src/schemas/account.ts | 5 +++- src/schemas/status.ts | 1 + 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts index bfe63801c..ddd29c2c7 100644 --- a/src/actions/interactions.ts +++ b/src/actions/interactions.ts @@ -78,6 +78,10 @@ const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL'; +const ZAP_REQUEST = 'ZAP_REQUEST'; +const ZAP_SUCCESS = 'ZAP_SUCCESS'; +const ZAP_FAIL = 'ZAP_FAIL'; + const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, @@ -306,6 +310,38 @@ const undislikeFail = (status: StatusEntity, error: unknown) => ({ skipLoading: true, }); +const zap = (status: StatusEntity, amount: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(zapRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.id}/zap`, { amount }).then(function(response) { + dispatch(zapSuccess(status)); + }).catch(function(error) { + dispatch(zapFail(status, error)); + }); + }; + +const zapRequest = (status: StatusEntity) => ({ + type: ZAP_REQUEST, + status: status, + skipLoading: true, +}); + +const zapSuccess = (status: StatusEntity) => ({ + type: ZAP_SUCCESS, + status: status, + skipLoading: true, +}); + +const zapFail = (status: StatusEntity, error: unknown) => ({ + type: ZAP_FAIL, + status: status, + error: error, + skipLoading: true, +}); + const bookmark = (status: StatusEntity) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(bookmarkRequest(status)); @@ -801,4 +837,5 @@ export { remoteInteractionRequest, remoteInteractionSuccess, remoteInteractionFail, + zap, }; diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index b07550ed7..f8d252dc4 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -6,7 +6,7 @@ import { blockAccount } from 'soapbox/actions/accounts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; -import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup } from 'soapbox/actions/interactions'; +import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup, zap } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -103,6 +103,7 @@ const messages = defineMessages({ unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, + zap: { id: 'status.zap', defaultMessage: 'Zap' }, }); interface IStatusActionBar { @@ -188,6 +189,14 @@ const StatusActionBar: React.FC = ({ } }; + const handleZapClick: React.EventHandler = (e) => { + if (me) { + dispatch(zap(status, 1337)); + } else { + onOpenUnauthorizedModal('ZAP'); + } + }; + const handleBookmarkClick: React.EventHandler = (e) => { dispatch(toggleBookmark(status)); }; @@ -694,6 +703,7 @@ const StatusActionBar: React.FC = ({ } const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); + const acceptsZaps = status.account.ditto.accepts_zaps === true; const spacing: { [key: string]: React.ComponentProps['space']; @@ -781,6 +791,19 @@ const StatusActionBar: React.FC = ({ /> )} + {acceptsZaps && ( + + )} + {canShare && ( | null, // Internal fields diff --git a/src/schemas/account.ts b/src/schemas/account.ts index fec6c18b2..a0a718db7 100644 --- a/src/schemas/account.ts +++ b/src/schemas/account.ts @@ -6,7 +6,7 @@ import { unescapeHTML } from 'soapbox/utils/html'; import { customEmojiSchema } from './custom-emoji'; import { relationshipSchema } from './relationship'; -import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import { coerceObject, contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; import type { Resolve } from 'soapbox/utils/types'; @@ -29,6 +29,9 @@ const baseAccountSchema = z.object({ created_at: z.string().datetime().catch(new Date().toUTCString()), discoverable: z.boolean().catch(false), display_name: z.string().catch(''), + ditto: coerceObject({ + accepts_zaps: z.boolean().catch(false), + }), emojis: filteredArray(customEmojiSchema), fields: filteredArray(fieldSchema), followers_count: z.number().catch(0), diff --git a/src/schemas/status.ts b/src/schemas/status.ts index cf66e4e3d..cc370871a 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -67,6 +67,7 @@ const baseStatusSchema = z.object({ uri: z.string().url().catch(''), url: z.string().url().catch(''), visibility: z.string().catch('public'), + zapped: z.coerce.boolean(), }); type BaseStatus = z.infer; From d20d08bc8fdfde7b63578e37d45b5f9d6aa74327 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 18:56:18 -0600 Subject: [PATCH 2/7] Optimistic zap --- src/actions/interactions.ts | 1 + src/reducers/statuses.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts index ddd29c2c7..58a92f6cc 100644 --- a/src/actions/interactions.ts +++ b/src/actions/interactions.ts @@ -768,6 +768,7 @@ export { FAVOURITES_EXPAND_FAIL, REBLOGS_EXPAND_SUCCESS, REBLOGS_EXPAND_FAIL, + ZAP_REQUEST, reblog, unreblog, toggleReblog, diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index 37d7ec2ad..8d336ce6c 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -29,6 +29,7 @@ import { DISLIKE_REQUEST, UNDISLIKE_REQUEST, DISLIKE_FAIL, + ZAP_REQUEST, } from '../actions/interactions'; import { STATUS_CREATE_REQUEST, @@ -233,6 +234,18 @@ const simulateDislike = ( return state.set(statusId, updatedStatus); }; +/** Simulate zap of status for optimistic interactions */ +const simulateZap = (state: State, statusId: string): State => { + const status = state.get(statusId); + if (!status) return state; + + const updatedStatus = status.merge({ + zapped: true, + }); + + return state.set(statusId, updatedStatus); +}; + interface Translation { content: string; detected_source_language: string; @@ -287,6 +300,8 @@ export default function statuses(state = initialState, action: AnyAction): State return state.get(action.status.id) === undefined ? state : state.setIn([action.status.id, 'favourited'], false); case DISLIKE_FAIL: return state.get(action.status.id) === undefined ? state : state.setIn([action.status.id, 'disliked'], false); + case ZAP_REQUEST: + return simulateZap(state, action.status.id); case REBLOG_REQUEST: return state.setIn([action.status.id, 'reblogged'], true); case REBLOG_FAIL: From 459bc723653cd9337f5274d1842de1fec617a718 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 16 Jan 2024 18:35:00 -0600 Subject: [PATCH 3/7] Partially implement NIP-47 and pay for zaps with WebLN --- src/api/hooks/nostr/useSignerStream.ts | 78 ++++++++++++++++++-------- src/schemas/nostr.ts | 10 +++- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 216c3a8e3..56db382b1 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,9 +1,10 @@ import { NiceRelay } from 'nostr-machina'; +import { type Event } from 'nostr-tools'; import { useEffect, useMemo } from 'react'; import { nip04, signEvent } from 'soapbox/features/nostr/sign'; import { useInstance } from 'soapbox/hooks'; -import { connectRequestSchema } from 'soapbox/schemas/nostr'; +import { connectRequestSchema, nwcRequestSchema } from 'soapbox/schemas/nostr'; import { jsonSchema } from 'soapbox/schemas/utils'; function useSignerStream() { @@ -18,35 +19,64 @@ function useSignerStream() { } }, [relayUrl]); - useEffect(() => { + async function handleConnectEvent(event: Event) { + if (!relay || !pubkey) return; + const decrypted = await nip04.decrypt(pubkey, event.content); + + const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted); + if (!reqMsg.success) { + console.warn(decrypted); + console.warn(reqMsg.error); + return; + } + + const respMsg = { + id: reqMsg.data.id, + result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]), + }; + + const respEvent = await signEvent({ + kind: 24133, + content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + relay.send(['EVENT', respEvent]); + } + + async function handleWalletEvent(event: Event) { if (!relay || !pubkey) return; - const sub = relay.req([{ kinds: [24133], authors: [pubkey], limit: 0 }]); + const decrypted = await nip04.decrypt(pubkey, event.content); + + const reqMsg = jsonSchema.pipe(nwcRequestSchema).safeParse(decrypted); + if (!reqMsg.success) { + console.warn(decrypted); + console.warn(reqMsg.error); + return; + } + + // @ts-ignore + await window.webln?.enable(); + // @ts-ignore + await window.webln?.sendPayment(reqMsg.data.params.invoice); + } + + useEffect(() => { + if (!relay || !pubkey) return; + const sub = relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }]); const readEvents = async () => { for await (const event of sub) { - const decrypted = await nip04.decrypt(pubkey, event.content); - - const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; + switch (event.kind) { + case 24133: + await handleConnectEvent(event); + break; + case 23194: + await handleWalletEvent(event); + break; } - - const respMsg = { - id: reqMsg.data.id, - result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]), - }; - - const respEvent = await signEvent({ - kind: 24133, - content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)), - tags: [['p', pubkey]], - created_at: Math.floor(Date.now() / 1000), - }); - - relay.send(['EVENT', respEvent]); } }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index e8aa80e9e..549bd497a 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -36,4 +36,12 @@ const connectRequestSchema = z.object({ params: z.tuple([eventTemplateSchema]).or(z.tuple([eventTemplateSchema, signEventOptsSchema])), }); -export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema }; \ No newline at end of file +/** NIP-47 signer response. */ +const nwcRequestSchema = z.object({ + method: z.literal('pay_invoice'), + params: z.object({ + invoice: z.string(), + }), +}); + +export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema, nwcRequestSchema }; \ No newline at end of file From 797fca71115013a070399636006e5338db950baa Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 14:37:44 -0600 Subject: [PATCH 4/7] Add WebLN types, only show zap button when WebLN is available --- package.json | 1 + src/api/hooks/nostr/useSignerStream.ts | 2 -- src/components/status-action-bar.tsx | 2 +- tsconfig.json | 3 ++- yarn.lock | 5 +++++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ba82523fb..a9807be70 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@types/semver": "^7.3.9", "@types/uuid": "^9.0.0", "@vitejs/plugin-react": "^4.0.4", + "@webbtc/webln-types": "^3.0.0", "autoprefixer": "^10.4.15", "axios": "^1.2.2", "axios-mock-adapter": "^1.22.0", diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 56db382b1..c2a0b13a1 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -57,9 +57,7 @@ function useSignerStream() { return; } - // @ts-ignore await window.webln?.enable(); - // @ts-ignore await window.webln?.sendPayment(reqMsg.data.params.invoice); } diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index f8d252dc4..3ee4f4aab 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -791,7 +791,7 @@ const StatusActionBar: React.FC = ({ /> )} - {acceptsZaps && ( + {(acceptsZaps && window.webln) && ( Date: Mon, 22 Jan 2024 15:07:33 -0600 Subject: [PATCH 5/7] yarn i18n --- src/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/locales/en.json b/src/locales/en.json index c0669f953..2d005746d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1453,6 +1453,7 @@ "status.unmute_conversation": "Unmute Conversation", "status.unpin": "Unpin from profile", "status.unpin_to_group": "Unpin from Group", + "status.zap": "Zap", "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", "statuses.quote_tombstone": "Post is unavailable.", "statuses.tombstone": "One or more posts are unavailable.", From 0e7e49ee80dc404c25928fe293cc09e94cc118b2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 15:57:59 -0600 Subject: [PATCH 6/7] Add a pkgzap entry to package.json, because why not https://github.com/getAlby/pkgzap/blob/main/cli/README.md --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index a9807be70..fa0170378 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "bugs": { "url": "https://gitlab.com/soapbox-pub/soapbox/-/issues" }, + "funding": { + "type": "lightning", + "url": "lightning:alex@alexgleason.me" + }, "scripts": { "start": "npx vite serve", "dev": "${npm_execpath} run start", From d4490f4e80c4efe9c2a42c2163b93f4208b7daf7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 16:12:30 -0600 Subject: [PATCH 7/7] Fix optimistic zapping --- src/actions/interactions.ts | 1 + src/reducers/statuses.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts index 58a92f6cc..cab5cd740 100644 --- a/src/actions/interactions.ts +++ b/src/actions/interactions.ts @@ -769,6 +769,7 @@ export { REBLOGS_EXPAND_SUCCESS, REBLOGS_EXPAND_FAIL, ZAP_REQUEST, + ZAP_FAIL, reblog, unreblog, toggleReblog, diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index 8d336ce6c..0b94d228a 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -30,6 +30,7 @@ import { UNDISLIKE_REQUEST, DISLIKE_FAIL, ZAP_REQUEST, + ZAP_FAIL, } from '../actions/interactions'; import { STATUS_CREATE_REQUEST, @@ -235,12 +236,12 @@ const simulateDislike = ( }; /** Simulate zap of status for optimistic interactions */ -const simulateZap = (state: State, statusId: string): State => { +const simulateZap = (state: State, statusId: string, zapped: boolean): State => { const status = state.get(statusId); if (!status) return state; const updatedStatus = status.merge({ - zapped: true, + zapped, }); return state.set(statusId, updatedStatus); @@ -301,7 +302,9 @@ export default function statuses(state = initialState, action: AnyAction): State case DISLIKE_FAIL: return state.get(action.status.id) === undefined ? state : state.setIn([action.status.id, 'disliked'], false); case ZAP_REQUEST: - return simulateZap(state, action.status.id); + return simulateZap(state, action.status.id, true); + case ZAP_FAIL: + return simulateZap(state, action.status.id, false); case REBLOG_REQUEST: return state.setIn([action.status.id, 'reblogged'], true); case REBLOG_FAIL: