Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .changeset/tough-dogs-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"thirdweb": minor
---

Support Account and Wallet headless components in react native

You can now use the Account and Wallet headless components in react native, this lets you build your own UI, styling it however you want, but letting the components handle the logic.

Example Account components usage:

```tsx
<AccountProvider address={account.address} client={client}>
/* avatar */
<AccountAvatar
loadingComponent={
<AccountBlobbie size={92} style={{ borderRadius: 100 }} />
}
fallbackComponent={
<AccountBlobbie size={92} style={{ borderRadius: 100 }} />
}
style={{
width: 92,
height: 92,
borderRadius: 100,
}}
/>
/* address */
<AccountAddress
style={{ fontSize: 16, color: Colors.secondary }}
formatFn={shortenAddress}
/>
/* balance */
<AccountBalance
showBalanceInFiat={"USD"}
chain={chain}
loadingComponent={<ActivityIndicator size="large" color={Colors.accent} />}
fallbackComponent={
<Text className="text-primary">Failed to load balance</Text>
}
style={{
color: "white",
fontSize: 48,
fontWeight: "bold",
}}
/>
</AccountProvider>
```

Example Wallet components usage:

```tsx
<WalletProvider id={"io.metamask"}>
<WalletIcon width={32} height={32} />
<WalletName style={{ fontSize: 16, color: Colors.primary }} />
</WalletProvider>
```
47 changes: 47 additions & 0 deletions packages/thirdweb/src/exports/react.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,50 @@ export type {
FarcasterProfile,
LensProfile,
} from "../social/types.js";

/**
* Account
*/
export {
Blobbie,
AccountBlobbie,
type BlobbieProps,
} from "../react/native/ui/prebuilt/Account/blobbie.js";
export {
AccountProvider,
type AccountProviderProps,
} from "../react/core/account/provider.js";
export {
AccountBalance,
type AccountBalanceProps,
} from "../react/native/ui/prebuilt/Account/balance.js";
export {
AccountAddress,
type AccountAddressProps,
} from "../react/native/ui/prebuilt/Account/address.js";
export {
AccountName,
type AccountNameProps,
} from "../react/native/ui/prebuilt/Account/name.js";
export {
AccountAvatar,
type AccountAvatarProps,
} from "../react/native/ui/prebuilt/Account/avatar.js";

