diff --git a/src/features/cashu/hooks/useCashu.ts b/src/features/cashu/hooks/useCashu.ts new file mode 100644 index 000000000..5d13c4d9b --- /dev/null +++ b/src/features/cashu/hooks/useCashu.ts @@ -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 }; \ No newline at end of file diff --git a/src/features/cashu/index.tsx b/src/features/cashu/index.tsx new file mode 100644 index 000000000..6e3c4f66b --- /dev/null +++ b/src/features/cashu/index.tsx @@ -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://' }, + 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([]); + + 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 ( + +
+ + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ ); +}; + +type Mint = string + +const CashuInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (): React.ChangeEventHandler => { + return e => { + onChange(e.currentTarget.value); + }; + }; + + return ( + + + + + + + + ); +}; + +export default Cashu; diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx index 33ec3324b..962078379 100644 --- a/src/features/settings/index.tsx +++ b/src/features/settings/index.tsx @@ -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 = () => { )} {features.nostr && } + {features.nostr && } diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index ff3f2799c..22b6c4957 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -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 = ({ children }) => {features.accountMoving && } {features.backups && } + diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 1db53d6a1..9754c3c34 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -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')); diff --git a/src/locales/en.json b/src/locales/en.json index 1dd23058b..a7505f91b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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://", "nostr_extension.found": "Sign in 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.",