Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/assets/star-cards/blockandbean.agi.cash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/star-cards/fake.agi.cash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/star-cards/fake2.agi.cash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/star-cards/fake4.agi.cash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion app/components/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChevronLeft, X } from 'lucide-react';
import React from 'react';
import { useLocation } from 'react-router';
import {
LinkWithViewTransition,
type ViewTransitionLinkProps,
Expand Down Expand Up @@ -27,8 +28,10 @@ export function Page({ children, className, ...props }: PageProps) {
interface ClosePageButtonProps extends ViewTransitionLinkProps {}

export function ClosePageButton({ className, ...props }: ClosePageButtonProps) {
const location = useLocation();
const redirectTo = new URLSearchParams(location.search).get('redirectTo');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return (
<LinkWithViewTransition {...props}>
<LinkWithViewTransition {...props} to={redirectTo || props.to}>
<X />
</LinkWithViewTransition>
);
Expand Down
49 changes: 40 additions & 9 deletions app/features/accounts/account-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useSuspenseQuery,
} from '@tanstack/react-query';
import { useCallback, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { type Currency, Money } from '~/lib/money';
import { useSupabaseRealtime } from '~/lib/supabase';
import { useLatest } from '~/lib/use-latest';
Expand All @@ -19,6 +20,7 @@ import {
type CashuAccount,
type ExtendedAccount,
getAccountBalance,
isStarAccount,
} from './account';
import {
type AccountRepository,
Expand Down Expand Up @@ -277,6 +279,8 @@ export const accountsQueryOptions = ({
export function useAccounts<T extends AccountType = AccountType>(select?: {
currency?: Currency;
type?: T;
excludeStarAccounts?: boolean;
starAccountsOnly?: boolean;
}): UseSuspenseQueryResult<ExtendedAccount<T>[]> {
const user = useUser();
const accountRepository = useAccountRepository();
Expand All @@ -301,13 +305,25 @@ export function useAccounts<T extends AccountType = AccountType>(select?: {
if (select.type && account.type !== select.type) {
return false;
}
if (select.excludeStarAccounts && isStarAccount(account)) {
return false;
}
if (select.starAccountsOnly && !isStarAccount(account)) {
return false;
}
return true;
},
);

return filteredData;
},
[select?.currency, select?.type, user],
[
select?.currency,
select?.type,
select?.excludeStarAccounts,
select?.starAccountsOnly,
user,
],
),
});
}
Expand Down Expand Up @@ -430,14 +446,29 @@ export function useAddCashuAccount() {
return mutateAsync;
}

/**
* @returns the total balance of all accounts for the given currency excluding Star accounts.
*/
export function useBalance(currency: Currency) {
const { data: accounts } = useAccounts({ currency });
const balance = accounts.reduce(
(acc, account) => {
const accountBalance = getAccountBalance(account);
return acc.add(accountBalance);
},
new Money({ amount: 0, currency }),
);
const { data: accounts } = useAccounts({
currency,
excludeStarAccounts: true,
});
const balance = accounts.reduce((acc, account) => {
const accountBalance = getAccountBalance(account);
return acc.add(accountBalance);
}, Money.zero(currency));
return balance;
}

/**
* Returns the account specified by the account ID in the URL.
* @param select - The type of the account to get.
*/
export function useGetAccountFromLocation(select?: { type?: AccountType }) {
const [searchParams] = useSearchParams();
const accountId = searchParams.get('accountId');
const { data: accounts } = useAccounts({ type: select?.type });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have useAccount hook. can we use that?

const account = accounts.find((account) => account.id === accountId);
return account;
}
8 changes: 5 additions & 3 deletions app/features/accounts/account-icons.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { LandmarkIcon, Zap } from 'lucide-react';
import { LandmarkIcon, StarIcon, Zap } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AccountType } from './account';

const CashuIcon = () => <LandmarkIcon className="h-4 w-4" />;
const NWCIcon = () => <Zap className="h-4 w-4" />;
const StarsIcon = () => <StarIcon className="h-4 w-4" />;

const iconsByAccountType: Record<AccountType, ReactNode> = {
const iconsByAccountType: Record<AccountType | 'star', ReactNode> = {
cashu: <CashuIcon />,
nwc: <NWCIcon />,
star: <StarsIcon />,
};

export function AccountTypeIcon({ type }: { type: AccountType }) {
export function AccountTypeIcon({ type }: { type: AccountType | 'star' }) {
return iconsByAccountType[type];
}
78 changes: 43 additions & 35 deletions app/features/accounts/account-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,45 +186,53 @@ export class AccountRepository {
private async getPreloadedWallet(mintUrl: string, currency: Currency) {
const seed = await this.getCashuWalletSeed?.();

// TODO: handle fetching errors. If the mint is unreachable these will throw,
// and the error will bubble up to the user and brick the app.
const [mintInfo, allMintKeysets, mintActiveKeys] = await Promise.all([
this.queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)),
this.queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)),
this.queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)),
]);

