[SDK] Feature: Social Extension (Beta) (#4349)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds support for fetching social profiles for Farcaster, Lens, and ENS.

### Detailed summary
- Added functions to fetch social profiles for different protocols
- Updated components to use social profile data
- Updated types for social profiles and profiles metadata

> The following files were skipped due to too many changes: `packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
gregfromstl
2024-09-06 19:46:13 +00:00
parent 9cefe5f2df
commit a2d22911bc
12 changed files with 268 additions and 52 deletions
+63
View File
@@ -0,0 +1,63 @@
---
"thirdweb": minor
---
Adds social profile retrieval for Farcaster, Lens, and ENS.
```ts
import { getSocialProfiles } from "thirdweb/social";
const profiles = await getSocialProfiles({
address: "0x...",
client,
});
```
```json
[
{
"type": "ens",
"name": "joenrv.eth",
"avatar": "ipfs://bafybeic2wvtpv5hpdyeuy6o77yd5fp2ndfygppd6drdxvtfd2jouijn72m",
"metadata": {
"name": "joenrv.eth"
}
},
{
"type": "farcaster",
"name": "joaquim",
"bio": "Eng Lead @ thirdweb",
"avatar": "https://lh3.googleusercontent.com/EUELPFJzdDNcc3qSaEMekh0_W16acnS8MSvWizt-7HPaQhfJsNFC5HA0W4NKcy6CN9zmV7d4Crqg2B8qM9BpiveqVTl2GPBQ16Ax2IQ",
"metadata": {
"fid": 2735,
"bio": "Eng Lead @ thirdweb",
"pfp": "https://lh3.googleusercontent.com/EUELPFJzdDNcc3qSaEMekh0_W16acnS8MSvWizt-7HPaQhfJsNFC5HA0W4NKcy6CN9zmV7d4Crqg2B8qM9BpiveqVTl2GPBQ16Ax2IQ",
"username": "joaquim",
"addresses": [
"0x2247d5d238d0f9d37184d8332ae0289d1ad9991b",
"0xf7970369310b541b8a84086c8c1c81d3beb85e0e"
]
}
},
{
"type": "lens",
"name": "joaquim",
"bio": "Lead engineer @thirdweb",
"avatar": "https://ik.imagekit.io/lens/media-snapshot/557708cc7581172234133c10d473058ace362c5f547fa86cee5be2abe1478e5b.png",
"metadata": {
"name": "joaquim",
"bio": "Lead engineer @thirdweb",
"picture": "https://ik.imagekit.io/lens/media-snapshot/557708cc7581172234133c10d473058ace362c5f547fa86cee5be2abe1478e5b.png"
}
}
]
```
```tsx
import { useSocialProfiles } from "thirdweb/react";
const { data: profiles } = useSocialProfiles({
client,
address: "0x...",
});
```
+3
View File
@@ -184,3 +184,6 @@ export { useConnectionManager } from "../react/core/providers/connection-manager
export { Blobbie } from "../react/web/ui/ConnectWallet/Blobbie.js";
export { useSiweAuth } from "../react/core/hooks/auth/useSiweAuth.js";
// Social
export { useSocialProfiles } from "../react/core/social/useSocialProfiles.js";
+2
View File
@@ -0,0 +1,2 @@
export { getSocialProfiles } from "../social/profiles.js";
export { type SocialProfiles } from "../social/types.js";
@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import type { ThirdwebClient } from "../../../client/client.js";
import { getSocialProfiles } from "../../../social/profiles.js";
/**
* Fetches the wallet's available social profiles.
* @param options - The options to use when fetching the social profiles.
* @param options.client - The Thirdweb client.
* @param options.address - The wallet address to fetch the social profiles for.
* @returns A React Query result containing the social profiles.
*
* @example
* ```ts
* import { useSocialProfiles } from "thirdweb/react";
* const { data: profiles } = useSocialProfiles({
* client,
* address: "0x...",
* });
* ```
* @beta
*/
export function useSocialProfiles(options: {
client: ThirdwebClient;
address: string | undefined;
}) {
const { client, address } = options;
return useQuery({
queryKey: ["social-profiles", address],
enabled: !!address,
retry: 3,
queryFn: async () => {
if (!address) {
throw new Error(
"Address is required, should not have reached this point.",
);
}
return await getSocialProfiles({ address, client });
},
});
}
@@ -5,11 +5,13 @@ import type { ThirdwebClient } from "../../../client/client.js";
import { resolveAvatar } from "../../../extensions/ens/resolve-avatar.js";
import { resolveName } from "../../../extensions/ens/resolve-name.js";
import { shortenAddress } from "../../../utils/address.js";
import { parseAvatarRecord } from "../../../utils/ens/avatar.js";
import { getWalletInfo } from "../../../wallets/__generated__/getWalletInfo.js";
import type { Account, Wallet } from "../../../wallets/interfaces/wallet.js";
import type { WalletInfo } from "../../../wallets/wallet-info.js";
import type { WalletId } from "../../../wallets/wallet-types.js";
import { useWalletBalance } from "../hooks/others/useWalletBalance.js";
import { useSocialProfiles } from "../social/useSocialProfiles.js";
/**
* Get the ENS name and avatar for an address
@@ -98,6 +100,11 @@ export function useConnectedWalletDetails(
ensName: ensNameQuery.data,
});
const socialProfileQuery = useSocialProfiles({
client,
address: activeAccount?.address,
});
const shortAddress = activeAccount?.address
? shortenAddress(activeAccount.address, 4)
: "";
@@ -110,11 +117,31 @@ export function useConnectedWalletDetails(
});
const addressOrENS = ensNameQuery.data || shortAddress;
const pfpUnresolved = socialProfileQuery.data?.filter((p) => p.avatar)[0]
?.avatar;
const { data: pfp } = useQuery({
queryKey: ["ens-avatar", pfpUnresolved],
queryFn: async () => {
if (!pfpUnresolved) {
return undefined;
}
return parseAvatarRecord({ client, uri: pfpUnresolved });
},
enabled: !!pfpUnresolved,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const name =
socialProfileQuery.data?.filter((p) => p.name)[0]?.name || addressOrENS;
return {
socialProfileQuery,
ensNameQuery,
ensAvatarQuery,
addressOrENS,
pfp,
name,
shortAddress,
balanceQuery,
};
@@ -21,9 +21,9 @@ export const WalletImage = (props: {
theme: Theme;
wallet: Wallet;
size: number;
ensAvatar?: string | null;
avatar?: string | null;
}) => {
const { wallet, ensAvatar, size } = props;
const { wallet, avatar, size } = props;
const { data: imageData } = useQuery({
queryKey: ["wallet-image", wallet.id, wallet.getAccount()?.address],
@@ -56,10 +56,10 @@ export const WalletImage = (props: {
return WALLET_ICON;
},
enabled: !ensAvatar,
enabled: !avatar,
});
const data = ensAvatar || imageData || WALLET_ICON;
const data = avatar || imageData || WALLET_ICON;
return <RNImage theme={props.theme} data={data} size={size} />;
};
@@ -22,13 +22,12 @@ export function ConnectedButton(
const theme = parseTheme(props.theme);
const { account, wallet } = props;
const walletChain = useActiveWalletChain();
const { ensAvatarQuery, addressOrENS, balanceQuery } =
useConnectedWalletDetails(
props.client,
walletChain,
account,
props.detailsButton?.displayBalanceToken,
);
const { pfp, name, balanceQuery } = useConnectedWalletDetails(
props.client,
walletChain,
account,
props.detailsButton?.displayBalanceToken,
);
return (
<ThemedButton
theme={theme}
@@ -41,12 +40,7 @@ export function ConnectedButton(
}}
>
<View style={styles.row}>
<WalletImage
theme={theme}
size={40}
wallet={wallet}
ensAvatar={ensAvatarQuery.data}
/>
<WalletImage theme={theme} size={40} wallet={wallet} avatar={pfp} />
<View style={styles.col}>
<ThemedText
theme={theme}
@@ -55,7 +49,7 @@ export function ConnectedButton(
color: theme.colors.primaryButtonText,
}}
>
{addressOrENS}
{name}
</ThemedText>
{balanceQuery.data ? (
<ThemedText
@@ -163,24 +163,18 @@ export function ConnectedModal(props: ConnectedModalProps) {
const AccountHeader = (props: ConnectedModalProps) => {
const { account, wallet, theme } = props;
const walletChain = useActiveWalletChain();
const { ensAvatarQuery, addressOrENS, balanceQuery } =
useConnectedWalletDetails(
props.client,
walletChain,
account,
props.detailsButton?.displayBalanceToken,
);
const { pfp, name, balanceQuery } = useConnectedWalletDetails(
props.client,
walletChain,
account,
props.detailsButton?.displayBalanceToken,
);
return (
<View style={styles.accountHeaderContainer}>
<WalletImage
theme={theme}
size={70}
wallet={wallet}
ensAvatar={ensAvatarQuery.data}
/>
<WalletImage theme={theme} size={70} wallet={wallet} avatar={pfp} />
<SmartAccountBadge client={props.client} theme={theme} />
<Spacer size="smd" />
<Address account={account} theme={theme} addressOrENS={addressOrENS} />
<Address account={account} theme={theme} addressOrENS={name} />
<Spacer size="xxs" />
{balanceQuery.data ? (
<ThemedText
@@ -131,13 +131,12 @@ export const ConnectedWalletDetails: React.FC<{
const activeAccount = useActiveAccount();
const walletChain = useActiveWalletChain();
const { ensAvatarQuery, addressOrENS, balanceQuery } =
useConnectedWalletDetails(
client,
walletChain,
activeAccount,
props.detailsButton?.displayBalanceToken,
);
const { pfp, name, balanceQuery } = useConnectedWalletDetails(
client,
walletChain,
activeAccount,
props.detailsButton?.displayBalanceToken,
);
function closeModal() {
setRootEl(null);
@@ -185,8 +184,7 @@ export const ConnectedWalletDetails: React.FC<{
);
}
const avatarSrc =
props.detailsButton?.connectedAccountAvatarUrl ?? ensAvatarQuery.data;
const avatarSrc = props.detailsButton?.connectedAccountAvatarUrl || pfp;
return (
<WalletInfoButton
@@ -235,7 +233,7 @@ export const ConnectedWalletDetails: React.FC<{
weight={500}
className={`${TW_CONNECTED_WALLET}__address`}
>
{props.detailsButton?.connectedAccountName ?? addressOrENS}
{props.detailsButton?.connectedAccountName ?? name}
</Text>
{/* Balance */}
@@ -280,13 +278,12 @@ function DetailsModal(props: {
const { client, locale } = props;
const walletChain = useActiveWalletChain();
const activeAccount = useActiveAccount();
const { ensAvatarQuery, addressOrENS, balanceQuery } =
useConnectedWalletDetails(
client,
walletChain,
activeAccount,
props.displayBalanceToken,
);
const { pfp, name, balanceQuery } = useConnectedWalletDetails(
client,
walletChain,
activeAccount,
props.displayBalanceToken,
);
const theme = parseTheme(props.theme);
const activeWallet = useActiveWallet();
@@ -370,8 +367,7 @@ function DetailsModal(props: {
</MenuButton>
);
const avatarSrc =
props.detailsModal?.connectedAccountAvatarUrl ?? ensAvatarQuery.data;
const avatarSrc = props.detailsModal?.connectedAccountAvatarUrl ?? pfp;
const { hideSendFunds, hideReceiveFunds, hideBuyFunds } =
props.detailsModal || {};
@@ -483,7 +479,7 @@ function DetailsModal(props: {
}}
>
<Text color="primaryText" weight={500} size="md">
{props.detailsModal?.connectedAccountName ?? addressOrENS}
{props.detailsModal?.connectedAccountName ?? name}
</Text>
<IconButton>
<CopyIcon
+46
View File
@@ -0,0 +1,46 @@
import type { ThirdwebClient } from "../client/client.js";
import { getThirdwebBaseUrl } from "../utils/domains.js";
import { getClientFetch } from "../utils/fetch.js";
import type { SocialProfiles } from "./types.js";
/**
* Fetches the wallet's available social profiles.
* @param args - The arguments to use when fetching the social profiles.
* @param args.address - The wallet address to fetch the social profiles for.
* @param args.client - The Thirdweb client.
* @returns A promise resolving to the retrieved social profiles for different protocols. If a profile is not available for a protocol, the value will be `null`.
*
* @example
* ```ts
* import { getProfiles } from "thirdweb/social";
* const profiles = await getProfiles({
* address: "0x...",
* client,
* });
* ```
* @beta
*/
export async function getSocialProfiles(args: {
address: string;
client: ThirdwebClient;
}): Promise<SocialProfiles> {
const { address, client } = args;
const clientFetch = getClientFetch(client);
const response = await clientFetch(
`${getThirdwebBaseUrl("social")}/v1/profiles/${address}`,
);
if (response.status !== 200) {
try {
const errorBody = await response.json();
throw new Error(`Failed to fetch profile: ${errorBody.message}`);
} catch {
throw new Error(
`Failed to fetch profile: ${response.status}\n${await response.text()}`,
);
}
}
return (await response.json()).data as SocialProfiles;
}
+43
View File
@@ -0,0 +1,43 @@
export type FarcasterProfile = {
fid?: number;
bio?: string;
pfp?: string;
display?: string;
username?: string;
custodyAddress?: string;
addresses?: string[];
};
export type LensProfile = {
name?: string;
bio?: string;
picture?: string;
coverPicture?: string;
};
export type EnsProfile = {
name?: string;
address?: string;
avatar?: string;
display?: string;
description?: string;
keywords?: string[];
email?: string;
mail?: string;
notice?: string;
location?: string;
phone?: string;
url?: string;
twitter?: string;
github?: string;
discord?: string;
telegram?: string;
};
export type SocialProfiles = {
type: "farcaster" | "lens" | "ens";
name?: string;
avatar?: string;
bio?: string;
metadata?: FarcasterProfile | LensProfile | EnsProfile;
}[];
+8
View File
@@ -4,6 +4,11 @@ type DomainOverrides = {
* @default "rpc.thirdweb.com"
*/
rpc?: string;
/**
* The base URL for the social service.
* @default "social.thirdweb.com"
*/
social?: string;
/**
* The base URL for the in-app wallet service
* @default "embedded-wallet.thirdweb.com"
@@ -28,6 +33,7 @@ type DomainOverrides = {
export const DEFAULT_RPC_URL = "rpc.thirdweb.com";
const DEFAULT_IN_APP_WALLET_URL = "embedded-wallet.thirdweb.com";
const DEFAULT_SOCIAL_URL = "social.thirdweb.com";
const DEFAULT_PAY_URL = "pay.thirdweb.com";
const DEFAULT_STORAGE_URL = "storage.thirdweb.com";
const DEFAULT_BUNDLER_URL = "bundler.thirdweb.com";
@@ -35,6 +41,7 @@ const DEFAULT_BUNDLER_URL = "bundler.thirdweb.com";
let domains: { [k in keyof DomainOverrides]-?: string } = {
rpc: DEFAULT_RPC_URL,
inAppWallet: DEFAULT_IN_APP_WALLET_URL,
social: DEFAULT_SOCIAL_URL,
pay: DEFAULT_PAY_URL,
storage: DEFAULT_STORAGE_URL,
bundler: DEFAULT_BUNDLER_URL,
@@ -47,6 +54,7 @@ export const setThirdwebDomains = (DomainOverrides: DomainOverrides) => {
domains = {
rpc: DomainOverrides.rpc ?? DEFAULT_RPC_URL,
inAppWallet: DomainOverrides.inAppWallet ?? DEFAULT_IN_APP_WALLET_URL,
social: DomainOverrides.social ?? DEFAULT_SOCIAL_URL,
pay: DomainOverrides.pay ?? DEFAULT_PAY_URL,
storage: DomainOverrides.storage ?? DEFAULT_STORAGE_URL,
bundler: DomainOverrides.bundler ?? DEFAULT_BUNDLER_URL,