Merge branch 'zaps' into 'main'

Nostr Zaps

See merge request soapbox-pub/soapbox!2920
This commit is contained in:
Alex Gleason 2024-01-23 01:07:06 +00:00
commit 17e28e2720
12 changed files with 161 additions and 28 deletions

View file

@ -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",

View file

@ -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,
};

View file

@ -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]);
}
};

View file

@ -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<IStatusActionBar> = ({
}
};
const handleZapClick: React.EventHandler<React.MouseEvent> = (e) => {
if (me) {
dispatch(zap(status, 1337));
} else {
onOpenUnauthorizedModal('ZAP');
}
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status));
};
@ -694,6 +703,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
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<typeof HStack>['space'];
@ -781,6 +791,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
)}
{(acceptsZaps && window.webln) && (
<StatusActionButton
title={intl.formatMessage(messages.zap)}
icon={require('@tabler/icons/bolt.svg')}
color='accent'
filled
onClick={handleZapClick}
active={status.zapped}
text={withLabels ? intl.formatMessage(messages.zap) : undefined}
theme={statusActionButtonTheme}
/>
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}

View file

@ -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.",

View file

@ -82,6 +82,7 @@ export const StatusRecord = ImmutableRecord({
uri: '',
url: '',
visibility: 'public' as StatusVisibility,
zapped: false,
event: null as ReturnType<typeof EventRecord> | null,
// Internal fields

View file

@ -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:

View file

@ -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),

View file

@ -36,4 +36,12 @@ const connectRequestSchema = z.object({
params: z.tuple([eventTemplateSchema]).or(z.tuple([eventTemplateSchema, signEventOptsSchema])),
});
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema };
/** 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 };

View file

@ -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<typeof baseStatusSchema>;

View file

@ -24,7 +24,8 @@
"types": [
"vite/client",
"vitest/globals",
"vite-plugin-compile-time/client"
"vite-plugin-compile-time/client",
"@webbtc/webln-types"
]
}
}

View file

@ -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"