const unitKeysets = allMintKeysets.keysets.filter(
(ks) => ks.unit === getCashuProtocolUnit(currency),
);
const activeKeyset = unitKeysets.find((ks) => ks.active);

if (!activeKeyset) {
throw new Error(`No active keyset found for ${currency} on ${mintUrl}`);
}

const activeKeysForUnit = mintActiveKeys.keysets.find(
(ks) => ks.id === activeKeyset.id,
);

if (!activeKeysForUnit) {
throw new Error(
`Got active keyset ${activeKeyset.id} from ${mintUrl} but could not find keys for it`,
try {
const [mintInfo, allMintKeysets, mintActiveKeys] = await Promise.all([
this.queryClient.fetchQuery(mintInfoQueryOptions(mintUrl)),
this.queryClient.fetchQuery(allMintKeysetsQueryOptions(mintUrl)),
this.queryClient.fetchQuery(mintKeysQueryOptions(mintUrl)),
]);

const unitKeysets = allMintKeysets.keysets.filter(
(ks) => ks.unit === getCashuProtocolUnit(currency),
);
}
const activeKeyset = unitKeysets.find((ks) => ks.active);

const wallet = getCashuWallet(mintUrl, {
unit: getCashuUnit(currency),
bip39seed: seed ?? undefined,
mintInfo,
keys: activeKeysForUnit,
keysets: unitKeysets,
});
if (!activeKeyset) {
throw new Error(`No active keyset found for ${currency} on ${mintUrl}`);
}

// The constructor does not set the keysetId, so we need to set it manually
wallet.keysetId = activeKeyset.id;
const activeKeysForUnit = mintActiveKeys.keysets.find(
(ks) => ks.id === activeKeyset.id,
);

return wallet;
if (!activeKeysForUnit) {
throw new Error(
`Got active keyset ${activeKeyset.id} from ${mintUrl} but could not find keys for it`,
);
}

const wallet = getCashuWallet(mintUrl, {
unit: getCashuUnit(currency),
bip39seed: seed ?? undefined,
mintInfo,
keys: activeKeysForUnit,
keysets: unitKeysets,
});

// The constructor does not set the keysetId, so we need to set it manually
wallet.keysetId = activeKeyset.id;

return wallet;
} catch {
// TODO: This is a quick fix to prevent the app from bricking when a mint is unreachable
// We should disable the account completley in this case because if the mint is offline,
// the account won't be usable.
return getCashuWallet(mintUrl, {
unit: getCashuUnit(currency),
bip39seed: seed ?? undefined,
});
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion app/features/accounts/account-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ScrollArea } from '~/components/ui/scroll-area';
import { cn } from '~/lib/utils';
import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount';
import { type Account, getAccountBalance } from './account';
import { isStarAccount } from './account';
import { AccountTypeIcon } from './account-icons';

export type AccountWithBadges<T extends Account = Account> = T & {
Expand All @@ -26,7 +27,7 @@ function AccountItem({ account }: { account: AccountWithBadges }) {

return (
<div className="flex w-full items-center gap-4 px-3 py-4">
<AccountTypeIcon type={account.type} />
<AccountTypeIcon type={isStarAccount(account) ? 'star' : account.type} />
<div className="flex w-full flex-col justify-between gap-2 text-start">
<span className="font-medium">{account.name}</span>
<div className="flex items-center justify-between text-xs">
Expand Down
3 changes: 3 additions & 0 deletions app/features/accounts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ export const getAccountBalance = (account: Account) => {
// TODO: implement balance logic for other account types
return new Money({ amount: 0, currency: account.currency });
};

export const isStarAccount = (account: Account) =>
account.type === 'cashu' && account.wallet.cachedMintInfo.internalMeltsOnly;
5 changes: 3 additions & 2 deletions app/features/receive/receive-cashu-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getCashuUnit,
getCashuWallet,
} from '~/lib/cashu';
import type { ExtendedCashuAccount } from '../accounts/account';
import { type ExtendedCashuAccount, isStarAccount } from '../accounts/account';
import {
allMintKeysetsQueryOptions,
cashuMintValidator,
Expand Down Expand Up @@ -198,8 +198,9 @@ export class ReceiveCashuTokenService {
sourceAccount: CashuAccountWithTokenFlags,
otherAccounts: CashuAccountWithTokenFlags[],
): CashuAccountWithTokenFlags[] {
if (sourceAccount.isTestMint) {
if (sourceAccount.isTestMint || isStarAccount(sourceAccount)) {
// Tokens sourced from test mint can only be claimed to the same mint
// Tokens sourced from Star accounts cannot pay external invoices
return sourceAccount.isSelectable ? [sourceAccount] : [];
}
return [sourceAccount, ...otherAccounts].filter(
Expand Down
46 changes: 31 additions & 15 deletions app/features/receive/receive-cashu-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import {
LinkWithViewTransition,
useNavigateWithViewTransition,
} from '~/lib/transitions';
import { isStarAccount } from '../accounts/account';
import { AccountSelector } from '../accounts/account-selector';
import { tokenToMoney } from '../shared/cashu';
import { getErrorMessage } from '../shared/error';
import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount';
import { WalletCard } from '../stars/wallet-card';
import { useAuthActions } from '../user/auth';
import { useFailCashuReceiveQuote } from './cashu-receive-quote-hooks';
import { useCreateCashuTokenSwap } from './cashu-token-swap-hooks';
Expand Down Expand Up @@ -204,14 +206,22 @@ export default function ReceiveToken({

<div className="absolute top-0 right-0 bottom-0 left-0 mx-auto flex max-w-sm items-center justify-center">
{claimableToken && receiveAccount ? (
<div className="w-full max-w-sm px-4">
<AccountSelector
accounts={selectableAccounts}
selectedAccount={receiveAccount}
disabled={isCrossMintSwapDisabled}
onSelect={setReceiveAccount}
/>
</div>
isStarAccount(receiveAccount) ? (
<div className="w-full max-w-sm px-4">
<WalletCard account={receiveAccount} />
</div>
) : (
<div className="w-full max-w-sm px-4">
<AccountSelector
accounts={selectableAccounts}
selectedAccount={receiveAccount}
disabled={
isCrossMintSwapDisabled || selectableAccounts.length === 1
}
onSelect={setReceiveAccount}
/>
</div>
)
) : (
<TokenErrorDisplay
message={
Expand Down Expand Up @@ -330,13 +340,19 @@ export function PublicReceiveCashuToken({ token }: { token: Token }) {

<div className="absolute top-0 right-0 bottom-0 left-0 mx-auto flex max-w-sm items-center justify-center">
{claimableToken ? (
<div className="w-full max-w-sm px-4">
<AccountSelector
accounts={[]}
selectedAccount={sourceAccount}
disabled={true}
/>
</div>
isStarAccount(sourceAccount) ? (
<div className="w-full max-w-sm px-4">
<WalletCard account={sourceAccount} />
</div>
) : (
<div className="w-full max-w-sm px-4">
<AccountSelector
accounts={[]}
selectedAccount={sourceAccount}
disabled={true}
/>
</div>
)
) : (
<TokenErrorDisplay message={cannotClaimReason} />
)}
Expand Down
5 changes: 4 additions & 1 deletion app/features/settings/accounts/all-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import type { Currency } from '~/lib/money';
import { LinkWithViewTransition } from '~/lib/transitions';

function CurrencyAccounts({ currency }: { currency: Currency }) {
const { data: accounts } = useAccounts({ currency });
const { data: accounts } = useAccounts({
currency,
excludeStarAccounts: true,
});

return (
<div className="space-y-3">
Expand Down
5 changes: 4 additions & 1 deletion app/features/settings/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useToast } from '~/hooks/use-toast';
import { canShare, shareContent } from '~/lib/share';
import { LinkWithViewTransition } from '~/lib/transitions';
import { cn } from '~/lib/utils';
import { isStarAccount } from '../accounts/account';
import { useDefaultAccount } from '../accounts/account-hooks';
import { AccountTypeIcon } from '../accounts/account-icons';
import { ColorModeToggle } from '../theme/color-mode-toggle';
Expand Down Expand Up @@ -111,7 +112,9 @@ export default function Settings() {
</SettingsNavButton>

<SettingsNavButton to="/settings/accounts">
<AccountTypeIcon type={defaultAccount.type} />
<AccountTypeIcon
type={isStarAccount(defaultAccount) ? 'star' : defaultAccount.type}
/>
<span>{defaultAccount.name}</span>
</SettingsNavButton>

Expand Down
2 changes: 1 addition & 1 deletion app/features/shared/cashu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export const cashuMintValidator = buildMintValidator({
export const mintInfoQueryOptions = (mintUrl: string) =>
queryOptions({
queryKey: ['mint-info', mintUrl],
queryFn: async () => getCashuWallet(mintUrl).getMintInfo(),
queryFn: async () => getCashuWallet(mintUrl).getExtendedMintInfo(),
staleTime: 1000 * 60 * 60, // 1 hour
});

Expand Down
18 changes: 18 additions & 0 deletions app/features/stars/animation-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Duration constants (in ms)
export const ANIMATION_DURATION = 400;
export const DETAIL_VIEW_DELAY = 300; // Delay before detail content starts animating
export const OPACITY_ANIMATION_RATIO = 0.5; // Multiplier for opacity animation duration

export const EASE_IN_OUT = 'cubic-bezier(0.25, 0.1, 0.25, 1)';
export const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)'; // Decelerating

// Layout constants (in px)
export const CARD_STACK_OFFSET = 64; // Space between cards in collapsed stack
export const CARD_ASPECT_RATIO = 1.586; // Credit card aspect ratio
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how was this num picked?


/**
* Get the off-screen Y offset for sliding cards out
*/
export function getOffScreenOffset(): number {
return typeof window !== 'undefined' ? window.innerHeight + 100 : 900; // Fallback for SSR
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are these numbers picked?

}
Loading