/**
* Wallet
*/
export {
WalletProvider,
type WalletProviderProps,
} from "../react/core/wallet/provider.js";
export {
WalletIcon,
SocialIcon,
type WalletIconProps,
type SocialIconProps,
} from "../react/native/ui/prebuilt/Wallet/icon.js";
export {
WalletName,
type WalletNameProps,
} from "../react/native/ui/prebuilt/Wallet/name.js";
8 changes: 5 additions & 3 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@ export {
export {
AccountBalance,
type AccountBalanceProps,
type AccountBalanceInfo,
} from "../react/web/ui/prebuilt/Account/balance.js";
export type { AccountBalanceInfo } from "../react/core/utils/account.js";
export {
AccountName,
type AccountNameProps,
Expand All @@ -233,7 +233,7 @@ export { AccountBlobbie } from "../react/web/ui/prebuilt/Account/blobbie.js";
export {
AccountProvider,
type AccountProviderProps,
} from "../react/web/ui/prebuilt/Account/provider.js";
} from "../react/core/account/provider.js";
export {
AccountAvatar,
type AccountAvatarProps,
Expand Down Expand Up @@ -278,9 +278,11 @@ export { getLastAuthProvider } from "../react/web/utils/storage.js";
export {
WalletProvider,
type WalletProviderProps,
} from "../react/web/ui/prebuilt/Wallet/provider.js";
} from "../react/core/wallet/provider.js";
export {
WalletIcon,
SocialIcon,
type SocialIconProps,
type WalletIconProps,
} from "../react/web/ui/prebuilt/Wallet/icon.js";
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { AccountAddress } from "./address.js";
import { AccountAddress } from "../../web/ui/prebuilt/Account/address.js";
import { AccountProvider } from "./provider.js";

describe.runIf(process.env.TW_SECRET_KEY)("AccountProvider component", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"use client";

import type { Address } from "abitype";
import type React from "react";
import { createContext, useContext } from "react";
import type { ThirdwebClient } from "../../../../../client/client.js";

import type { ThirdwebClient } from "../../../client/client.js";
import { isAddress } from "../../../utils/address.js";
/**
* Props for the <AccountProvider /> component
* @component
Expand All @@ -14,7 +13,7 @@ export type AccountProviderProps = {
/**
* The user's wallet address
*/
address: Address;
address: string;
/**
* thirdweb Client
*/
Expand All @@ -35,7 +34,7 @@ const AccountProviderContext = /* @__PURE__ */ createContext<
* ```tsx
* import { AccountProvider, AccountAvatar, AccountName, AccountAddress } from "thirdweb/react";
*
* <AccountProvider>
* <AccountProvider address="0x..." client={client}>
* <AccountAvatar />
* <AccountName />
* <AccountAddress />
Expand All @@ -54,6 +53,9 @@ export function AccountProvider(
"AccountProvider: No address passed. Ensure an address is always provided to the AccountProvider",
);
}
if (!isAddress(props.address)) {
throw new Error(`AccountProvider: Invalid address: ${props.address}`);
}
return (
<AccountProviderContext.Provider value={props}>
{props.children}
Expand Down
146 changes: 146 additions & 0 deletions packages/thirdweb/src/react/core/utils/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { Chain } from "../../../chains/types.js";
import type { ThirdwebClient } from "../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js";
import { convertCryptoToFiat } from "../../../pay/convert/cryptoToFiat.js";
import type { SupportedFiatCurrency } from "../../../pay/convert/type.js";
import { type Address, isAddress } from "../../../utils/address.js";
import { formatNumber } from "../../../utils/formatNumber.js";
import { shortenLargeNumber } from "../../../utils/shortenLargeNumber.js";
import { getWalletBalance } from "../../../wallets/utils/getWalletBalance.js";

export const COLOR_OPTIONS = [
["#fca5a5", "#b91c1c"],
["#fdba74", "#c2410c"],
["#fcd34d", "#b45309"],
["#fde047", "#a16207"],
["#a3e635", "#4d7c0f"],
["#86efac", "#15803d"],
["#67e8f9", "#0e7490"],
["#7dd3fc", "#0369a1"],
["#93c5fd", "#1d4ed8"],
["#a5b4fc", "#4338ca"],
["#c4b5fd", "#6d28d9"],
["#d8b4fe", "#7e22ce"],
["#f0abfc", "#a21caf"],
["#f9a8d4", "#be185d"],
["#fda4af", "#be123c"],
];

/**
* @component
* @wallet
*/
export type AccountBalanceInfo = {
/**
* Represents either token balance or fiat balance.
*/
balance: number;
/**
* Represents either token symbol or fiat symbol
*/
symbol: string;
};

/**
* @internal Exported for tests
*/
export async function loadAccountBalance(props: {
chain?: Chain;
client: ThirdwebClient;
address: Address;
tokenAddress?: Address;
showBalanceInFiat?: SupportedFiatCurrency;
}): Promise<AccountBalanceInfo> {
const { chain, client, address, tokenAddress, showBalanceInFiat } = props;
if (!chain) {
throw new Error("chain is required");
}

if (
tokenAddress &&
tokenAddress?.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
) {
throw new Error(`Invalid tokenAddress - cannot be ${NATIVE_TOKEN_ADDRESS}`);
}

if (!isAddress(address)) {
throw new Error("Invalid wallet address. Expected an EVM address");
}

if (tokenAddress && !isAddress(tokenAddress)) {
throw new Error("Invalid tokenAddress. Expected an EVM contract address");
}

const tokenBalanceData = await getWalletBalance({
chain,
client,
address,
tokenAddress,
}).catch(() => undefined);

if (!tokenBalanceData) {
throw new Error(
`Failed to retrieve ${tokenAddress ? `token: ${tokenAddress}` : "native token"} balance for address: ${address} on chainId:${chain.id}`,
);
}

if (showBalanceInFiat) {
const fiatData = await convertCryptoToFiat({
fromAmount: Number(tokenBalanceData.displayValue),
fromTokenAddress: tokenAddress || NATIVE_TOKEN_ADDRESS,
to: showBalanceInFiat,
chain,
client,
}).catch(() => undefined);

if (fiatData === undefined) {
throw new Error(
`Failed to resolve fiat value for ${tokenAddress ? `token: ${tokenAddress}` : "native token"} on chainId: ${chain.id}`,
);
}
const result = {
balance: fiatData?.result,
symbol: getFiatSymbol(showBalanceInFiat),
};

return result;
}

return {
balance: Number(tokenBalanceData.displayValue),
symbol: tokenBalanceData.symbol,
};
}

function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) {
switch (showBalanceInFiat) {
case "USD":
return "$";
}
}

/**
* Format the display balance for both crypto and fiat, in the Details button and Modal
* If both crypto balance and fiat balance exist, we have to keep the string very short to avoid UI issues.
* @internal
* Used internally for the Details button and the Details Modal
*/
export function formatAccountTokenBalance(
props: AccountBalanceInfo & { decimals: number },
): string {
const formattedTokenBalance = formatNumber(props.balance, props.decimals);
return `${formattedTokenBalance} ${props.symbol}`;
}

/**
* Used internally for the Details button and Details Modal
* @internal
*/
export function formatAccountFiatBalance(
props: AccountBalanceInfo & { decimals: number },
) {
const num = formatNumber(props.balance, props.decimals);
// Need to keep them short to avoid UI overflow issues
const formattedFiatBalance = shortenLargeNumber(num);
return `${props.symbol}${formattedFiatBalance}`;
}
28 changes: 28 additions & 0 deletions packages/thirdweb/src/react/core/utils/walletIcon.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import { getWalletInfo } from "../../../wallets/__generated__/getWalletInfo.js";
import type { AuthOption } from "../../../wallets/types.js";
import type { WalletId } from "../../../wallets/wallet-types.js";
import { useWalletContext } from "../wallet/provider.js";

// TODO make the social icons usable in RN too
const googleIconUri =
Expand Down Expand Up @@ -106,3 +110,27 @@ export function getSocialIcon(provider: AuthOption | ({} & string)): string {
return genericWalletIcon;
}
}

/**
* @internal
*/
export function useWalletIcon(props: {
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
}) {
const { id } = useWalletContext();
const imageQuery = useQuery({
queryKey: ["walletIcon", id],
queryFn: async () => fetchWalletImage({ id }),
...props.queryOptions,
});
return imageQuery;
}

/**
* @internal Exported for tests only
*/
export async function fetchWalletImage(props: {
id: WalletId;
}) {
return getWalletInfo(props.id, true);
}
Loading
Loading