Merge branch 'create-wallet' into 'main'

Cashu wallet

See merge request soapbox-pub/soapbox!3333
merge-requests/3365/head
Alex Gleason 2025-04-22 15:35:22 +00:00
commit 75e1e9ecd6
45 zmienionych plików z 1854 dodań i 135 usunięć

Wyświetl plik

@ -48,6 +48,7 @@ import DropdownMenu from 'soapbox/components/dropdown-menu/index.ts';
import PureStatusReactionWrapper from 'soapbox/components/pure-status-reaction-wrapper.tsx';
import StatusActionButton from 'soapbox/components/status-action-button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import { useZapCashuRequest } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useDislike } from 'soapbox/hooks/useDislike.ts';
@ -179,6 +180,9 @@ const PureStatusActionBar: React.FC<IPureStatusActionBar> = ({
const features = useFeatures();
const { boostModal, deleteModal } = useSettings();
const { zapCashuList } = useZapCashuRequest();
const isZappedCashu = zapCashuList.some((zapCashu)=> zapCashu === status.id);
const { account } = useOwnAccount();
const isStaff = account ? account.staff : false;
const isAdmin = account ? account.admin : false;
@ -239,9 +243,9 @@ const PureStatusActionBar: React.FC<IPureStatusActionBar> = ({
const handleZapClick: React.EventHandler<React.MouseEvent> = (e) => {
if (me) {
dispatch(openModal('ZAP_PAY_REQUEST', { status, account: status.account }));
dispatch(openModal('PAY_REQUEST', { status, account: status.account }));
} else {
onOpenUnauthorizedModal('ZAP_PAY_REQUEST');
onOpenUnauthorizedModal('PAY_REQUEST');
}
};
@ -759,6 +763,7 @@ const PureStatusActionBar: React.FC<IPureStatusActionBar> = ({
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
const acceptsZaps = status.account.ditto.accepts_zaps === true;
const acceptsZapsCashu = status.account.ditto.accepts_zaps_cashu === true;
const spacing: {
[key: string]: React.ComponentProps<typeof HStack>['space'];
@ -842,16 +847,16 @@ const PureStatusActionBar: React.FC<IPureStatusActionBar> = ({
/>
)}
{(acceptsZaps) && (
{(acceptsZaps || acceptsZapsCashu) && (
<StatusActionButton
title={intl.formatMessage(messages.zap)}
icon={boltIcon}
color='accent'
filled
onClick={handleZapClick}
active={status.zapped}
active={status.zapped_cashu || status.zapped || isZappedCashu}
theme={statusActionButtonTheme}
count={status?.zaps_amount ? status.zaps_amount / 1000 : 0}
count={(status?.zaps_amount ?? 0) / 1000 + (status?.zaps_amount_cashu ?? 0)}
/>
)}

Wyświetl plik

@ -13,6 +13,7 @@ import plusIcon from '@tabler/icons/outline/plus.svg';
import settingsIcon from '@tabler/icons/outline/settings.svg';
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import walletIcon from '@tabler/icons/outline/wallet.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -213,6 +214,13 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
onClick={onClose}
/>
<SidebarLink
to={'/wallet'}
icon={walletIcon}
text={<FormattedMessage id='tabs_bar.wallet' defaultMessage='Wallet' />}
onClick={onClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'

Wyświetl plik

@ -18,6 +18,7 @@ import messagesIcon from '@tabler/icons/outline/messages.svg';
import settingsIcon from '@tabler/icons/outline/settings.svg';
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import walletIcon from '@tabler/icons/outline/wallet.svg';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
@ -196,6 +197,13 @@ const SidebarNavigation = () => {
text={<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />}
/>
<SidebarNavigationLink
to={'/wallet'}
icon={walletIcon}
activeIcon={walletIcon}
text={<FormattedMessage id='tabs_bar.wallet' defaultMessage='Wallet' />}
/>
<SidebarNavigationLink
to='/settings'
icon={settingsIcon}

Wyświetl plik

@ -49,6 +49,7 @@ import DropdownMenu from 'soapbox/components/dropdown-menu/index.ts';
import StatusActionButton from 'soapbox/components/status-action-button.tsx';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import { useZapCashuRequest } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
@ -174,6 +175,9 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const features = useFeatures();
const { boostModal, deleteModal } = useSettings();
const { zapCashuList } = useZapCashuRequest();
const isZappedCashu = zapCashuList.some((zapCashu)=> zapCashu === status.id);
const { account } = useOwnAccount();
const isStaff = account ? account.staff : false;
const isAdmin = account ? account.admin : false;
@ -227,9 +231,9 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleZapClick: React.EventHandler<React.MouseEvent> = (e) => {
if (me) {
dispatch(openModal('ZAP_PAY_REQUEST', { status, account: status.account }));
dispatch(openModal('PAY_REQUEST', { status, account: status.account }));
} else {
onOpenUnauthorizedModal('ZAP_PAY_REQUEST');
onOpenUnauthorizedModal('PAY_REQUEST');
}
};
@ -749,6 +753,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 acceptsZapsCashu = status.account.ditto.accepts_zaps_cashu === true;
const spacing: {
[key: string]: React.ComponentProps<typeof HStack>['space'];
@ -832,16 +837,16 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
)}
{(acceptsZaps) && (
{(acceptsZaps || acceptsZapsCashu) && (
<StatusActionButton
title={intl.formatMessage(messages.zap)}
icon={boltIcon}
color='accent'
filled
onClick={handleZapClick}
active={status.zapped}
active={status.zapped_cashu || status.zapped || isZappedCashu}
theme={statusActionButtonTheme}
count={status?.zaps_amount ? status.zaps_amount / 1000 : 0}
count={(status?.zaps_amount ?? 0) / 1000 + (status?.zaps_amount_cashu ?? 0)}
/>
)}

Wyświetl plik

@ -14,6 +14,8 @@ interface IButton {
children?: React.ReactNode;
/** Extra class names for the button. */
className?: string;
/** Extra class names for the icon. */
iconClassName?: string;
/** Prevent the button from being clicked. */
disabled?: boolean;
/** Specifies the icon element as 'svg' or 'img'. */
@ -49,6 +51,7 @@ const Button = forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element
to,
type = 'button',
className,
iconClassName,
} = props;
const body = text || children;
@ -65,7 +68,7 @@ const Button = forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element
return null;
}
return <Icon src={icon} className='size-4' element={iconElement} />;
return <Icon src={icon} className={clsx('size-4', iconClassName)} element={iconElement} />;
};
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback((event) => {

Wyświetl plik

@ -78,7 +78,7 @@ const Tooltip: React.FC<ITooltip> = (props) => {
left: x ?? 0,
...styles,
}}
className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900'
className='pointer-events-none z-[100] max-w-[200px] whitespace-normal rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900 sm:max-w-[300px]'
{...getFloatingProps()}
>
{text}

Wyświetl plik

@ -44,6 +44,7 @@ import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note.tsx';
import ActionButton from 'soapbox/features/ui/components/action-button.tsx';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button.tsx';
import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
@ -99,7 +100,7 @@ const messages = defineMessages({
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' },
zap: { id: 'zap.send_to', defaultMessage: 'Send zaps to {target}' },
method: { id: 'payment_method.send_to', defaultMessage: 'Send sats via {method} to {target}' },
});
interface IHeader {
@ -115,6 +116,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
const { account: ownAccount } = useOwnAccount();
const { follow } = useFollow();
const { method: paymentMethod } = usePaymentMethod();
const { software } = useAppSelector((state) => parseVersion(state.instance.version));
const { getOrCreateChatByAccountId } = useChats();
@ -310,7 +313,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
};
const handleZapAccount: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(openModal('ZAP_PAY_REQUEST', { account }));
dispatch(openModal('PAY_REQUEST', { account }));
};
const makeMenu = () => {
@ -666,10 +669,10 @@ const Header: React.FC<IHeader> = ({ account }) => {
<IconButton
src={boltIcon}
onClick={handleZapAccount}
title={intl.formatMessage(messages.zap, { target: account.display_name })}
title={intl.formatMessage(messages.method, { target: account.display_name, method: paymentMethod })}
theme='outlined'
className='px-2'
iconClassName='h-4 w-4'
iconClassName='size-4'
/>
);
};
@ -677,6 +680,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
const info = makeInfo();
const menu = makeMenu();
const acceptsZaps = account.ditto.accepts_zaps === true;
const acceptsZapsCashu = account.ditto.accepts_zaps_cashu === true;
return (
<div>
@ -718,7 +722,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
<SubscriptionButton account={account} />
{renderMessageButton()}
{renderShareButton()}
{acceptsZaps && renderZapAccount()}
{(acceptsZaps || acceptsZapsCashu) && renderZapAccount()}
{menu.length > 0 && (
<DropdownMenu items={menu} placement='bottom-end'>

Wyświetl plik

@ -206,13 +206,13 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
};
const getZaps = () => {
if (status.zaps_amount) {
if (status.zaps_amount || status.zaps_amount_cashu) {
return (
<InteractionCounter count={status.zaps_amount / 1000} onClick={handleOpenZapsModal}>
<InteractionCounter count={(status.zaps_amount ?? 0) / 1000 + (status.zaps_amount_cashu ?? 0)} onClick={handleOpenZapsModal}>
<FormattedMessage
id='status.interactions.zaps'
defaultMessage='{count, plural, one {Zap} other {Zaps}}'
values={{ count: status.zaps_amount }}
values={{ count: (status.zaps_amount ?? 0) / 1000 + (status.zaps_amount_cashu ?? 0) }}
/>
</InteractionCounter>
);

Wyświetl plik

@ -43,7 +43,7 @@ import {
UnauthorizedModal,
VideoModal,
EditRuleModal,
ZapPayRequestModal,
PayRequestModal,
ZapSplitModal,
ZapInvoiceModal,
ZapsModal,
@ -89,6 +89,7 @@ const MODAL_COMPONENTS: Record<string, React.ExoticComponent<any>> = {
'NOSTR_LOGIN': NostrLoginModal,
'NOSTR_SIGNUP': NostrSignupModal,
'ONBOARDING': OnboardingModal,
'PAY_REQUEST': PayRequestModal,
'REACTIONS': ReactionsModal,
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,
@ -98,7 +99,6 @@ const MODAL_COMPONENTS: Record<string, React.ExoticComponent<any>> = {
'VIDEO': VideoModal,
'ZAPS': ZapsModal,
'ZAP_INVOICE': ZapInvoiceModal,
'ZAP_PAY_REQUEST': ZapPayRequestModal,
'ZAP_SPLIT': ZapSplitModal,
};

Wyświetl plik

@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'soapbox/components/ui/modal.tsx';
import PayRequestForm from 'soapbox/features/zap/components/pay-request-form.tsx';
import type { Status as StatusEntity, Account as AccountEntity } from 'soapbox/types/entities.ts';
interface IPayRequestModal {
account: AccountEntity;
status?: StatusEntity;
onClose:(type?: string) => void;
}
const PayRequestModal: React.FC<IPayRequestModal> = ({ account, status, onClose }) => {
const onClickClose = () => {
onClose('PAY_REQUEST');
};
return (
<Modal width='lg'>
<PayRequestForm account={account} status={status} onClose={onClickClose} />
</Modal>
);
};
export default PayRequestModal;

Wyświetl plik

@ -45,7 +45,7 @@ const ZapInvoiceModal: React.FC<IZapInvoice> = ({ account, invoice, splitData, o
const { hasZapSplit, zapSplitAccounts, splitValues } = splitData;
const onClickClose = () => {
onClose('ZAP_INVOICE');
dispatch(closeModal('ZAP_PAY_REQUEST'));
dispatch(closeModal('PAY_REQUEST'));
};
const renderTitle = () => {

Wyświetl plik

@ -1,27 +0,0 @@
import React from 'react';
import Modal from 'soapbox/components/ui/modal.tsx';
import ZapPayRequestForm from 'soapbox/features/zap/components/zap-pay-request-form.tsx';
import type { Status as StatusEntity, Account as AccountEntity } from 'soapbox/types/entities.ts';
interface IZapPayRequestModal {
account: AccountEntity;
status?: StatusEntity;
onClose:(type?: string) => void;
}
const ZapPayRequestModal: React.FC<IZapPayRequestModal> = ({ account, status, onClose }) => {
const onClickClose = () => {
onClose('ZAP_PAY_REQUEST');
};
return (
<Modal width='lg'>
<ZapPayRequestForm account={account} status={status} onClose={onClickClose} />
</Modal>
);
};
export default ZapPayRequestModal;

Wyświetl plik

@ -8,6 +8,7 @@ import Modal from 'soapbox/components/ui/modal.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import { useWalletStore, useZappedByCashu } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { shortNumberFormat } from 'soapbox/utils/numbers.tsx';
@ -16,6 +17,7 @@ interface IAccountWithZaps {
id: string;
comment: string;
amount: number;
type: string;
}
interface IZapsModal {
@ -25,16 +27,23 @@ interface IZapsModal {
const ZapsModal: React.FC<IZapsModal> = ({ onClose, statusId }) => {
const dispatch = useAppDispatch();
const zaps = useAppSelector((state) => state.user_lists.zapped_by.get(statusId)?.items);
const next = useAppSelector((state) => state.user_lists.zapped_by.get(statusId)?.next);
const nutzaps = useWalletStore().nutzappedRecord[statusId];
const { nextZaps } = useWalletStore();
const { getNutzappedBy, expandNutzappedBy } = useZappedByCashu();
const accounts = useMemo((): ImmutableList<IAccountWithZaps> | undefined => {
if (!zaps) return;
if (!zaps && !nutzaps) return;
return zaps
.map(({ account, amount, comment }) => ({ id: account, amount, comment }))
.flatten() as ImmutableList<IAccountWithZaps>;
}, [zaps]);
const zappedAccounts = zaps?.map(({ account, amount, comment }) => ({ id: account, amount, comment, type: 'zap' })) || [];
const nutzappedAccounts = nutzaps?.map(({ account, amount, comment }) => ({ id: account.id, amount, comment, type: 'nutzap' })) || [];
const combinedAccounts = [...zappedAccounts, ...nutzappedAccounts];
return ImmutableList(combinedAccounts);
}, [zaps, nutzaps]);
const fetchData = () => {
dispatch(fetchZaps(statusId));
@ -42,6 +51,7 @@ const ZapsModal: React.FC<IZapsModal> = ({ onClose, statusId }) => {
useEffect(() => {
fetchData();
getNutzappedBy(statusId);
}, []);
const onClickClose = () => {
@ -52,11 +62,14 @@ const ZapsModal: React.FC<IZapsModal> = ({ onClose, statusId }) => {
if (next) {
dispatch(expandZaps(statusId, next));
}
if (nextZaps) {
expandNutzappedBy(statusId);
}
};
let body;
if (!zaps || !accounts) {
if (!zaps || !nutzaps || !accounts) {
body = <Spinner />;
} else {
const emptyMessage = <FormattedMessage id='status.zaps.empty' defaultMessage='No one has zapped this post yet. When someone does, they will show up here.' />;
@ -70,13 +83,13 @@ const ZapsModal: React.FC<IZapsModal> = ({ onClose, statusId }) => {
style={{ height: '80vh' }}
useWindowScroll={false}
onLoadMore={handleLoadMore}
hasMore={!!next}
hasMore={!!next || !!nextZaps}
>
{accounts.map((account, index) => {
return (
<div key={index}>
<Text weight='bold'>
{shortNumberFormat(account.amount / 1000)}
{account.type === 'zap' ? shortNumberFormat(account.amount / 1000) : shortNumberFormat(account.amount)}
</Text>
<AccountContainer id={account.id} note={account.comment} emoji='⚡' />
</div>

Wyświetl plik

@ -0,0 +1,76 @@
import arrowsDiagonalIcon from '@tabler/icons/outline/arrows-diagonal-2.svg';
import arrowsDiagonalMinimizeIcon from '@tabler/icons/outline/arrows-diagonal-minimize.svg';
import eyeClosedIcon from '@tabler/icons/outline/eye-closed.svg';
import eyeIcon from '@tabler/icons/outline/eye.svg';
import walletIcon from '@tabler/icons/outline/wallet.svg';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import Balance from 'soapbox/features/wallet/components/balance.tsx';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
const messages = defineMessages({
wallet: { id: 'wallet.title', defaultMessage: 'Wallet' },
balance: { id: 'wallet.sats', defaultMessage: '{amount} sats' },
withdraw: { id: 'wallet.button.withdraw', defaultMessage: 'Withdraw' },
mint: { id: 'wallet.button.mint', defaultMessage: 'Mint' },
});
const PocketWallet = () => {
const intl = useIntl();
const { wallet } = useWallet();
const [expanded, setExpanded] = useState(false);
const [eyeClosed, setEyeClosed] = useState(() => {
const storedData = localStorage.getItem('soapbox:wallet:eye');
return storedData ? JSON.parse(storedData) : false;
});
useEffect(() => {
localStorage.setItem('soapbox:wallet:eye', JSON.stringify(eyeClosed));
}, [eyeClosed]);
return (
<Stack className='rounded-lg border p-2 px-4 black:border-gray-500 dark:border-gray-500' alignItems='center' space={4}>
<HStack className='w-full' justifyContent='between' alignItems='center' >
<HStack space={1} alignItems='center'>
<Icon src={walletIcon} size={20} className='text-gray-200' />
<Text size='lg'>
{intl.formatMessage(messages.wallet)}
</Text>
</HStack>
<HStack alignItems='center' space={2}>
{!expanded && <>
{ eyeClosed ? <Text className='text-sm !text-gray-500'>{intl.formatMessage({ id: 'wallet.hidden.balance', defaultMessage: '••••••' })}</Text> : <Text>
{intl.formatMessage(messages.balance, { amount: wallet?.balance })}
</Text>}
<Button className='!ml-1 space-x-2 !border-none !p-0 !text-gray-500 focus:!ring-transparent focus:ring-offset-transparent rtl:ml-0 rtl:mr-1 rtl:space-x-reverse' theme='transparent' onClick={() => setEyeClosed(!eyeClosed)}>
<Icon src={eyeClosed ? eyeClosedIcon : eyeIcon} className='text-gray-500 hover:cursor-pointer' size={18} />
</Button>
</>}
<Button className='!ml-1 space-x-2 !border-none !p-0 !text-gray-500 focus:!ring-transparent focus:ring-offset-transparent rtl:ml-0 rtl:mr-1 rtl:space-x-reverse' theme='transparent' onClick={() => setExpanded(!expanded)}>
<Icon src={!expanded ? arrowsDiagonalIcon : arrowsDiagonalMinimizeIcon} className='rounded-full bg-secondary-500 p-1 text-white hover:cursor-pointer' size={22} />
</Button>
</HStack>
</HStack>
{expanded &&
<Balance />
}
</Stack>
);
};
export default PocketWallet;

Wyświetl plik

@ -72,6 +72,10 @@ import {
Lists,
Bookmarks,
Settings,
Wallet,
WalletRelays,
WalletMints,
WalletTransactions,
EditProfile,
EditEmail,
EditPassword,
@ -330,6 +334,10 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/settings/mfa' page={DefaultPage} component={MfaForm} exact />
<WrappedRoute path='/settings/tokens' page={DefaultPage} component={AuthTokenList} content={children} />
<WrappedRoute path='/settings' page={DefaultPage} component={Settings} content={children} />
<WrappedRoute path='/wallet' page={DefaultPage} component={Wallet} content={children} exact />
<WrappedRoute path='/wallet/relays' page={DefaultPage} component={WalletRelays} content={children} exact />
<WrappedRoute path='/wallet/mints' page={DefaultPage} component={WalletMints} content={children} exact />
<WrappedRoute path='/wallet/transactions' page={DefaultPage} component={WalletTransactions} content={children} exact />
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
<WrappedRoute path='/soapbox/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />

Wyświetl plik

@ -174,12 +174,17 @@ export const Relays = lazy(() => import('soapbox/features/admin/relays.tsx'));
export const Rules = lazy(() => import('soapbox/features/admin/rules.tsx'));
export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal.tsx'));
export const AdminNostrRelays = lazy(() => import('soapbox/features/admin/nostr-relays.tsx'));
export const ZapPayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/zap-pay-request-modal.tsx'));
export const PayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/pay-request-modal.tsx'));
export const ZapInvoiceModal = lazy(() => import('soapbox/features/ui/components/modals/zap-invoice.tsx'));
export const ZapsModal = lazy(() => import('soapbox/features/ui/components/modals/zaps-modal.tsx'));
export const ZapSplitModal = lazy(() => import('soapbox/features/ui/components/modals/zap-split/zap-split-modal.tsx'));
export const CaptchaModal = lazy(() => import('soapbox/features/ui/components/modals/captcha-modal/captcha-modal.tsx'));
export const NostrBunkerLogin = lazy(() => import('soapbox/features/nostr/nostr-bunker-login.tsx'));
export const Wallet = lazy(() => import('soapbox/features/wallet/index.tsx'));
export const WalletRelays = lazy(() => import('soapbox/features/wallet/components/wallet-relays.tsx'));
export const WalletMints = lazy(() => import('soapbox/features/wallet/components/wallet-mints.tsx'));
export const WalletTransactions = lazy(() => import('soapbox/features/wallet/components/wallet-transactions.tsx'));
export const StreakModal = lazy(() => import('soapbox/features/ui/components/modals/streak-modal.tsx'));
export const FollowsTimeline = lazy(() => import('soapbox/features/home-timeline/follows-timeline.tsx'));
export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx'));
export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx'));
export const PocketWallet = lazy(() => import('soapbox/features/ui/components/pocket-wallet.tsx'));

Wyświetl plik

@ -0,0 +1,266 @@
import cancelIcon from '@tabler/icons/outline/cancel.svg';
import withdrawIcon from '@tabler/icons/outline/cash.svg';
import mIcon from '@tabler/icons/outline/circle-dotted-letter-m.svg';
import creditCardPayIcon from '@tabler/icons/outline/credit-card-pay.svg';
import libraryPlusIcon from '@tabler/icons/outline/library-plus.svg';
import QRCode from 'qrcode.react';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import CopyableInput from 'soapbox/components/copyable-input.tsx';
import Button from 'soapbox/components/ui/button.tsx';
import Divider from 'soapbox/components/ui/divider.tsx';
import FormGroup from 'soapbox/components/ui/form-group.tsx';
import Form from 'soapbox/components/ui/form.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
import { useTransactions, useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useApi } from 'soapbox/hooks/useApi.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { Quote, quoteSchema } from 'soapbox/schemas/wallet.ts';
import toast from 'soapbox/toast.tsx';
const messages = defineMessages({
amount: { id: 'wallet.balance.mint.amount', defaultMessage: 'Amount in sats' },
cancel: { id: 'wallet.button.cancel', defaultMessage: 'Cancel' },
balance: { id: 'wallet.sats', defaultMessage: '{amount} sats' },
withdraw: { id: 'wallet.button.withdraw', defaultMessage: 'Withdraw' },
mint: { id: 'wallet.button.mint', defaultMessage: 'Mint' },
payment: { id: 'wallet.balance.mint.payment', defaultMessage: 'Make the payment to complete:' },
paidMessage: { id: 'wallet.balance.mint.paid_message', defaultMessage: 'Your mint was successful, and your sats are now in your balance. Enjoy!' },
unpaidMessage: { id: 'wallet.balance.mint.unpaid_message', defaultMessage: 'Your mint is still unpaid. Complete the payment to receive your sats.' },
expired: { id: 'wallet.balance.expired', defaultMessage: 'Expired' },
});
interface AmountProps {
amount: number;
onMintClick: () => void;
}
interface NewMintProps {
list: string[];
onBack: () => void;
}
const openExtension = async (invoice: string) => {
try {
await window.webln?.enable();
await window.webln?.sendPayment(invoice);
return undefined;
} catch (e) {
return invoice;
}
};
const Amount = ({ amount, onMintClick }: AmountProps) => {
const intl = useIntl();
return (
<Stack alignItems='center' space={4} className='w-4/5'>
<Text theme='default' weight='semibold' size='3xl'>
{intl.formatMessage(messages.balance, { amount })}
</Text>
<div className='w-full'>
<Divider />
</div>
<HStack space={2}>
<Button icon={withdrawIcon} theme='secondary' text={intl.formatMessage(messages.withdraw)} />
<Button icon={libraryPlusIcon} theme='primary' onClick={onMintClick} text={intl.formatMessage(messages.mint)} />
</HStack>
</Stack>
);
};
const NewMint = ({ onBack, list }: NewMintProps) => {
const [mintAmount, setMintAmount] = useState('');
const [quote, setQuote] = useState<Quote | undefined>(() => {
const storedQuote = localStorage.getItem('soapbox:wallet:quote');
return storedQuote ? JSON.parse(storedQuote) : undefined;
});
const [mintName, setMintName] = useState(list[0]);
const [hasProcessedQuote, setHasProcessedQuote] = useState(false);
const [currentState, setCurrentState] = useState<'loading' | 'paid' | 'default'>('default');
const api = useApi();
const intl = useIntl();
const { getWallet } = useWallet();
const { getTransactions } = useTransactions();
const now = Math.floor(Date.now() / 1000);
const handleClean = useCallback(() => {
onBack();
setQuote(undefined);
setMintAmount('');
setCurrentState('default');
localStorage.removeItem('soapbox:wallet:quote');
}, []);
const checkQuoteStatus = async (quoteId: string): Promise<void> => {
try {
const response = await api.post(`/api/v1/ditto/cashu/mint/${quoteId}`);
const data = await response.json();
if (data.state !== 'ISSUED') {
toast.error(intl.formatMessage(messages.unpaidMessage));
setCurrentState('paid');
} else {
toast.success(intl.formatMessage(messages.paidMessage));
onBack();
getWallet();
getTransactions();
handleClean();
setCurrentState('default');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Something went wrong. Please try again.';
toast.error(errorMessage);
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setCurrentState('loading');
if (!quote) {
try {
const response = await api.post('/api/v1/ditto/cashu/quote', { mint: mintName, amount: Number(mintAmount) });
const newQuote = quoteSchema.parse(await response.json());
localStorage.setItem('soapbox:wallet:quote', JSON.stringify(newQuote));
setQuote(newQuote);
setHasProcessedQuote(true);
if (!(await openExtension(newQuote.request))) checkQuoteStatus(newQuote.quote);
} catch (error) {
console.error('Mint Error:', error);
toast.error('An error occurred while processing the mint.');
}
setCurrentState('paid');
} else {
if (now > quote.expiry) {
toast.error(intl.formatMessage(messages.expired));
handleClean();
} else {
checkQuoteStatus(quote.quote);
}
}
};
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
const index = Number(e.target.value);
const item = list[index];
setMintName(item);
};
useEffect(() => {
const processQuote = async () => {
if (quote && !hasProcessedQuote) {
const invoice = await openExtension(quote.request);
if (invoice === undefined && now < quote.expiry) {
await checkQuoteStatus(quote.quote);
}
if (now > quote.expiry) {
handleClean();
toast.error(intl.formatMessage(messages.expired));
} else {
setCurrentState('paid');
setHasProcessedQuote(true);
}
}
};
processQuote();
}, [quote, hasProcessedQuote]);
const mintList: Record<string, string> = Object.fromEntries(list.map((item, index) => [`${index}`, item]));
const buttonState = {
loading: {
textButton: 'Loading...',
iconButton: '',
},
paid: {
textButton: 'Mint paid',
iconButton: creditCardPayIcon,
},
default: {
textButton: 'Mint',
iconButton: mIcon,
},
};
const { textButton, iconButton } = buttonState[currentState];
return (
<Form onSubmit={handleSubmit}>
<Stack space={6}>
{!quote ? <Stack space={2}>
<FormGroup labelText='Mint URL'>
<SelectDropdown items={mintList} defaultValue={mintList[0]} onChange={handleSelectChange} />
</FormGroup>
<FormGroup labelText={intl.formatMessage(messages.amount)}>
<Input
placeholder='Amount'
type='number'
value={mintAmount}
onChange={(e) => /^[0-9]*$/.test(e.target.value) && setMintAmount(e.target.value)}
autoCapitalize='off'
required
/>
</FormGroup>
</Stack>
: <Stack space={3} justifyContent='center' alignItems='center'>
<Text>
{intl.formatMessage(messages.payment)}
</Text>
<QRCode className='rounded-lg' value={quote.request} includeMargin />
<CopyableInput value={quote.request} />
</Stack>
}
<HStack grow space={2} justifyContent='center'>
<Button icon={cancelIcon} theme='danger' text={intl.formatMessage(messages.cancel)} onClick={handleClean} />
<Button icon={iconButton} type='submit' theme='primary' text={textButton} />
</HStack>
</Stack>
</Form>
);
};
const Balance = () => {
const { wallet } = useWallet();
const [amount, setAmount] = useState(0);
const [mints, setMints] = useState<string[]>([]);
const { account } = useOwnAccount();
const [current, setCurrent] = useState<keyof typeof items>('balance');
const items = {
balance: <Amount amount={amount} onMintClick={() => setCurrent('newMint')} />,
newMint: <NewMint onBack={() => setCurrent('balance')} list={mints} />,
};
useEffect(
() => {
if (wallet){
setMints([...wallet.mints]);
setAmount(wallet.balance);
}
}, [wallet],
);
if (!account) {
return null;
}
return (<>
{items[current]}
</>
);
};
export default Balance;

Wyświetl plik

@ -0,0 +1,140 @@
import helpIcon from '@tabler/icons/outline/help-circle.svg';
import plusIcon from '@tabler/icons/outline/square-rounded-plus.svg';
import { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import FormActions from 'soapbox/components/ui/form-actions.tsx';
import Form from 'soapbox/components/ui/form.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import Tooltip from 'soapbox/components/ui/tooltip.tsx';
import { MintEditor } from 'soapbox/features/wallet/components/editable-lists.tsx';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
const messages = defineMessages({
title: { id: 'wallet.create_wallet.title', defaultMessage: 'You don\'t have a wallet' },
question: { id: 'wallet.create_wallet.question', defaultMessage: 'Do you want create one?' },
button: { id: 'wallet.button.create_wallet', defaultMessage: 'Create' },
mints: { id: 'wallet.mints', defaultMessage: 'Mints' },
});
const CreateWallet = () => {
const intl = useIntl();
const { account } = useOwnAccount();
const [formActive, setFormActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [mints, setMints] = useState<string[]>([]);
const { createWallet } = useWallet();
const handleSubmit = async () => {
setIsLoading(true);
const walletInfo = {
mints: mints,
relays: [],
};
await createWallet(walletInfo);
setIsLoading(false);
};
if (!account) {
return null;
}
return (
<Stack
className='rounded-lg border border-gray-200 p-8
dark:border-gray-700'
space={4}
>
<Stack space={3} justifyContent='center' alignItems='center'>
{!formActive ? (
<>
<div className='mb-2 flex size-16 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-800'>
<Icon
className='size-8 text-primary-500 dark:text-primary-400'
src={plusIcon}
/>
</div>
<Text
theme='default'
size='2xl'
weight='bold'
align='center'
className='mb-1 text-gray-900 dark:text-gray-100'
>
{intl.formatMessage(messages.title)}
</Text>
<HStack space={3} className='mt-2'>
<Text
theme='default'
size='lg'
align='center'
className='text-gray-800 dark:text-gray-200'
>
{intl.formatMessage(messages.question)}
</Text>
<Button
theme='primary'
text={intl.formatMessage(messages.button)}
onClick={() => setFormActive(!formActive)}
className='px-6 font-medium'
/>
</HStack>
</>
) : (
<Stack space={5} className='w-full'>
<Form onSubmit={handleSubmit}>
<Stack className='rounded-lg p-4'>
<HStack alignItems='center' space={2}>
<Text
size='lg'
weight='medium'
className='text-gray-900 dark:text-gray-100'
>
{intl.formatMessage(messages.mints)}
</Text>
<Tooltip text={'Mint: A kind of digital bank that issues tokens backed by Bitcoin, like \'Bitcoin gift cards\' with built-in privacy.'}>
<div className='text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'>
<Icon src={helpIcon} />
</div>
</Tooltip>
</HStack>
<div>
<MintEditor items={mints} setItems={setMints} />
</div>
</Stack>
<FormActions>
<Button
to='/wallet'
onClick={() => setFormActive(false)}
theme='tertiary'
className='px-6 text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button
theme={isLoading ? 'secondary' : 'primary'}
type='submit'
disabled={isLoading}
className='px-6 font-medium'
>
<FormattedMessage id='common.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
</Stack>
)}
</Stack>
</Stack>
);
};
export default CreateWallet;

Wyświetl plik

@ -0,0 +1,61 @@
import HStack from 'soapbox/components/ui/hstack.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield.tsx';
interface IEditableList<T> {
items: T[];
setItems: (items: T[]) => void;
}
const MintField: StreamfieldComponent<string> = ({ value, onChange }) => {
return (
<HStack space={2} grow>
<Input
type='text'
placeholder='https://mint.example.com'
outerClassName='w-full grow'
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
/>
</HStack>
);
};
const MintEditor: React.FC<IEditableList<string>> = ({ items, setItems }) => {
const handleAdd = () => setItems([...items, '' ]);
const handleRemove = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
setItems(newItems);
};
return <Streamfield values={items} onChange={setItems} component={MintField} onAddItem={handleAdd} onRemoveItem={handleRemove} />;
};
const RelayField: StreamfieldComponent<string> = ({ value, onChange }) => {
return (
<HStack space={2} grow>
<Input
type='text'
placeholder='wss://example.com/relay'
outerClassName='w-full grow'
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
/>
</HStack>
);
};
const RelayEditor: React.FC<IEditableList<string>> = ({ items, setItems }) => {
const handleAdd = () => setItems([...items, '']);
const handleRemove = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
setItems(newItems);
};
return <Streamfield values={items} onChange={setItems} component={RelayField} onAddItem={handleAdd} onRemoveItem={handleRemove} />;
};
export { RelayEditor, MintEditor };
export type { IEditableList };

Wyświetl plik

@ -0,0 +1,130 @@
import arrowBarDownIcon from '@tabler/icons/outline/arrow-bar-down.svg';
import arrowBarUpIcon from '@tabler/icons/outline/arrow-bar-up.svg';
import questionIcon from '@tabler/icons/outline/question-mark.svg';
import { FormattedDate, FormattedMessage } from 'react-intl';
import Divider from 'soapbox/components/ui/divider.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useTransactions } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
const themes = {
default: '!text-gray-900 dark:!text-gray-100',
danger: '!text-danger-600',
primary: '!text-primary-600 dark:!text-accent-blue',
muted: '!text-gray-700 dark:!text-gray-600',
subtle: '!text-gray-400 dark:!text-gray-500',
success: '!text-success-600',
inherit: '!text-inherit',
white: '!text-white',
withdraw: '!text-orange-600 dark:!text-orange-300',
};
const groupByDate = (transactions: { amount: number; created_at: number; direction: 'in' | 'out' }[]) => {
return transactions.reduce((acc, transaction) => {
const dateKey = new Date(transaction.created_at * 1000).toDateString(); // Agrupa pelo dia
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(transaction);
return acc;
}, {} as Record<string, typeof transactions>);
};
const TransactionItem = ({ transaction, hasDivider = true }: { transaction: { amount: number; created_at: number; direction: 'in' | 'out' }; hasDivider?: boolean}) => {
let icon, type, messageColor;
const { direction, amount, created_at } = transaction;
const formattedTime = (
<FormattedDate value={new Date(created_at * 1000)} hour12 hour='numeric' minute='2-digit' />
);
switch (direction) {
case 'in':
icon = arrowBarDownIcon;
type = 'Received';
messageColor = themes.success;
break;
case 'out':
icon = arrowBarUpIcon;
type = 'Sent';
messageColor = themes.danger;
break;
default:
messageColor = 'default';
type = 'Unknown';
icon = questionIcon;
}
return (
<Stack space={2}>
<HStack space={4} alignItems='center' justifyContent='between'>
<HStack space={4}>
<div className='rounded-lg border-transparent bg-primary-100 p-3 text-primary-500 hover:bg-primary-50 focus:bg-primary-100 dark:bg-primary-800 dark:text-primary-200 dark:hover:bg-primary-700 dark:focus:bg-primary-800'>
<SvgIcon src={icon} className={messageColor} />
</div>
<Stack justifyContent='center'>
<Text size='lg'>{type}</Text>
</Stack>
</HStack>
<HStack space={2} alignItems='center'>
<Stack alignItems='end' justifyContent='center'>
<Text size='lg'><FormattedMessage id='wallet.sats' defaultMessage='{amount} sats' values={{ amount }} /></Text>
<Text theme='muted' size='xs'>{formattedTime}</Text>
</Stack>
</HStack>
</HStack>
{hasDivider && <Divider />}
</Stack>
);
};
interface ITransactions {
limit?: number;
}
const Transactions = ({ limit }: ITransactions) => {
const { account } = useOwnAccount();
const { transactions } = useTransactions();
if (!account) {
return null;
}
if (!transactions) {
return <Spinner withText={false} />;
}
if (transactions.length === 0) {
return (<Stack alignItems='center'>
<FormattedMessage id='wallet.transactions.no_transactions' defaultMessage={'You don\'t have transactions yet.'} />
</Stack>);
}
const groupedTransactions = groupByDate(transactions.slice(0, limit));
return (
<Stack className='rounded-lg px-3' alignItems='center' space={4}>
<Stack space={6} className='w-full'>
{Object.entries(groupedTransactions).map(([date, transactions]) => (
<Stack key={date} space={2}>
<Text size='lg' theme='muted'>
<FormattedDate value={new Date(date)} year='numeric' month='short' day='2-digit' />
</Text>
{transactions.map((transaction, index) => (
<TransactionItem key={transaction.created_at} transaction={transaction} hasDivider={transactions.length - 1 !== index} />
))}
</Stack>
))}
</Stack>
</Stack>
);
};
export default Transactions;

Wyświetl plik

@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import { Column } from 'soapbox/components/ui/column.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { MintEditor } from 'soapbox/features/wallet/components/editable-lists.tsx';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useApi } from 'soapbox/hooks/useApi.ts';
import toast from 'soapbox/toast.tsx';
import { isURL } from 'soapbox/utils/auth.ts';
const messages = defineMessages({
title: { id: 'wallet.mints', defaultMessage: 'Mints' },
loadingError: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' },
error: { id: 'wallet.mints.error', defaultMessage: 'Failed to update mints.' },
empty: { id: 'wallet.mints.empty', defaultMessage: 'At least one mint is required.' },
url: { id: 'wallet.invalid_url', defaultMessage: 'All strings must be valid URLs.' },
success: { id: 'wallet.mints.success', defaultMessage: 'Mints updated with success!' },
send: { id: 'common.send', defaultMessage: 'Send' },
});
const WalletMints = () => {
const intl = useIntl();
const api = useApi();
const { wallet } = useWallet();
const [relays, setRelays] = useState<string[]>([]);
const [initialMints, setInitialMints] = useState<string[]>([]);
const [mints, setMints] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [hasError, setHasError] = useState<boolean>(false);
const handleClick = async () =>{
if (mints.length === 0) {
toast.error(intl.formatMessage(messages.empty));
return;
}
if (mints.some((mint) => !isURL(mint))) {
toast.error(intl.formatMessage(messages.url));
return;
}
if (JSON.stringify(initialMints) === JSON.stringify(mints)) {
return;
}
try {
await api.put('/api/v1/ditto/cashu/wallet', { mints: mints, relays: relays });
setInitialMints(mints);
toast.success(intl.formatMessage(messages.success));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : intl.formatMessage(messages.error);
toast.error(errorMessage);
console.error(error);
}
};
useEffect(
() => {
setIsLoading(true);
setHasError(false);
if (wallet) {
try {
setMints(wallet.mints ?? []);
setInitialMints(wallet.mints ?? []);
setRelays(wallet.relays ?? []);
} catch (error) {
console.error('Error setting wallet data:', error);
setHasError(true);
toast.error(intl.formatMessage(messages.loadingError));
} finally {
setIsLoading(false);
}
} else {
// Handle the case when wallet is null or undefined
setIsLoading(false);
if (wallet === undefined) { // wallet is still loading
// Keep loading state true
setIsLoading(true);
} else if (wallet === null) { // wallet failed to load
setHasError(true);
toast.error(intl.formatMessage(messages.loadingError));
}
}
}, [wallet, intl],
);
return (
<Column label={intl.formatMessage(messages.title)} >
{(() => {
if (isLoading) {
return (
<Stack space={2} className='flex h-32 items-center justify-center'>
<Spinner />
</Stack>
);
} else if (hasError) {
return (
<Stack space={2} className='flex h-32 items-center justify-center text-center'>
<Text theme='danger'>{intl.formatMessage(messages.loadingError)}</Text>
</Stack>
);
} else {
return (
<Stack space={2}>
<MintEditor items={mints} setItems={setMints} />
<Button className='w-full' theme='primary' onClick={handleClick}>
{intl.formatMessage(messages.send)}
</Button>
</Stack>
);
}
})()}
</Column>
);
};
export default WalletMints;

Wyświetl plik

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import { Column } from 'soapbox/components/ui/column.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { RelayEditor } from 'soapbox/features/wallet/components/editable-lists.tsx';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useApi } from 'soapbox/hooks/useApi.ts';
import toast from 'soapbox/toast.tsx';
import { isURL } from 'soapbox/utils/auth.ts';
const messages = defineMessages({
title: { id: 'wallet.relays', defaultMessage: 'Wallet Relays' },
loadingError: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' },
error: { id: 'wallet.relays.error', defaultMessage: 'Failed to update relays.' },
empty: { id: 'wallet.relays.empty', defaultMessage: 'At least one relay is required.' },
url: { id: 'wallet.invalid_url', defaultMessage: 'All strings must be valid URLs.' },
success: { id: 'wallet.relays.success', defaultMessage: 'Relays updated with success!' },
send: { id: 'common.send', defaultMessage: 'Send' },
});
const WalletRelays = () => {
const intl = useIntl();
const api = useApi();
const { wallet } = useWallet();
const [relays, setRelays] = useState<string[]>([]);
const [initialRelays, setInitialRelays] = useState<string[]>([]);
const [mints, setMints] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [hasError, setHasError] = useState<boolean>(false);
const handleClick = async () =>{
if (relays.length === 0) {
toast.error(intl.formatMessage(messages.empty));
return;
}
if (relays.some((relay) => !isURL(relay))) {
toast.error(intl.formatMessage(messages.url));
return;
}
if (JSON.stringify(initialRelays) === JSON.stringify(relays)) {
return;
}
try {
await api.put('/api/v1/ditto/cashu/wallet', { mints: mints, relays: relays });
setInitialRelays(relays);
toast.success(intl.formatMessage(messages.success));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : intl.formatMessage(messages.error);
toast.error(errorMessage);
console.error(error);
}
};
useEffect(
() => {
setIsLoading(true);
setHasError(false);
if (wallet) {
try {
setMints(wallet.mints ?? []);
setInitialRelays(wallet.relays ?? []);
setRelays(wallet.relays ?? []);
} catch (error) {
console.error('Error setting wallet data:', error);
setHasError(true);
toast.error(intl.formatMessage(messages.loadingError));
} finally {
setIsLoading(false);
}
} else {
setIsLoading(false);
if (wallet === undefined) {
setIsLoading(true);
} else if (wallet === null) {
setHasError(true);
toast.error(intl.formatMessage(messages.loadingError));
}
}
}, [wallet, intl],
);
return (
<Column label={intl.formatMessage(messages.title)} >
{(() => {
if (isLoading) {
return (
<Stack space={2} className='flex h-32 items-center justify-center'>
<Spinner />
</Stack>
);
} else if (hasError) {
return (
<Stack space={2} className='flex h-32 items-center justify-center text-center'>
<Text theme='danger'>{intl.formatMessage(messages.loadingError)}</Text>
</Stack>
);
} else {
return (
<Stack space={2}>
<RelayEditor items={relays} setItems={setRelays} />
<Button className='w-full' theme='primary' onClick={handleClick}>
{intl.formatMessage(messages.send)}
</Button>
</Stack>
);
}
})()}
</Column>
);
};
export default WalletRelays;

Wyświetl plik

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Column } from 'soapbox/components/ui/column.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Transactions from 'soapbox/features/wallet/components/transactions.tsx';
import { useTransactions } from 'soapbox/features/zap/hooks/useHooks.ts';
const messages = defineMessages({
title: { id: 'wallet.transactions', defaultMessage: 'Transactions' },
more: { id: 'wallet.transactions.show_more', defaultMessage: 'Show More' },
loading: { id: 'wallet.loading', defaultMessage: 'Loading…' },
noMoreTransactions: { id: 'wallet.transactions.no_more', defaultMessage: 'You reached the end of transactions' },
});
const WalletTransactions = () => {
const intl = useIntl();
const { isLoading, expandTransactions } = useTransactions();
const observerRef = useRef<HTMLDivElement | null>(null);
const [showSpinner, setShowSpinner] = useState(false);
const [hasMoreTransactions, setHasMoreTransactions] = useState(true);
useEffect(() => {
const observer = new IntersectionObserver(
async ([entry]) => {
if (entry.isIntersecting && !isLoading && hasMoreTransactions) {
setShowSpinner(true);
const hasMore = await expandTransactions();
setHasMoreTransactions(hasMore);
}
},
{ rootMargin: '100px' },
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [expandTransactions, isLoading, hasMoreTransactions]);
return (
<Column label={intl.formatMessage(messages.title)}>
<Transactions />
<div className='flex w-full justify-center' ref={observerRef}>
{showSpinner && isLoading && <Spinner />}
{!hasMoreTransactions && (
<div className='py-4 text-center text-gray-600'>
{intl.formatMessage(messages.noMoreTransactions)}
</div>
)}
</div>
</Column>
);
};
export default WalletTransactions;

Wyświetl plik

@ -0,0 +1,196 @@
import moreIcon from '@tabler/icons/outline/dots-circle-horizontal.svg';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list.tsx';
import Button from 'soapbox/components/ui/button.tsx';
import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx';
import { Column } from 'soapbox/components/ui/column.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
import Balance from 'soapbox/features/wallet/components/balance.tsx';
import CreateWallet from 'soapbox/features/wallet/components/create-wallet.tsx';
import Transactions from 'soapbox/features/wallet/components/transactions.tsx';
import { useTransactions, useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
const messages = defineMessages({
payment: { id: 'wallet.payment', defaultMessage: 'Payment Method' },
relays: { id: 'wallet.relays', defaultMessage: 'Wallet Relays' },
transactions: { id: 'wallet.transactions', defaultMessage: 'Transactions' },
wallet: { id: 'wallet.title', defaultMessage: 'Wallet' },
management: { id: 'wallet.management', defaultMessage: 'Wallet Management' },
mints: { id: 'wallet.mints', defaultMessage: 'Mints' },
more: { id: 'wallet.transactions.more', defaultMessage: 'More' },
loading: { id: 'wallet.loading', defaultMessage: 'Loading…' },
error: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' },
retrying: { id: 'wallet.retrying', defaultMessage: 'Retrying in {seconds}s…' },
retry: { id: 'wallet.retry', defaultMessage: 'Retry Now' },
});
const paymentMethods = {
lightning: 'lightning',
cashu: 'cashu',
};
const RETRY_DELAY = 5000;
/** User Wallet page. */
const Wallet = () => {
const intl = useIntl();
const [isRetrying, setIsRetrying] = useState(false);
const [retrySeconds, setRetrySeconds] = useState(RETRY_DELAY / 1000);
const [retryTimer, setRetryTimer] = useState<NodeJS.Timeout | null>(null);
const { account } = useOwnAccount();
const { wallet: walletData, isLoading, error, getWallet } = useWallet();
const { method, changeMethod } = usePaymentMethod();
const { transactions } = useTransactions();
const hasTransactions = transactions && transactions.length > 0;
// Function to handle manual retry
const handleRetry = () => {
// Clear any existing timer
if (retryTimer) {
clearInterval(retryTimer);
setRetryTimer(null);
}
setIsRetrying(false);
getWallet(true); // Trigger wallet reload with error messages
};
// Setup automatic retry when there's an error
useEffect(() => {
if (error && !isLoading && !isRetrying) {
setIsRetrying(true);
setRetrySeconds(RETRY_DELAY / 1000);
// Start countdown timer
const timer = setInterval(() => {
setRetrySeconds(prevSeconds => {
if (prevSeconds <= 1) {
clearInterval(timer);
handleRetry();
return RETRY_DELAY / 1000;
}
return prevSeconds - 1;
});
}, 1000);
setRetryTimer(timer);
// Cleanup timer on unmount
return () => {
clearInterval(timer);
};
}
}, [error, isLoading, isRetrying]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (retryTimer) {
clearInterval(retryTimer);
}
};
}, [retryTimer]);
if (!account) return null;
return (
<>
{isLoading && (
<Stack className='h-screen items-center justify-center'>
<Spinner size={50} withText={false} />
<Text>{intl.formatMessage(messages.loading)}</Text>
</Stack>
)}
{error && !isLoading && (
<Stack className='h-screen items-center justify-center space-y-4'>
<Text size='xl' weight='bold' theme='danger'>{intl.formatMessage(messages.error)}</Text>
<Text>{error}</Text>
{isRetrying && (
<Text>{intl.formatMessage(messages.retrying, { seconds: retrySeconds })}</Text>
)}
<Button onClick={handleRetry} theme='primary'>
{intl.formatMessage(messages.retry)}
</Button>
</Stack>
)}
{!isLoading && !error && (
<Column label={intl.formatMessage(messages.wallet)} transparent withHeader={false} slim>
<Card className='space-y-4 overflow-hidden'>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.wallet)} />
</CardHeader>
{walletData ? (
<>
<CardBody>
<Stack
className='rounded-lg border border-gray-200 p-8 dark:border-gray-700'
alignItems='center'
space={4}
>
<Balance />
</Stack>
</CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.transactions)} />
</CardHeader>
<CardBody>
<Transactions limit={4} />
{hasTransactions && <div className='mt-4 flex w-full justify-center'>
<Button
icon={moreIcon}
theme='primary'
to='/wallet/transactions'
className='px-6 font-medium'
>
{intl.formatMessage(messages.more)}
</Button>
</div>}
</CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.management)} />
</CardHeader>
<CardBody>
<List>
<ListItem label={intl.formatMessage(messages.mints)} to='/wallet/mints' />
<ListItem label={intl.formatMessage(messages.relays)} to='/wallet/relays' />
<ListItem label={intl.formatMessage(messages.payment)} >
<SelectDropdown
className='max-w-[200px]'
items={paymentMethods}
defaultValue={method}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => {
changeMethod((event.target.value as 'cashu' | 'lightning'));
}}
/>
</ListItem>
</List>
</CardBody>
</>
) : (
<>
<CardBody>
<CreateWallet />
</CardBody>
</>
)}
</Card>
</Column>
)}
</>
);
};
export default Wallet;

Wyświetl plik

@ -16,122 +16,173 @@ import pileCoin from 'soapbox/assets/icons/pile-coin.png';
import DisplayNameInline from 'soapbox/components/display-name-inline.tsx';
import Avatar from 'soapbox/components/ui/avatar.tsx';
import Button from 'soapbox/components/ui/button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useZapCashuRequest } from 'soapbox/features/zap/hooks/useHooks.ts';
import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import ZapButton from './zap-button/zap-button.tsx';
import PaymentButton from './zap-button/payment-button.tsx';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities.ts';
const ZAP_PRESETS = [
{ amount: 50, icon: coinIcon },
{ amount: 200, icon: coinStack },
{ amount: 1_000, icon: pileCoin },
{ amount: 3_000, icon: moneyBag },
{ amount: 5_000, icon: chestIcon },
{ buttonAmount: 50, icon: coinIcon },
{ buttonAmount: 200, icon: coinStack },
{ buttonAmount: 1_000, icon: pileCoin },
{ buttonAmount: 3_000, icon: moneyBag },
{ buttonAmount: 5_000, icon: chestIcon },
];
interface IZapPayRequestForm {
interface IPayRequestForm {
status?: StatusEntity;
account: AccountEntity;
onClose?(): void;
}
const closeIcon = xIcon;
const messages = defineMessages({
zap_button_rounded: { id: 'zap.button.text.rounded', defaultMessage: 'Zap {amount}K sats' },
zap_button: { id: 'zap.button.text.raw', defaultMessage: 'Zap {amount} sats' },
zap_commentPlaceholder: { id: 'zap.comment_input.placeholder', defaultMessage: 'Optional comment' },
zap_button: { id: 'payment_method.button.text.raw', defaultMessage: 'Zap {amount} sats' },
zap_commentPlaceholder: { id: 'payment_method.comment_input.placeholder', defaultMessage: 'Optional comment' },
});
const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) => {
const PayRequestForm = ({ account, status, onClose }: IPayRequestForm) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [zapComment, setZapComment] = useState('');
const [zapAmount, setZapAmount] = useState(50); // amount in millisatoshi
const [amount, setAmount] = useState(50);
const { zapArrays, zapSplitData, receiveAmount } = useZapSplit(status, account);
const splitValues = zapSplitData.splitValues;
const hasZapSplit = zapArrays.length > 0;
const { method: paymentMethod, changeMethod } = usePaymentMethod();
const isCashu = paymentMethod === 'cashu';
const hasZapSplit = zapArrays.length > 0 && !isCashu;
const { zapCashuRequest } = useZapCashuRequest();
const handleSubmit = async (e?: React.FormEvent<Element>) => {
e?.preventDefault();
const zapSplitAccounts = zapArrays.filter(zapData => zapData.account.id !== account.id);
const splitData = { hasZapSplit, zapSplitAccounts, splitValues };
const invoice = hasZapSplit ? await dispatch(zap(account, status, zapSplitData.receiveAmount * 1000, zapComment)) : await dispatch(zap(account, status, zapAmount * 1000, zapComment));
// If invoice is undefined it means the user has paid through his extension
// In this case, we simply close the modal
if (isCashu) {
await zapCashuRequest(account, amount, zapComment, status);
dispatch(closeModal('PAY_REQUEST'));
return;
}
const invoice = hasZapSplit
? await dispatch(zap(account, status, zapSplitData.receiveAmount * 1000, zapComment))
: await dispatch(zap(account, status, amount * 1000, zapComment));
if (!invoice) {
dispatch(closeModal('ZAP_PAY_REQUEST'));
// Dispatch the adm account
dispatch(closeModal('PAY_REQUEST'));
if (zapSplitAccounts.length > 0) {
dispatch(openModal('ZAP_SPLIT', { zapSplitAccounts, splitValues }));
}
return;
}
// open QR code modal
dispatch(closeModal('ZAP_PAY_REQUEST'));
dispatch(closeModal('PAY_REQUEST'));
dispatch(openModal('ZAP_INVOICE', { account, invoice, splitData }));
};
const handleCustomAmount = (e: React.ChangeEvent<HTMLInputElement>) => {
e?.preventDefault();
const inputAmount = e.target.value.replace(/[^0-9]/g, '');
const maxSendable = 250000000;
// multiply by 1000 to convert from satoshi to millisatoshi
if (maxSendable * 1000 > Number(inputAmount)) {
setZapAmount(Number(inputAmount));
if (maxSendable * 1000 > Number(inputAmount) && !isCashu) {
setAmount(Number(inputAmount));
} else if (maxSendable > Number(inputAmount)) {
setAmount(Number(inputAmount));
}
};
const renderZapButtonText = () => {
if (zapAmount >= 1000) {
return intl.formatMessage(messages.zap_button_rounded, { amount: Math.round((zapAmount / 1000) * 10) / 10 });
const renderPaymentButtonText = () => {
if (amount >= 1000) {
return intl.formatMessage(messages.zap_button_rounded, { amount: Math.round((amount / 1000) * 10) / 10 });
}
return intl.formatMessage(messages.zap_button, { amount: zapAmount });
return intl.formatMessage(messages.zap_button, { amount: amount });
};
useEffect(() => {
receiveAmount(zapAmount);
}, [zapAmount, zapArrays]);
receiveAmount(amount);
}, [amount, zapArrays]);
return (
<Stack space={4} element='form' onSubmit={handleSubmit} justifyContent='center' alignItems='center' className='relative'>
<Stack space={2} justifyContent='center' alignItems='center' >
<IconButton
src={closeIcon}
onClick={onClose}
className='absolute right-[-1%] top-[-2%] text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 rtl:rotate-180'
/>
<Stack space={2} justifyContent='center' alignItems='center'>
<HStack className='absolute right-[-1%] top-[-2%] w-full'>
<div className='absolute left-[-1%] top-[-2%] flex items-center gap-2'>
<button
type='button'
className={`flex size-8 items-center justify-center rounded-full transition ${
paymentMethod === 'lightning'
? 'bg-primary-600 text-white'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800'
}`}
onClick={() => changeMethod('lightning')}
title={intl.formatMessage({ id: 'payment_method.lightning', defaultMessage: 'Lightning' })}
>
<span role='img' aria-label={intl.formatMessage({ id: 'emoji.lightning', defaultMessage: 'Lightning' })} className='text-lg'></span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
</button>
<button
type='button'
className={`flex size-8 items-center justify-center rounded-full transition ${
paymentMethod === 'cashu'
? 'bg-primary-600 text-white'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800'
}`}
onClick={() => changeMethod('cashu')}
title={intl.formatMessage({ id: 'payment_method.cashu', defaultMessage: 'Cashu' })}
>
<span role='img' aria-label={intl.formatMessage({ id: 'emoji.cashu', defaultMessage: 'Cashu' })} className='text-lg'>💰</span> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
</button>
</div>
<IconButton
src={xIcon}
onClick={onClose}
className='absolute right-[-1%] top-[-2%] text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 rtl:rotate-180'
/>
</HStack>
<Text weight='semibold'>
<FormattedMessage
id='zap.send_to'
defaultMessage='Send zaps to {target}'
values={{ target: emojifyText(account.display_name, account.emojis) }}
id='payment_method.send_to'
defaultMessage='Send sats via {method} to {target}'
values={{
target: emojifyText(account.display_name, account.emojis),
method: paymentMethod,
}}
/>
</Text>
{zapArrays.length > 0 && isCashu && (
<Text size='xs' theme='muted' className='text-center'>
<FormattedMessage
id='payment_method.cashu.split_disabled'
defaultMessage='Switch to ⚡ for splits'
/>
</Text>
)}
<Avatar src={account.avatar} size={50} />
<DisplayNameInline account={account} />
</Stack>
<div className='flex w-full justify-center'>
{ZAP_PRESETS.map(({ amount, icon }) => (
<ZapButton
onClick={() => setZapAmount(amount)}
{ZAP_PRESETS.map(({ buttonAmount, icon }) => (
<PaymentButton
onClick={() => setAmount(buttonAmount)}
className='m-0.5 sm:m-1'
selected={zapAmount === amount}
selected={buttonAmount === amount}
icon={icon}
amount={amount}
amount={buttonAmount}
/>
))}
</div>
@ -139,11 +190,13 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
<Stack space={2}>
<div className='relative flex items-end justify-center gap-4'>
<Input
type='text' onChange={handleCustomAmount} value={zapAmount}
type='text' onChange={handleCustomAmount} value={amount}
className='max-w-20 rounded-none border-0 border-b-4 p-0 text-center !text-2xl font-bold shadow-none !ring-0 dark:bg-transparent sm:max-w-28 sm:p-0.5 sm:!text-4xl'
/>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{hasZapSplit && <p className='absolute right-0 font-bold sm:-right-6 sm:text-xl'>sats</p>}
{hasZapSplit && <p className='absolute right-0 font-bold sm:-right-6 sm:text-xl'>
{intl.formatMessage({ id: 'payment_method.unit', defaultMessage: 'sats' })}
</p>}
</div>
{hasZapSplit && (
@ -164,12 +217,12 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
{hasZapSplit ? <Stack space={2}>
<Button className='m-auto w-auto' type='submit' theme='primary' icon={boltIcon} text={'Zap sats'} disabled={zapAmount < 1 ? true : false} />
<Button className='m-auto w-auto' type='submit' theme='primary' icon={boltIcon} text={intl.formatMessage({ id: 'payment_method.button.zap_sats', defaultMessage: 'Zap sats' })} disabled={amount < 1 ? true : false} />
<div className='flex items-center justify-center gap-2 sm:gap-4'>
<span className='text-[10px] sm:text-xs'>
<FormattedMessage
id='zap.split_message.deducted'
id='payment_method.split_message.deducted'
defaultMessage='{amountDeducted} sats will deducted*'
values={{ instance: emojifyText(account.display_name, account.emojis), amountDeducted: zapSplitData.splitAmount }}
/>
@ -180,10 +233,10 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
</Link>
</div>
</Stack> : <Button className='m-auto w-auto' type='submit' theme='primary' icon={boltIcon} text={renderZapButtonText()} disabled={zapAmount < 1 ? true : false} />}
</Stack> : <Button className='m-auto w-auto' type='submit' theme='primary' icon={boltIcon} text={renderPaymentButtonText()} disabled={amount < 1 ? true : false} />}
</Stack>
);
};
export default ZapPayRequestForm;
export default PayRequestForm;

Wyświetl plik

@ -30,7 +30,7 @@ interface IButton {
}
/** Customizable button element. */
const ZapButton = forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const PaymentButton = forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const {
disabled = false,
icon,
@ -75,6 +75,6 @@ const ZapButton = forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Eleme
});
export {
ZapButton as default,
ZapButton,
PaymentButton as default,
PaymentButton,
};

Wyświetl plik

@ -0,0 +1,279 @@
import { debounce } from 'es-toolkit';
import { useEffect, useState } from 'react';
import { create } from 'zustand';
import { useApi } from 'soapbox/hooks/useApi.ts';
import { NutzappedEntry, NutzappedRecord, Transactions, WalletData, baseWalletSchema, nutzappedEntry, transactionsSchema } from 'soapbox/schemas/wallet.ts';
import toast from 'soapbox/toast.tsx';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities.ts';
interface WalletState {
wallet: WalletData | null;
acceptsZapsCashu: boolean;
transactions: Transactions | null;
zapCashuList: string[];
nutzappedRecord: NutzappedRecord;
prevTransaction?: string | null;
nextTransaction?: string | null;
prevZaps?: string | null;
nextZaps?: string | null;
hasFetchedWallet: boolean;
hasFetchedTransactions: boolean;
setNutzappedRecord: (statusId: string, nutzappedEntry: NutzappedEntry, prevZaps?: string | null, nextZaps?: string | null) => void;
setAcceptsZapsCashu: (acceptsZapsCashu: boolean) => void;
setWallet: (wallet: WalletData | null) => void;
setHasFetchedWallet: (hasFetchedWallet: boolean) => void;
setTransactions: (transactions: Transactions | null, prevTransaction?: string | null, nextTransaction?: string | null) => void;
setHasFetchedTransactions: (hasFetched: boolean) => void;
addZapCashu: (statusId: string) => void;
}
interface IWalletInfo {
mints: string[];
relays: string[];
}
const useWalletStore = create<WalletState>((set) => ({
wallet: null,
acceptsZapsCashu: false,
transactions: null,
prevTransaction: null,
nextTransaction: null,
prevZaps: null,
nextZaps: null,
zapCashuList: [],
nutzappedRecord: {},
hasFetchedWallet: false,
hasFetchedTransactions: false,
setNutzappedRecord: (statusId, nutzappedEntry, prevZaps, nextZaps) => set((state)=> ({
nutzappedRecord: {
...state.nutzappedRecord,
[statusId]: nutzappedEntry,
},
prevZaps,
nextZaps,
})),
setAcceptsZapsCashu: (acceptsZapsCashu) => set({ acceptsZapsCashu }),
setWallet: (wallet) => set({ wallet }),
setHasFetchedWallet: (hasFetchedWallet) => set({ hasFetchedWallet }),
setTransactions: (transactions, prevTransaction, nextTransaction) => set({ transactions, prevTransaction, nextTransaction }),
setHasFetchedTransactions: (hasFetched) => set({ hasFetchedTransactions: hasFetched }),
addZapCashu: (statusId) =>
set((state) => ({
zapCashuList: [
...state.zapCashuList,
statusId,
],
})),
}));
const useWallet = () => {
const api = useApi();
const { wallet, setWallet, setAcceptsZapsCashu, hasFetchedWallet, setHasFetchedWallet } = useWalletStore();
const [isLoading, setIsLoading] = useState(!hasFetchedWallet);
const [error, setError] = useState<string | null>(null);
const createWallet = async (walletInfo: IWalletInfo) => {
setIsLoading(true);
try {
const response = await api.put('/api/v1/ditto/cashu/wallet', walletInfo);
const data = await response.json();
if (data) {
const normalizedData = baseWalletSchema.parse(data);
toast.success('Wallet created successfully');
setWallet(normalizedData);
}
} catch (err) {
const messageError = err instanceof Error ? err.message : 'An error has occurred';
setError(messageError);
toast.error(messageError);
} finally {
setIsLoading(false);
}
};
const getWallet = async (hasMessage = true) => {
setIsLoading(true);
try {
const response = await api.get('/api/v1/ditto/cashu/wallet');
const data = await response.json();
if (data) {
const normalizedData = baseWalletSchema.parse(data);
setAcceptsZapsCashu(true);
setWallet(normalizedData);
}
} catch (err) {
const messageError = err instanceof Error ? err.message : 'Wallet not found';
if (hasMessage) toast.error(messageError);
setError(messageError);
} finally {
setIsLoading(false);
setHasFetchedWallet(true);
}
};
useEffect(() => {
if (!hasFetchedWallet) {
getWallet(false);
}
}, []);
return { wallet, isLoading, error, createWallet, getWallet };
};
const useTransactions = () => {
const api = useApi();
const { transactions, nextTransaction, setTransactions, hasFetchedTransactions, setHasFetchedTransactions } = useWalletStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getTransactions = async () => {
setIsLoading(true);
try {
const response = await api.get('/api/v1/ditto/cashu/transactions');
const { prev, next } = response.pagination();
const data = await response.json();
if (data) {
const normalizedData = transactionsSchema.parse(data);
setTransactions(normalizedData, prev, next);
}
} catch (err) {
const messageError = err instanceof Error ? err.message : 'Transactions not found';
toast.error(messageError);
setError(messageError);
} finally {
setIsLoading(false);
}
};
const expandTransactions = async () => {
if (!nextTransaction || !transactions) {
return false;
}
try {
setIsLoading(true);
const response = await api.get(nextTransaction);
const { prev, next } = response.pagination();
const data = await response.json();
const normalizedData = transactionsSchema.parse(data);
const newTransactions = [...(transactions ?? []), ...normalizedData ];
setTransactions(newTransactions, prev, next);
return true;
} catch (err) {
const messageError = err instanceof Error ? err.message : 'Error expanding transactions';
toast.error(messageError);
setError(messageError);
return false;
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!hasFetchedTransactions) {
setHasFetchedTransactions(true);
getTransactions();
}
}, []);
return { transactions, isLoading, error, getTransactions, expandTransactions };
};
const useZapCashuRequest = () => {
const api = useApi();
const { zapCashuList, addZapCashu } = useWalletStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { getWallet } = useWallet();
const { getTransactions } = useTransactions();
const zapCashuRequest = async (account: AccountEntity, amount: number, comment: string, status?: StatusEntity) => {
setIsLoading(true);
setError(null);
try {
await api.post('/api/v1/ditto/cashu/nutzap', {
amount,
comment,
account_id: account.id,
status_id: status?.id,
});
if (status) {
addZapCashu(status.id);
}
toast.success('Sats sent successfully!');
getWallet();
getTransactions();
} catch (err) {
const messageError = err instanceof Error ? err.message : 'An unexpected error occurred';
setError(messageError);
toast.error('An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
return { zapCashuList, isLoading, error, zapCashuRequest };
};
const useZappedByCashu = () => {
const api = useApi();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { nextZaps, nutzappedRecord, setNutzappedRecord } = useWalletStore();
const getNutzappedBy = async (statusId: string) => {
setIsLoading(true);
try {
const response = await api.get(`/api/v1/ditto/cashu/statuses/${statusId}/nutzapped_by`);
const { prev, next } = response.pagination();
const data = await response.json();
if (data) {
const normalizedData = nutzappedEntry.parse(data);
setNutzappedRecord(statusId, normalizedData, prev, next);
}
} catch (err) {
const messageError = err instanceof Error ? err.message : 'Zaps not found';
toast.error('Zaps not foud');
setError(messageError);
} finally {
setIsLoading(false);
}
};
const expandNutzappedBy = debounce(async (id: string) => {
if (!nextZaps || !nutzappedRecord[id]) {
return;
}
try {
setIsLoading(true);
const response = await api.get(nextZaps);
const { prev, next } = response.pagination();
const data = await response.json();
if (data) {
const normalizedData = nutzappedEntry.parse(data);
const newNutzappedBy = [...(nutzappedRecord[id] ?? []), ...normalizedData ];
setNutzappedRecord(id, newNutzappedBy, prev, next);
}
} catch (err) {
const messageError = err instanceof Error ? err.message : 'Error expanding transactions';
toast.error(messageError);
setError(messageError);
} finally {
setIsLoading(false);
}
}, 700);
return { error, isLoading, getNutzappedBy, expandNutzappedBy };
};
export { useWalletStore, useWallet, useTransactions, useZapCashuRequest, useZappedByCashu };

Wyświetl plik

@ -0,0 +1,19 @@
import { enableMapSet } from 'immer';
import { create } from 'zustand';
enableMapSet();
interface IPaymentMethod {
method: 'cashu' | 'lightning';
changeMethod: (method: 'cashu' | 'lightning') => void;
}
export const usePaymentMethod = create<IPaymentMethod>(
(set) => ({
method: localStorage.getItem('soapbox:payment_method') as 'cashu' | 'lightning' || 'cashu',
changeMethod: (method: 'cashu' | 'lightning') => {
localStorage.setItem('soapbox:payment_method', method);
set({ method });
},
}),
);

Wyświetl plik

@ -1520,7 +1520,8 @@
"video.play": "تشغيل",
"video.unmute": "تفعيل الصوت",
"who_to_follow.title": "حسابات مقترحة",
"zap.comment_input.placeholder": "تعليق إختياري",
"payment_method.comment_input.placeholder": "تعليق إختياري",
"zap.open_wallet": "فتح المحفظة",
"zap.send_to": "أرسل zaps إلى {target}"
"zap.send_to": "أرسل zaps إلى {target}",
"payment_method.send_to": "أرسل sats عبر {method} إلى {target}"
}

Wyświetl plik

@ -466,6 +466,8 @@
"column_forbidden.body": "You do not have permission to access this page.",
"column_forbidden.title": "Forbidden",
"common.cancel": "Cancel",
"common.save": "Save",
"common.send": "Send",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}",
"compose.edit_success": "Your post was edited",
@ -712,6 +714,8 @@
"edit_profile.success": "Your profile has been successfully saved!",
"email_confirmation.success": "Your email has been confirmed!",
"embed.instructions": "Embed this post on your website by copying the code below.",
"emoji.cashu": "Cashu",
"emoji.lightning": "Lightning",
"emoji_button.activity": "Activity",
"emoji_button.add_custom": "Add custom emoji",
"emoji_button.custom": "Custom",
@ -1293,6 +1297,15 @@
"password_reset.reset": "Reset password",
"patron.donate": "Donate",
"patron.title": "Funding Goal",
"payment_method.button.text.raw": "Zap {amount} sats",
"payment_method.button.zap_sats": "Zap sats",
"payment_method.cashu": "Cashu",
"payment_method.cashu.split_disabled": "Switch to ⚡ for splits",
"payment_method.comment_input.placeholder": "Optional comment",
"payment_method.lightning": "Lightning",
"payment_method.send_to": "Send sats via {method} to {target}",
"payment_method.split_message.deducted": "{amountDeducted} sats will deducted*",
"payment_method.unit": "sats",
"pinned_accounts.title": "{name}s choices",
"pinned_statuses.none": "No pins to show.",
"poll.choose_multiple": "Choose as many as you'd like.",
@ -1650,6 +1663,7 @@
"tabs_bar.profile": "Profile",
"tabs_bar.search": "Explore",
"tabs_bar.settings": "Settings",
"tabs_bar.wallet": "Wallet",
"textarea.counter.label": "{count} characters remaining",
"theme_editor.colors.accent": "Accent",
"theme_editor.colors.accent_blue": "Accent Blue",
@ -1706,15 +1720,46 @@
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound",
"wallet.balance.expired": "Expired",
"wallet.balance.mint.amount": "Amount in sats",
"wallet.balance.mint.paid_message": "Your mint was successful, and your sats are now in your balance. Enjoy!",
"wallet.balance.mint.payment": "Make the payment to complete:",
"wallet.balance.mint.unpaid_message": "Your mint is still unpaid. Complete the payment to receive your sats.",
"wallet.button.cancel": "Cancel",
"wallet.button.create_wallet": "Create",
"wallet.button.mint": "Mint",
"wallet.button.withdraw": "Withdraw",
"wallet.create_wallet.question": "Do you want create one?",
"wallet.create_wallet.title": "You don't have a wallet",
"wallet.hidden.balance": "••••••",
"wallet.invalid_url": "All strings must be valid URLs.",
"wallet.loading": "Loading…",
"wallet.loading_error": "An unexpected error occurred while loading your wallet data.",
"wallet.management": "Wallet Management",
"wallet.mints": "Mints",
"wallet.mints.empty": "At least one mint is required.",
"wallet.mints.error": "Failed to update mints.",
"wallet.mints.success": "Mints updated with success!",
"wallet.payment": "Payment Method",
"wallet.relays": "Wallet Relays",
"wallet.relays.empty": "At least one relay is required.",
"wallet.relays.error": "Failed to update relays.",
"wallet.relays.success": "Relays updated with success!",
"wallet.retry": "Retry Now",
"wallet.retrying": "Retrying in {seconds}s…",
"wallet.sats": "{amount} sats",
"wallet.title": "Wallet",
"wallet.transactions": "Transactions",
"wallet.transactions.more": "More",
"wallet.transactions.no_more": "You reached the end of transactions",
"wallet.transactions.no_transactions": "You don't have transactions yet.",
"wallet.transactions.show_more": "Show More",
"who_to_follow.title": "People To Follow",
"zap.button.text.raw": "Zap {amount} sats",
"zap.button.text.rounded": "Zap {amount}K sats",
"zap.comment_input.placeholder": "Optional comment",
"zap.finish": "Finish",
"zap.next": "Next",
"zap.open_wallet": "Open Wallet",
"zap.send_to": "Send zaps to {target}",
"zap.split_message.deducted": "{amountDeducted} sats will deducted*",
"zap.split_message.receiver": "{receiver} will receive {amountReceiver} sats*",
"zap_split.question": "Why am I paying this?",
"zap_split.text": "Your support will help us build an unstoppable empire and rule the galaxy!",

Wyświetl plik

@ -1626,8 +1626,9 @@
"video.play": "Reproducir",
"video.unmute": "Dejar de silenciar sonido",
"who_to_follow.title": "Personas a seguir",
"zap.button.text.raw": "Zap {amount} sats",
"zap.comment_input.placeholder": "Comentario opcional",
"payment_method.button.text.raw": "Zap {amount} sats",
"payment_method.comment_input.placeholder": "Comentario opcional",
"zap.open_wallet": "Abrir Wallet",
"zap.send_to": "Enviar zaps a {target}"
"zap.send_to": "Enviar zaps a {target}",
"payment_method.send_to": "Enviar sats a través de {method} a {target}"
}

Wyświetl plik

@ -1679,14 +1679,15 @@
"video.play": "Seinn",
"video.unmute": "Fuaim gan iomrá",
"who_to_follow.title": "Daoine le Leanúint",
"zap.button.text.raw": "Suíonn Zap {amount}",
"payment_method.button.text.raw": "Suíonn Zap {amount}",
"zap.button.text.rounded": "Zap {amount}K shuíonn",
"zap.comment_input.placeholder": "Nóta roghnach",
"payment_method.comment_input.placeholder": "Nóta roghnach",
"zap.finish": "Críochnaigh",
"zap.next": "Ar Aghaidh",
"zap.open_wallet": "Oscail Sparán",
"zap.send_to": "Seol zaps chuig {target}",
"zap.split_message.deducted": "Asbhainfear {amountDeducted} sats*",
"payment_method.send_to": "Seol sats trí {method} chuig {target}",
"payment_method.split_message.deducted": "Asbhainfear {amountDeducted} sats*",
"zap.split_message.receiver": "Gheobhaidh {receiver} {amountReceiver} sats*",
"zap_split.question": "Cén fáth a bhfuil mé ag íoc seo?",
"zap_split.text": "Beidh do thacaíocht cabhrú linn a thógáil Impireacht unstoppable agus riail an réaltra!",

Wyświetl plik

@ -1484,5 +1484,5 @@
"video.pause": "Zaustavi",
"video.play": "Reproduciraj",
"who_to_follow.title": "Moglo bi vam se svidjeti",
"zap.comment_input.placeholder": "Opcionalni komentar"
"payment_method.comment_input.placeholder": "Opcionalni komentar"
}

Wyświetl plik

@ -1635,11 +1635,12 @@
"video.play": "Reproduzir",
"video.unmute": "Reativar som",
"who_to_follow.title": "Quem seguir",
"zap.button.text.raw": "Zap {amount} sats",
"zap.comment_input.placeholder": "Comentário opcional",
"payment_method.button.text.raw": "Zap {amount} sats",
"payment_method.comment_input.placeholder": "Comentário opcional",
"zap.open_wallet": "Abrir Carteira",
"zap.send_to": "Enviar zaps para {target}",
"zap.split_message.deducted": "{amountDeducted} sats serão deduzidos*",
"payment_method.send_to": "Enviar sats via {method} para {target}",
"payment_method.split_message.deducted": "{amountDeducted} sats serão deduzidos*",
"zap.split_message.receiver": "{receiver} receberá {amountReceiver} sats*",
"zap_split.question": "Por que estou pagando isso?",
"zap_split.text": "Seu apoio nos ajudará a construir um império imparável e governar a galáxia!",

Wyświetl plik

@ -1616,7 +1616,8 @@
"video.play": "Reproduzir",
"video.unmute": "Retirar do silêncio",
"who_to_follow.title": "Quem Seguir",
"zap.comment_input.placeholder": "Comentário opcional",
"payment_method.comment_input.placeholder": "Comentário opcional",
"zap.open_wallet": "Abrir Carteira",
"payment_method.send_to": "Enviar sats através de {method} para {target}",
"zap.send_to": "Enviar zaps para {target}"
}

Wyświetl plik

@ -1620,8 +1620,9 @@
"video.play": "播放",
"video.unmute": "解除静音声音",
"who_to_follow.title": "推荐关注",
"zap.button.text.raw": "打闪 {amount} 聪",
"zap.comment_input.placeholder": "可选评论",
"payment_method.button.text.raw": "打闪 {amount} 聪",
"payment_method.comment_input.placeholder": "可选评论",
"zap.open_wallet": "打开钱包",
"payment_method.send_to": "通过 {method} 发送 sats 到 {target}",
"zap.send_to": "发送打闪给 {target}"
}

Wyświetl plik

@ -77,6 +77,7 @@ export const StatusRecord = ImmutableRecord({
reblogs_count: 0,
replies_count: 0,
zaps_amount: 0,
zaps_amount_cashu: 0,
sensitive: false,
spoiler_text: '',
tags: ImmutableList<ImmutableMap<string, any>>(),
@ -85,6 +86,7 @@ export const StatusRecord = ImmutableRecord({
url: '',
visibility: 'public' as StatusVisibility,
zapped: false,
zapped_cashu: false,
event: null as ReturnType<typeof EventRecord> | null,
// Internal fields

Wyświetl plik

@ -1,3 +1,5 @@
import { useLocation } from 'react-router-dom';
import Layout from 'soapbox/components/ui/layout.tsx';
import LinkFooter from 'soapbox/features/ui/components/link-footer.tsx';
import {
@ -5,7 +7,9 @@ import {
TrendsPanel,
SignUpPanel,
CtaBanner,
PocketWallet,
} from 'soapbox/features/ui/util/async-components.ts';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
@ -16,6 +20,9 @@ interface IDefaultPage {
const DefaultPage: React.FC<IDefaultPage> = ({ children }) => {
const me = useAppSelector(state => state.me);
const features = useFeatures();
const { wallet } = useWallet();
const path = useLocation().pathname;
const hasPocketWallet = wallet && path !== '/wallet';
return (
<>
@ -31,6 +38,9 @@ const DefaultPage: React.FC<IDefaultPage> = ({ children }) => {
{!me && (
<SignUpPanel />
)}
{hasPocketWallet && (
<PocketWallet />
)}
{features.trends && (
<TrendsPanel limit={5} />
)}

Wyświetl plik

@ -20,7 +20,9 @@ import {
CtaBanner,
AnnouncementsPanel,
LatestAccountsPanel,
PocketWallet,
} from 'soapbox/features/ui/util/async-components.ts';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useDraggedFiles } from 'soapbox/hooks/useDraggedFiles.ts';
@ -39,6 +41,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { pathname } = useLocation();
const { wallet } = useWallet();
const me = useAppSelector(state => state.me);
const { account } = useOwnAccount();
@ -112,6 +115,9 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
{!me && (
<SignUpPanel />
)}
{wallet && (
<PocketWallet />
)}
{me && features.announcements && (
<AnnouncementsPanel />
)}

Wyświetl plik

@ -17,7 +17,9 @@ import {
CtaBanner,
PinnedAccountsPanel,
AccountNotePanel,
PocketWallet,
} from 'soapbox/features/ui/util/async-components.ts';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
@ -40,6 +42,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
const me = useAppSelector(state => state.me);
const features = useFeatures();
const { displayFqn } = useSoapboxConfig();
const { wallet } = useWallet();
// Fix case of username
if (account && account.acct !== username) {
@ -118,6 +121,10 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
<SignUpPanel />
)}
{wallet && (
<PocketWallet />
)}
{features.notes && account && account?.id !== me && (
<AccountNotePanel account={account} />
)}

Wyświetl plik

@ -9,7 +9,9 @@ import {
CtaBanner,
LatestAccountsPanel,
SuggestedGroupsPanel,
PocketWallet,
} from 'soapbox/features/ui/util/async-components.ts';
import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
@ -21,6 +23,7 @@ const ExplorePage: React.FC<IExplorePage> = ({ children }) => {
const me = useAppSelector(state => state.me);
const features = useFeatures();
const accountsPath = useLocation().pathname === '/explore/accounts';
const { wallet } = useWallet();
return (
<>
@ -37,6 +40,10 @@ const ExplorePage: React.FC<IExplorePage> = ({ children }) => {
<SignUpPanel />
)}
{wallet && (
<PocketWallet />
)}
{features.trends && (
<TrendsPanel limit={5} />
)}

Wyświetl plik

@ -222,7 +222,7 @@ const simulateDislike = (
};
/** Simulate zap of status for optimistic interactions */
const simulateZap = (state: State, statusId: string, zapped: boolean): State => {
const simulatePayment = (state: State, statusId: string, zapped: boolean): State => {
const status = state.get(statusId);
if (!status) return state;
@ -288,9 +288,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, true);
return simulatePayment(state, action.status.id, true);
case ZAP_FAIL:
return simulateZap(state, action.status.id, false);
return simulatePayment(state, action.status.id, false);
case REBLOG_REQUEST:
return state.setIn([action.status.id, 'reblogged'], true);
case REBLOG_FAIL:

Wyświetl plik

@ -38,6 +38,7 @@ const baseAccountSchema = z.object({
display_name: z.string().catch(''),
ditto: coerceObject({
accepts_zaps: z.boolean().catch(false),
accepts_zaps_cashu: z.boolean().catch(false),
external_url: z.string().optional().catch(undefined),
streak: coerceObject({
days: z.number().catch(0),

Wyświetl plik

@ -73,6 +73,8 @@ const baseStatusSchema = z.object({
visibility: z.string().catch('public'),
zapped: z.coerce.boolean(),
zaps_amount: z.number().catch(0),
zapped_cashu: z.coerce.boolean(),
zaps_amount_cashu: z.number().catch(0),
});
type BaseStatus = z.infer<typeof baseStatusSchema>;

Wyświetl plik

@ -0,0 +1,53 @@
import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { accountSchema } from 'soapbox/schemas/account.ts';
const baseWalletSchema = z.object({
pubkey_p2pk: n.id(),
mints: z.array(z.string().url()).nonempty(),
relays: z.array(z.string()).nonempty(),
balance: z.number(),
});
const quoteSchema = z.object({
expiry: z.number(),
paid: z.boolean(),
quote: z.string(),
request: z.string(),
state: z.enum(['UNPAID', 'PAID', 'ISSUED']),
});
const transactionSchema = z.object({
amount: z.number(),
created_at: z.number(),
direction: z.enum(['in', 'out']),
});
const nutzappedEntry = z.array(
z.object({
comment: z.string(),
amount: z.number(),
account: accountSchema,
}),
);
const nutzappedRecord = z.record(
z.string(),
nutzappedEntry,
);
const transactionsSchema = z.array(transactionSchema);
type NutzappedEntry = z.infer<typeof nutzappedEntry>
type NutzappedRecord = z.infer<typeof nutzappedRecord>
type Transactions = z.infer<typeof transactionsSchema>
type Quote = z.infer<typeof quoteSchema>
type WalletData = z.infer<typeof baseWalletSchema>;
export { baseWalletSchema, quoteSchema, transactionsSchema, nutzappedRecord, nutzappedEntry, type WalletData, type Quote, type Transactions, type NutzappedRecord, type NutzappedEntry };