diff --git a/apps/storybook.namekit.io/stories/Namekit/Identity.stories.tsx b/apps/storybook.namekit.io/stories/Namekit/Identity.stories.tsx new file mode 100644 index 000000000..4d886724e --- /dev/null +++ b/apps/storybook.namekit.io/stories/Namekit/Identity.stories.tsx @@ -0,0 +1,181 @@ +import React, { useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { + Identity, + NameKitProvider, + ProfileLinkGenerator, +} from "@namehash/namekit-react/client"; + +const meta: Meta = { + title: "Namekit/Identity", + component: Identity.Root, + argTypes: { + address: { control: "text" }, + network: { + control: { + type: "select", + options: ["mainnet", "sepolia"], + }, + }, + className: { control: "text" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const TwitterProfileLink = new ProfileLinkGenerator( + "Twitter", + "https://twitter.com/", +); +const GitHubProfileLink = new ProfileLinkGenerator( + "GitHub", + "https://github.com/", +); + +const DefaultIdentityCard: React.FC<{ + address: string; + network?: "mainnet" | "sepolia"; + returnNameGuardReport?: boolean; +}> = ({ address, network, returnNameGuardReport }) => ( + + + + + + +
+ + View on ENS App +
+
+ +
+); + +const CustomAppIdentityCard: React.FC<{ address: string }> = ({ address }) => ( + + + + + + + + + +); + +const ModalIdentityCard: React.FC<{ address: string }> = ({ address }) => { + const dialogRef = useRef(null); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + dialogRef.current?.showModal(); + }; + + return ( + + + + + + <> + Open Profile Modal + + + + + + Hello {address} + + + + ); +}; + +export const Default: Story = { + args: { + address: "0x838aD0EAE54F99F1926dA7C3b6bFbF617389B4D9", + network: "mainnet", + className: "rounded-xl", + }, + render: (args) => , +}; + +export const MultipleCards: Story = { + render: () => ( + <> + + + + + ), +}; + +export const ProfileLinkVariants: Story = { + render: () => { + const address = "0x838aD0EAE54F99F1926dA7C3b6bFbF617389B4D9"; + + return ( +
+
+

Default ENS App Link

+ + + + +
+ + View on ENS App +
+
+
+
+ +
+

Custom App Link

+ + + + + + + +
+ +
+

Modal Link

+ +
+
+ ); + }, +}; + +const ENSLogo = () => ( + + + + + + + + +); diff --git a/packages/namekit-react/package.json b/packages/namekit-react/package.json index 1f5972738..4babffc8d 100644 --- a/packages/namekit-react/package.json +++ b/packages/namekit-react/package.json @@ -43,6 +43,7 @@ "@headlessui/react": "1.7.17", "@namehash/ens-utils": "workspace:*", "@namehash/ens-webfont": "workspace:*", + "@namehash/nameguard": "workspace:*", "classcat": "5.0.5" }, "devDependencies": { diff --git a/packages/namekit-react/src/client.ts b/packages/namekit-react/src/client.ts index 6f68c586d..087449af6 100644 --- a/packages/namekit-react/src/client.ts +++ b/packages/namekit-react/src/client.ts @@ -1,3 +1,5 @@ +"use client"; + import "@namehash/ens-webfont"; import "./styles.css"; @@ -12,3 +14,9 @@ export { CurrencySymbolSize, } from "./components/CurrencySymbol/CurrencySymbol"; export { TruncatedText } from "./components/TruncatedText"; + +export { + Identity, + NameKitProvider, + ProfileLinkGenerator, +} from "./components/Identity"; diff --git a/packages/namekit-react/src/components/Identity.tsx b/packages/namekit-react/src/components/Identity.tsx new file mode 100644 index 000000000..256090bea --- /dev/null +++ b/packages/namekit-react/src/components/Identity.tsx @@ -0,0 +1,370 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from "react"; +import { + createClient, + Network, + type SecurePrimaryNameResult, +} from "@namehash/nameguard"; +import cc from "classcat"; + +export class ProfileLinkGenerator { + private baseURL: string; + private name: string; + + constructor(name: string, baseURL: string) { + this.name = name; + this.baseURL = baseURL; + } + + getName(): string { + return this.name; + } + + getProfileURL(address: string): string { + return `${this.baseURL}${address}`; + } +} + +export const ENSProfileLink = new ProfileLinkGenerator( + "ENS", + "https://app.ens.domains/", +); + +const DEFAULT_PROFILE_LINKS = [ENSProfileLink]; + +interface NameKitConfig { + profileLinks?: ProfileLinkGenerator[]; +} + +const NameKitConfigContext = createContext({}); + +interface NameKitProviderProps { + children: React.ReactNode; + config: NameKitConfig; +} + +export const NameKitProvider: React.FC = ({ + children, + config, +}) => { + return ( + + {children} + + ); +}; + +export const useNameKitConfig = () => useContext(NameKitConfigContext); + +interface IdentityContextType { + network: Network; + address: string; + returnNameGuardReport: boolean; + loadingState: "loading" | "error" | "success"; + error?: string; + identityData?: SecurePrimaryNameResult; + followersCount?: string; +} + +const IdentityContext = createContext(null); + +const useIdentity = () => { + const context = useContext(IdentityContext); + + if (!context) { + throw new Error("useIdentity must be used within an IdentityProvider"); + } + + return context; +}; + +interface SubComponentProps { + className?: string; + children?: ReactNode; +} + +interface RootProps { + address: string; + network?: Network; + className?: string; + children: ReactNode; + returnNameGuardReport?: boolean; +} + +const Root = ({ + address, + network = "mainnet", + className, + children, + returnNameGuardReport = false, + ...props +}: RootProps) => { + const [data, setData] = useState({ + address, + network, + returnNameGuardReport, + loadingState: "loading", + }); + + useEffect(() => { + const fetchData = async () => { + try { + setData((prev) => ({ ...prev, loadingState: "loading" })); + + const nameguard = createClient({ network }); + + const result = await nameguard.getSecurePrimaryName(address, { + returnNameGuardReport, + }); + + setData((prev) => ({ + ...prev, + loadingState: "success", + identityData: result, + })); + } catch (err) { + setData((prev) => ({ + ...prev, + loadingState: "error", + error: + err instanceof Error ? err.message : "An unknown error occurred", + })); + } + }; + + const fetchEthFollowUserStats = async () => { + try { + const response = await fetch( + `https://api.ethfollow.xyz/api/v1/users/${address}/stats`, + ); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const result = await response.json(); + setData((prev) => ({ + ...prev, + followersCount: result.followers_count, + })); + } catch (err) { + console.error("Error fetching followers data:", err); + } + }; + + fetchData(); + fetchEthFollowUserStats(); + }, [address, network, returnNameGuardReport]); + + return ( + +
+ {children} +
+
+ ); +}; + +const Avatar = ({ className, ...props }: SubComponentProps) => { + const { identityData, loadingState, network } = useIdentity(); + + if (loadingState === "loading") { + return ( +
+
+
+ ); + } + + if (loadingState === "error" || !identityData?.display_name) { + return ( +
+
+ ! +
+
+ ); + } + + const avatarUrl = `https://metadata.ens.domains/${network}/avatar/${identityData.display_name}`; + + return ( +
+ {identityData.display_name} { + e.currentTarget.src = "path/to/fallback/image.png"; + }} + /> +
+ ); +}; + +const Name = ({ className, ...props }: SubComponentProps) => { + const { identityData, loadingState, address } = useIdentity(); + + if (loadingState === "loading") { + return ( +
+ ); + } + + const displayName = + identityData?.display_name || + address.slice(0, 6) + "..." + address.slice(-4); + + return ( +
+ {displayName} +
+ ); +}; + +const Address = ({ className, ...props }: SubComponentProps) => { + const { address } = useIdentity(); + + return ( +
+ {address} +
+ ); +}; + +const NameGuardShield = ({ className, ...props }: SubComponentProps) => { + const { identityData, returnNameGuardReport, loadingState } = useIdentity(); + + if ( + !returnNameGuardReport || + loadingState !== "success" || + !identityData?.nameguard_report + ) { + return null; + } + + return ( +
+
+ Rating: {identityData.nameguard_report.rating} +
+
+ Risks: {identityData.nameguard_report.risk_count} +
+
+ ); +}; + +const Followers = ({ className, ...props }: SubComponentProps) => { + const { followersCount, loadingState } = useIdentity(); + + if (loadingState === "loading") { + return ( +
+ Loading followers... +
+ ); + } + + if (followersCount === undefined) { + return ( +
+ Fetching followers... +
+ ); + } + + return ( +
+ {followersCount} followers +
+ ); +}; + +interface ProfileLinkProps { + config?: ProfileLinkGenerator; + className?: string; + children?: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; +} + +const ProfileLink: React.FC = ({ + config, + children, + onClick, +}) => { + const identity = useIdentity(); + const nameKitConfig = useNameKitConfig(); + + const linkConfig = + config || nameKitConfig.profileLinks?.[0] || DEFAULT_PROFILE_LINKS[0]; + + if (!identity) { + console.warn("ProfileLink used outside of Identity context"); + return null; + } + + const url = linkConfig.getProfileURL(identity.address); + + return ( + + {children || linkConfig.getName()} + + ); +}; + +interface ProfileLinksProps { + configs?: ProfileLinkGenerator[]; + className?: string; +} + +const ProfileLinks: React.FC = ({ configs, className }) => { + const identity = useIdentity(); + const { profileLinks: globalConfigs } = useNameKitConfig(); + + const linksToRender = configs || globalConfigs || DEFAULT_PROFILE_LINKS; + + if (!identity) { + console.warn("ProfileLinks used outside of Identity context"); + return null; + } + + return ( +
+ {linksToRender.map((config) => ( + + {config.getName()} + + ))} +
+ ); +}; + +export const Identity = { + Root, + Avatar, + Name, + Address, + NameGuardShield, + ProfileLink, + ProfileLinks, + Followers, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2276af63b..79f4d4d92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -568,6 +568,9 @@ importers: '@namehash/ens-webfont': specifier: workspace:* version: link:../ens-webfont + '@namehash/nameguard': + specifier: workspace:* + version: link:../nameguard-sdk classcat: specifier: 5.0.5 version: 5.0.5 @@ -3548,6 +3551,7 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true esniff@1.1.3: @@ -7591,7 +7595,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -7762,7 +7766,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9023,7 +9027,7 @@ snapshots: '@typescript-eslint/types': 8.8.1 '@typescript-eslint/typescript-estree': 8.8.1(typescript@5.6.2) '@typescript-eslint/visitor-keys': 8.8.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.1 optionalDependencies: typescript: 5.6.2 @@ -9049,7 +9053,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.8.0(typescript@5.6.2) '@typescript-eslint/utils': 8.8.0(eslint@8.57.1)(typescript@5.6.2) - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: typescript: 5.6.2 @@ -9082,7 +9086,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.8.0 '@typescript-eslint/visitor-keys': 8.8.0 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.4 @@ -9097,7 +9101,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.8.1 '@typescript-eslint/visitor-keys': 8.8.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.4 @@ -9917,20 +9921,12 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.4(supports-color@8.1.1): dependencies: ms: 2.1.2 optionalDependencies: supports-color: 8.1.1 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.3.7(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -10332,7 +10328,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1) eslint-plugin-react: 7.34.1(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -10355,8 +10351,8 @@ snapshots: debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.16.1 eslint: 8.57.1 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -10369,10 +10365,10 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.16.1 eslint: 8.57.1 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -10384,7 +10380,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -10395,7 +10391,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-module-utils@2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -10406,7 +10402,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -10416,7 +10412,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -10443,7 +10439,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -10526,7 +10522,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -13282,7 +13278,7 @@ snapshots: vite-node@2.1.2(@types/node@22.7.4): dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) pathe: 1.1.2 vite: 5.1.7(@types/node@22.7.4) transitivePeerDependencies: @@ -13347,7 +13343,7 @@ snapshots: '@vitest/spy': 2.1.2 '@vitest/utils': 2.1.2 chai: 5.1.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) magic-string: 0.30.11 pathe: 1.1.2 std-env: 3.7.0