Merge branch 'zaps' into 'main'
Nostr Zaps See merge request soapbox-pub/soapbox!2920
This commit is contained in:
commit
17e28e2720
12 changed files with 161 additions and 28 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 };
|
|
@ -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>;
|
||||
|
|
|
@ -24,7 +24,8 @@
|
|||
"types": [
|
||||
"vite/client",
|
||||
"vitest/globals",
|
||||
"vite-plugin-compile-time/client"
|
||||
"vite-plugin-compile-time/client",
|
||||
"@webbtc/webln-types"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue