diff --git a/package.json b/package.json index 7b2c0e028..c71e853dc 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", @@ -92,6 +96,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/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx index 5af959cd7..ab40f164e 100644 --- a/src/components/dropdown-menu/dropdown-menu.tsx +++ b/src/components/dropdown-menu/dropdown-menu.tsx @@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu'; import { closeModal, openModal } from 'soapbox/actions/modals'; import { useAppDispatch } from 'soapbox/hooks'; -import { isUserTouching } from 'soapbox/is-mobile'; +import { userTouching } from 'soapbox/is-mobile'; import { IconButton, Portal } from '../ui'; @@ -53,8 +53,6 @@ const DropdownMenu = (props: IDropdownMenu) => { const arrowRef = useRef(null); - const isOnMobile = isUserTouching(); - const { x, y, strategy, refs, middlewareData, placement } = useFloating({ placement: initialPlacement, middleware: [ @@ -92,7 +90,7 @@ const DropdownMenu = (props: IDropdownMenu) => { * On mobile screens, let's replace the Popper dropdown with a Modal. */ const handleOpen = () => { - if (isOnMobile) { + if (userTouching.matches) { dispatch( openModal('ACTIONS', { status: filteredProps.status, @@ -113,7 +111,7 @@ const DropdownMenu = (props: IDropdownMenu) => { const handleClose = () => { (refs.reference.current as HTMLButtonElement)?.focus(); - if (isOnMobile) { + if (userTouching.matches) { dispatch(closeModal('ACTIONS')); } else { closeDropdownMenu(); 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 && ( = ({ statusId, chi clearTimeout(timeout.current); } - if (!isUserTouching()) { + if (!userTouching.matches) { setVisible(true); } }; @@ -51,7 +51,7 @@ const StatusReactionWrapper: React.FC = ({ statusId, chi // Unless the user is touching, delay closing the emoji selector briefly // so the user can move the mouse diagonally to make a selection. - if (isUserTouching()) { + if (userTouching.matches) { setVisible(false); } else { timeout.current = setTimeout(() => { @@ -73,7 +73,7 @@ const StatusReactionWrapper: React.FC = ({ statusId, chi const handleClick: React.EventHandler = e => { const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.name || '👍'; - if (isUserTouching()) { + if (userTouching.matches) { if (ownAccount) { if (visible) { handleReact(meEmojiReact); diff --git a/src/features/compose/components/privacy-dropdown.tsx b/src/features/compose/components/privacy-dropdown.tsx index e26c8c6e4..1d46c3c0a 100644 --- a/src/features/compose/components/privacy-dropdown.tsx +++ b/src/features/compose/components/privacy-dropdown.tsx @@ -11,7 +11,7 @@ import { closeModal, openModal } from 'soapbox/actions/modals'; import Icon from 'soapbox/components/icon'; import { IconButton } from 'soapbox/components/ui'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; -import { isUserTouching } from 'soapbox/is-mobile'; +import { userTouching } from 'soapbox/is-mobile'; import Motion from '../../ui/util/optional-motion'; @@ -173,7 +173,7 @@ const PrivacyDropdown: React.FC = ({ const onModalClose = () => dispatch(closeModal('ACTIONS')); const handleToggle: React.MouseEventHandler = (e) => { - if (isUserTouching()) { + if (userTouching.matches) { if (open) { onModalClose(); } else { diff --git a/src/features/crypto-donate/components/lightning-address.tsx b/src/features/crypto-donate/components/lightning-address.tsx new file mode 100644 index 000000000..aa52d61f3 --- /dev/null +++ b/src/features/crypto-donate/components/lightning-address.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import CopyableInput from 'soapbox/components/copyable-input'; +import { Text, Stack, HStack, Emoji } from 'soapbox/components/ui'; + +export interface ILightningAddress { + address: string; +} + +const LightningAddress: React.FC = (props): JSX.Element => { + const { address } = props; + + return ( + + + + + + + + + + + + ); +}; + +export default LightningAddress; diff --git a/src/features/ui/components/modals/media-modal.tsx b/src/features/ui/components/modals/media-modal.tsx index 1f406cbbd..6f78a8ed5 100644 --- a/src/features/ui/components/modals/media-modal.tsx +++ b/src/features/ui/components/modals/media-modal.tsx @@ -15,7 +15,7 @@ import PlaceholderStatus from 'soapbox/features/placeholder/components/placehold import Thread from 'soapbox/features/status/components/thread'; import Video from 'soapbox/features/video'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { isUserTouching } from 'soapbox/is-mobile'; +import { userTouching } from 'soapbox/is-mobile'; import { makeGetStatus } from 'soapbox/selectors'; import ImageLoader from '../image-loader'; @@ -104,7 +104,7 @@ const MediaModal: React.FC = (props) => { const getIndex = () => index !== null ? index : props.index; const toggleNavigation = () => { - setNavigationHidden(value => !value && isUserTouching()); + setNavigationHidden(value => !value && userTouching.matches); }; const handleStatusClick: React.MouseEventHandler = e => { diff --git a/src/features/ui/components/profile-field.tsx b/src/features/ui/components/profile-field.tsx index 540d29806..45a801678 100644 --- a/src/features/ui/components/profile-field.tsx +++ b/src/features/ui/components/profile-field.tsx @@ -4,12 +4,13 @@ import { defineMessages, useIntl, FormatDateOptions } from 'react-intl'; import Markup from 'soapbox/components/markup'; import { HStack, Icon } from 'soapbox/components/ui'; -import { CryptoAddress } from 'soapbox/features/ui/util/async-components'; +import { CryptoAddress, LightningAddress } from 'soapbox/features/ui/util/async-components'; import type { Account } from 'soapbox/schemas'; const getTicker = (value: string): string => (value.match(/\$([a-zA-Z]*)/i) || [])[1]; const isTicker = (value: string): boolean => Boolean(getTicker(value)); +const isZapEmoji = (value: string) => /^\u26A1[\uFE00-\uFE0F]?$/.test(value); const messages = defineMessages({ linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, @@ -39,6 +40,8 @@ const ProfileField: React.FC = ({ field }) => { address={field.value_plain} /> ); + } else if (isZapEmoji(field.name)) { + return ; } return ( diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 8f63658bb..952f3c639 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -102,6 +102,7 @@ export const CryptoDonate = lazy(() => import('soapbox/features/crypto-donate')) export const CryptoDonatePanel = lazy(() => import('soapbox/features/crypto-donate/components/crypto-donate-panel')); export const CryptoAddress = lazy(() => import('soapbox/features/crypto-donate/components/crypto-address')); export const CryptoDonateModal = lazy(() => import('soapbox/features/ui/components/modals/crypto-donate-modal')); +export const LightningAddress = lazy(() => import('soapbox/features/crypto-donate/components/lightning-address')); export const ScheduledStatuses = lazy(() => import('soapbox/features/scheduled-statuses')); export const UserIndex = lazy(() => import('soapbox/features/admin/user-index')); export const FederationRestrictions = lazy(() => import('soapbox/features/federation-restrictions')); diff --git a/src/is-mobile.ts b/src/is-mobile.ts index bbe38a123..cada4d479 100644 --- a/src/is-mobile.ts +++ b/src/is-mobile.ts @@ -1,5 +1,3 @@ -import { supportsPassiveEvents } from 'detect-passive-events'; - /** Breakpoint at which the application is considered "mobile". */ const LAYOUT_BREAKPOINT = 630; @@ -11,20 +9,7 @@ export function isMobile(width: number) { /** Whether the device is iOS (best guess). */ const iOS: boolean = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; -let userTouching = false; -const listenerOptions = supportsPassiveEvents ? { passive: true } as EventListenerOptions : false; - -function touchListener(): void { - userTouching = true; - window.removeEventListener('touchstart', touchListener, listenerOptions); -} - -window.addEventListener('touchstart', touchListener, listenerOptions); - -/** Whether the user has touched the screen since the page loaded. */ -export function isUserTouching(): boolean { - return userTouching; -} +export const userTouching = window.matchMedia('(pointer: coarse)'); /** Whether the device is iOS (best guess). */ export function isIOS(): boolean { diff --git a/src/locales/en.json b/src/locales/en.json index c0669f953..3b5804357 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -528,6 +528,7 @@ "confirmations.scheduled_status_delete.message": "Are you sure you want to discard this scheduled post?", "confirmations.unfollow.confirm": "Unfollow", "copy.success": "Copied to clipboard!", + "crypto.lightning": "Lightning", "crypto_donate.explanation_box.message": "{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!", "crypto_donate.explanation_box.title": "Sending cryptocurrency donations", "crypto_donate_panel.actions.view": "Click to see {count, plural, one {# wallet} other {# wallets}}", @@ -1453,6 +1454,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.", diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index 9ae7933bc..4e5941a3d 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -82,6 +82,7 @@ export const StatusRecord = ImmutableRecord({ uri: '', url: '', visibility: 'public' as StatusVisibility, + zapped: false, event: null as ReturnType | null, // Internal fields diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index 4eff74506..9083e4e66 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -30,6 +30,8 @@ import { DISLIKE_REQUEST, UNDISLIKE_REQUEST, DISLIKE_FAIL, + ZAP_REQUEST, + ZAP_FAIL, } from '../actions/interactions'; import { STATUS_CREATE_REQUEST, @@ -234,6 +236,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; @@ -288,6 +302,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 555badab0..014820d34 100644 --- a/src/schemas/account.ts +++ b/src/schemas/account.ts @@ -7,7 +7,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'; @@ -30,6 +30,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 ec70d9234..7906e8f98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,6 +2914,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"