kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'create-wallet' into 'main'
Cashu wallet See merge request soapbox-pub/soapbox!3333merge-requests/3365/head
commit
75e1e9ecd6
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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 />
|
||||
|
|
|
@ -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'));
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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 });
|
||||
},
|
||||
}),
|
||||
);
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 };
|
Ładowanie…
Reference in New Issue