Merge branch 'cashu' into 'main'

Draft: checkpoint: call '/api/v1/ditto/wallet/create' endpoint

See merge request soapbox-pub/soapbox!3329
merge-requests/3329/merge
P. Reis 2025-06-07 12:44:56 -03:00
commit d33c8f6a32
6 zmienionych plików z 488 dodań i 1 usunięć

Wyświetl plik

@ -0,0 +1,69 @@
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'soapbox/hooks/useApi.ts';
import { queryClient } from 'soapbox/queries/client.ts';
function useCashu() {
const api = useApi();
const { mutate: createWallet } = useMutation({
mutationFn: (data: {mints: string[]}) => api.post('/api/v1/ditto/wallet/create', data),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'create', 'wallet'] });
},
});
const { mutate: createNutzapInfo } = useMutation({
mutationFn: (data: {mints: string[]; relays: string[]}) => api.post('/api/v1/ditto/nutzap_information/create', data),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'nutzap', 'info'] });
},
});
const { mutate: swapCashuToWallet } = useMutation({
mutationFn: () => api.post('/api/v1/ditto/nutzap/swap_to_wallet'),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'swap', 'wallet'] });
},
});
const { mutate: createQuote } = useMutation({
mutationFn: (data: {mint: string; amount: number}) => api.post('/api/v1/ditto/cashu/quote', data),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'nutzap', 'info'] });
},
});
const { mutate: getQuoteState } = useMutation({
mutationFn: (quote_id: string) => api.get(`/api/v1/ditto/cashu/quote/${quote_id}`),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'nutzap', 'info'] });
},
});
const { mutate: getWallet } = useMutation({
mutationFn: () => api.get('/api/v1/ditto/cashu/wallet'),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'nutzap', 'info'] });
},
});
const { mutate: mintTheMint } = useMutation({
mutationFn: (quote_id: string) => api.post(`/api/v1/ditto/cashu/mint/${quote_id}`),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['cashu', 'nutzap', 'info'] });
},
});
return {
getWallet,
mintTheMint,
getQuoteState,
createQuote,
createWallet,
createNutzapInfo,
swapCashuToWallet,
};
}
export { useCashu };

Wyświetl plik

