diff --git a/package.json b/package.json index ba82523fb..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", @@ -91,6 +95,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/actions/interactions.ts b/src/actions/interactions.ts index bfe63801c..cab5cd740 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)); @@ -732,6 +768,8 @@ export { FAVOURITES_EXPAND_FAIL, REBLOGS_EXPAND_SUCCESS, REBLOGS_EXPAND_FAIL, + ZAP_REQUEST, + ZAP_FAIL, reblog, unreblog, toggleReblog, @@ -801,4 +839,5 @@ export { remoteInteractionRequest, remoteInteractionSuccess, remoteInteractionFail, + zap, }; diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 216c3a8e3..c2a0b13a1 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,62 @@ 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; + } + + await window.webln?.enable(); + 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/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index b07550ed7..3ee4f4aab 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 && window.webln) && ( + + )} + {canShare && ( | null, // Internal fields diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index 37d7ec2ad..0b94d228a 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -29,6 +29,8 @@ import { DISLIKE_REQUEST, UNDISLIKE_REQUEST, DISLIKE_FAIL, + ZAP_REQUEST, + ZAP_FAIL, } from '../actions/interactions'; import { STATUS_CREATE_REQUEST, @@ -233,6 +235,18 @@ const simulateDislike = ( return state.set(statusId, updatedStatus); }; +/** Simulate zap of status for optimistic interactions */ +const simulateZap = (state: State, statusId: string, zapped: boolean): State => { + const status = state.get(statusId); + if (!status) return state; + + const updatedStatus = status.merge({ + zapped, + }); + + return state.set(statusId, updatedStatus); +}; + interface Translation { content: string; detected_source_language: string; @@ -287,6 +301,10 @@ 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, 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: 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/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 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; diff --git a/tsconfig.json b/tsconfig.json index 37ba64dce..7b6322fb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "types": [ "vite/client", "vitest/globals", - "vite-plugin-compile-time/client" + "vite-plugin-compile-time/client", + "@webbtc/webln-types" ] } } diff --git a/yarn.lock b/yarn.lock index 26b5253bd..77b106535 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2902,6 +2902,11 @@ "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" +"@webbtc/webln-types@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-3.0.0.tgz#448b2138423865087ba8859e9e6430fc2463b864" + integrity sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"