@ -0,0 +1,403 @@
import { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Button from 'soapbox/components/ui/button.tsx';
import { Column } from 'soapbox/components/ui/column.tsx';
import FormActions from 'soapbox/components/ui/form-actions.tsx';
import FormGroup from 'soapbox/components/ui/form-group.tsx';
import Form from 'soapbox/components/ui/form.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield.tsx';
import { useCashu } from 'soapbox/features/cashu/hooks/useCashu.ts';
import toast from 'soapbox/toast.tsx';
const messages = defineMessages({
title: { id: 'cashu.wallet.create', defaultMessage: 'Create Cashu Wallet' },
mints: { id: 'cashu.wallet.mints', defaultMessage: 'Your mints' },
nutzap_info: { id: 'cashu.nutzap.info', defaultMessage: 'Your nutzap info' },
swap_cashu: { id: 'cashu.nutzap.swap', defaultMessage: 'Swap your Cashu' },
mint_placeholder: { id: 'cashu.wallet.mint_placeholder', defaultMessage: 'https://<mint-url>' },
submit_success: { id: 'generic.saved', defaultMessage: 'Saved!' },
create_cashu_quote: { id: 'cashu.quote', defaultMessage: 'Create Cashu Quote' },
get_cashu_quote_state: { id: 'cashu.quote.state', defaultMessage: 'Create Cashu Quote' },
mint_the_mint: { id: 'cashu.quote.mint', defaultMessage: 'Mint the Mint' },
get_wallet: { id: 'cashu.get_wallet', defaultMessage: 'Get wallet' },
});
const Cashu = () => {
const intl = useIntl();
const { createWallet, createNutzapInfo, swapCashuToWallet, createQuote, getQuoteState, mintTheMint, getWallet } = useCashu();
const [mints, setMints] = useState<string[]>([]);
const updateData = (values: string[]) => {
setMints(values);
};
const handleStreamItemChange = () => {
return (values: string[]) => {
updateData(values);
};
};
const deleteStreamItem = () => {
return (i: number) => {
setMints(prevData => {
return [...prevData ].toSpliced(i, 1);
});
};
};
const handleAddMint = (): void => {
setMints(prevData => [...prevData, '']);
};
const handleCreateWalletSubmit: React.FormEventHandler = async (event) => {
event.preventDefault();
createWallet({ mints }, {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleCreateNutzapInfoSubmit: React.FormEventHandler = async (event) => {
event.preventDefault();
createNutzapInfo({ mints, relays: [] }, {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleSwapWalletSubmit: React.FormEventHandler = async (event) => {
event.preventDefault();
swapCashuToWallet(undefined, {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleMintQuote: React.FormEventHandler = async (event) => {
event.preventDefault();
createQuote({ mint: mints[0], amount: 20 }, {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleGetMintQuoteState: React.FormEventHandler = async (event) => {
event.preventDefault();
getQuoteState(mints[0], {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleMintTheMintQuoteState: React.FormEventHandler = async (event) => {
event.preventDefault();
mintTheMint(mints[0], {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
const handleGetWallet: React.FormEventHandler = async (event) => {
event.preventDefault();
getWallet(undefined, {
onSuccess: async () => {
toast.success(messages.submit_success);
},
onError: async (err) => {
if (err instanceof HTTPError) {
try {
const { error } = await err.response.json();
if (typeof error === 'string') {
toast.error(error);
return;
}
} catch { /* empty */ }
}
toast.error(err.message);
},
});
};
return (
<Column label={intl.formatMessage(messages.title)}>
<Form onSubmit={handleCreateWalletSubmit}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.mints)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleCreateNutzapInfoSubmit}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.nutzap_info)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleSwapWalletSubmit}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.swap_cashu)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleMintQuote}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.create_cashu_quote)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleGetMintQuoteState}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.get_cashu_quote_state)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleMintTheMintQuoteState}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.mint_the_mint)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
<Form onSubmit={handleGetWallet}>
<Stack space={4}>
<Streamfield
label={intl.formatMessage(messages.get_wallet)}
component={CashuInput}
values={mints}
onChange={handleStreamItemChange()}
onAddItem={handleAddMint}
onRemoveItem={deleteStreamItem()}
/>
<FormActions>
<Button to='/settings' theme='tertiary'>
<FormattedMessage id='common.cancel' defaultMessage='Cancel' />
</Button>
<Button theme='primary' type='submit'>
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
</Button>
</FormActions>
</Stack>
</Form>
</Column>
);
};
type Mint = string
const CashuInput: StreamfieldComponent<Mint> = ({ value, onChange }) => {
const intl = useIntl();
const handleChange = (): React.ChangeEventHandler<HTMLInputElement> => {
return e => {
onChange(e.currentTarget.value);
};
};
return (
<Stack space={3} grow className='my-2'>
<FormGroup>
<Input
type='text'
outerClassName='grow'
value={value}
onChange={handleChange()}
placeholder={intl.formatMessage(messages.mint_placeholder)}
/>
</FormGroup>
</Stack>
);
};
export default Cashu;

Wyświetl plik

@ -32,6 +32,7 @@ const messages = defineMessages({
editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' },
editIdentity: { id: 'settings.edit_identity', defaultMessage: 'Identity' },
editRelays: { id: 'nostr_relays.title', defaultMessage: 'Relays' },
manageCashu: { id: 'settings.nostr.cashu', defaultMessage: 'Cashu' },
exportData: { id: 'column.export_data', defaultMessage: 'Export data' },
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' },
@ -88,6 +89,7 @@ const Settings = () => {
</ListItem>
)}
{features.nostr && <ListItem label={intl.formatMessage(messages.editRelays)} to='/settings/relays' />}
{features.nostr && <ListItem label={intl.formatMessage(messages.manageCashu)} to='/settings/cashu' />}
</List>
</CardBody>

Wyświetl plik

@ -145,7 +145,7 @@ import {
LandingTimeline,
EditIdentity,
Domains,
NostrRelays,
Cashu,
Bech32Redirect,
Relays,
ManageZapSplit,
@ -153,6 +153,7 @@ import {
AdminNostrRelays,
NostrBunkerLogin,
ManageDittoServer,
NostrRelays,
} from './util/async-components.ts';
import GlobalHotkeys from './util/global-hotkeys.tsx';
import { WrappedRoute } from './util/react-router-helpers.tsx';
@ -328,6 +329,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.accountMoving && <WrappedRoute path='/settings/migration' page={DefaultPage} component={Migration} content={children} />}
{features.backups && <WrappedRoute path='/settings/backups' page={DefaultPage} component={Backups} content={children} />}
<WrappedRoute path='/settings/relays' page={DefaultPage} component={NostrRelays} content={children} />
<WrappedRoute path='/settings/cashu' page={DefaultPage} component={Cashu} content={children} />
<WrappedRoute path='/settings/email' page={DefaultPage} component={EditEmail} content={children} />
<WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} />
<WrappedRoute path='/settings/account' page={DefaultPage} component={DeleteAccount} content={children} />

Wyświetl plik

@ -167,6 +167,7 @@ export const EditIdentity = lazy(() => import('soapbox/features/edit-identity/in
export const Domains = lazy(() => import('soapbox/features/admin/domains.tsx'));
export const EditDomainModal = lazy(() => import('soapbox/features/ui/components/modals/edit-domain-modal.tsx'));
export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays/index.tsx'));
export const Cashu = lazy(() => import('soapbox/features/cashu/index.tsx'));
export const Bech32Redirect = lazy(() => import('soapbox/features/nostr/Bech32Redirect.tsx'));
export const ManageZapSplit = lazy(() => import('soapbox/features/admin/manage-zap-split.tsx'));
export const ManageDittoServer = lazy(() => import('soapbox/features/admin/manage-ditto-server.tsx'));

Wyświetl plik

@ -1190,6 +1190,16 @@
"new_group_panel.action": "Create Group",
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
"new_group_panel.title": "Create Group",
"settings.nostr.cashu": "Cashu",
"cashu.title": "Cashu",
"cashu.wallet.create": "Create Cashu Wallet",
"cashu.nutzap.info": "Your nutzap info",
"cashu.nutzap.swap": "Swap your Cashu",
"cashu.quote": "Create Cashu Quote",
"cashu.quote.state": "Get Cashu Quote State",
"cashu.wallet.mints": "Your mints",
"cashu.quote.mint": "Mint the Mint",
"cashu.wallet.mint_placeholder": "https://<mint-url>",
"nostr_extension.found": "<link>Sign in</link> with browser extension.",
"nostr_extension.not_found": "Browser extension not found.",
"nostr_extension.not_supported": "Browser extension not supported. Please upgrade to the latest version.",