-
Notifications
You must be signed in to change notification settings - Fork 2
feat(namekit-react): Identity #439
base: main
Are you sure you want to change the base?
Changes from 5 commits
db46f78
450998f
1d92fb1
105a487
ef9dbc5
3ddd190
ec2cb9c
752ac3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import React from "react"; | ||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
| import { Identity } from "@namehash/namekit-react/client"; | ||
|
|
||
| const meta: Meta<typeof Identity.Root> = { | ||
| 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<typeof Identity.Root>; | ||
|
|
||
| const IdentityCard: React.FC<{ | ||
| address: string; | ||
| network?: "mainnet" | "sepolia"; | ||
| returnNameGuardReport?: boolean; | ||
| }> = ({ address, network, returnNameGuardReport }) => ( | ||
| <Identity.Root | ||
| address={address} | ||
| network={network} | ||
| returnNameGuardReport={returnNameGuardReport} | ||
| > | ||
| <Identity.Avatar /> | ||
| <Identity.Name /> | ||
| <Identity.Address /> | ||
| <Identity.NameGuardShield /> | ||
| <Identity.ENSProfileLink /> | ||
| <Identity.Followers /> | ||
| </Identity.Root> | ||
| ); | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| address: "0x838aD0EAE54F99F1926dA7C3b6bFbF617389B4D9", | ||
| network: "mainnet", | ||
| className: "rounded-xl", | ||
| }, | ||
| render: (args) => <IdentityCard {...args} />, | ||
| }; | ||
|
|
||
| export const MultipleCards: Story = { | ||
| render: () => ( | ||
| <> | ||
| <IdentityCard address="0x838aD0EAE54F99F1926dA7C3b6bFbF617389B4D9" /> | ||
| <IdentityCard address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" /> | ||
| <IdentityCard | ||
| address="0xf81bc66316a3f2a60adc258f97f61dfcbdd23bb1" | ||
| returnNameGuardReport | ||
| /> | ||
| </> | ||
| ), | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ | |
| "@headlessui/react": "1.7.17", | ||
| "@namehash/ens-utils": "workspace:*", | ||
| "@namehash/ens-webfont": "workspace:*", | ||
| "@namehash/nameguard": "workspace:*", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your suggestion to make this a peer dependency sounds good 👍
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI: This hasn't been changed yet. |
||
| "classcat": "5.0.5" | ||
| }, | ||
| "devDependencies": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,292 @@ | ||
| import React, { | ||
| createContext, | ||
| useContext, | ||
| useState, | ||
| useEffect, | ||
| ReactNode, | ||
| } from "react"; | ||
| import { | ||
| createClient, | ||
| Network, | ||
| type SecurePrimaryNameResult, | ||
| } from "@namehash/nameguard"; | ||
|
|
||
| interface IdentityContextType { | ||
| network: string; | ||
|
notrab marked this conversation as resolved.
Outdated
|
||
| address: string; | ||
| returnNameGuardReport: boolean; | ||
| loadingState: "loading" | "error" | "success"; | ||
| error?: string; | ||
| identityData?: SecurePrimaryNameResult; | ||
| followersCount?: string; | ||
| } | ||
|
|
||
| const IdentityContext = createContext<IdentityContextType | null>(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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @notrab Hey, suggest extending this idea. When someone defines an Identity Root, suggest that they also identify what lookups they want to make for that identity. For example, suggest we support optional lookups of each of the following:
A few other important suggestions here:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lightwalker-eth this is fantastic feedback, and the ideas are awesome. I do prefer a DX whereby there's less configuration. So if you use the However, to be more explicit with the "options" available as you suggested, we could do something like this: interface IdentityLookupOptions {
nameGuardReport?: boolean;
efpStats?: boolean;
efpCommonFollowers?: boolean;
efpFollowState?: boolean;
ensProfile?: boolean;
}Which means we can do something like this: <Identity.Root
address="0x..."
network="mainnet"
lookupOptions={{
nameGuardReport: true,
efpStats: true,
ensProfile: true,
}}
>
<Identity.Avatar />
<Identity.Name />
<Identity.Address />
<Identity.NameGuardShield />
<Identity.ProfileLink>View Profile</Identity.ProfileLink>
<Identity.Followers />
</Identity.Root> |
||
| } | ||
|
|
||
| const Root = ({ | ||
| address, | ||
| network = "mainnet", | ||
| className, | ||
| children, | ||
| returnNameGuardReport = false, | ||
| ...props | ||
| }: RootProps) => { | ||
| const [data, setData] = useState<IdentityContextType>({ | ||
| 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, { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggest that we add ReactQuery as a dependency / peer dependency for NameKit React. The web3 community has generally adopted ReactQuery as an unofficial standard. There's a ton of various libraries and packages already using it or built on it. For example, strongly suggest having a look at the following:
In fact, suggest that we not only add ReactQuery as a dependency, but we also add wagmi as a dependency. We shouldn't try to reinvent the whole universe. Pretty much everyone who might use NameKit React is also going to be using wagmi, so we better make it convenient for everything to work together. |
||
| 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 fetchFollowersData = async () => { | ||
|
notrab marked this conversation as resolved.
Outdated
|
||
| 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(); | ||
| fetchFollowersData(); | ||
| }, [address, network, returnNameGuardReport]); | ||
|
|
||
| return ( | ||
| <IdentityContext.Provider value={data}> | ||
| <div className={`namekit-identity ${className}`} {...props}> | ||
| {children} | ||
| </div> | ||
| </IdentityContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| const Avatar = ({ className, ...props }: SubComponentProps) => { | ||
| const { identityData, loadingState, network } = useIdentity(); | ||
|
|
||
| if (loadingState === "loading") { | ||
| return ( | ||
| <div | ||
| className={`namekit-identity namekit-avatar-skeleton ${className}`} | ||
| {...props} | ||
| > | ||
| <div className="nk-w-10 nk-h-10 nk-bg-gray-200 nk-rounded-full nk-animate-pulse"></div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (loadingState === "error" || !identityData?.display_name) { | ||
| return ( | ||
| <div | ||
| className={`namekit-identity namekit-avatar-error ${className}`} | ||
| {...props} | ||
| > | ||
| <div className="nk-w-10 nk-h-10 nk-bg-red-200 nk-rounded-full nk-flex nk-items-center nk-justify-center"> | ||
| ! | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const avatarUrl = `https://metadata.ens.domains/${network}/avatar/${identityData.display_name}`; | ||
|
|
||
| return ( | ||
| <div className={`namekit-identity namekit-avatar ${className}`} {...props}> | ||
| <img | ||
| src={avatarUrl} | ||
| alt={identityData.display_name} | ||
| className="nk-w-10 nk-h-10 nk-rounded-full nk-object-cover" | ||
| onError={(e) => { | ||
| e.currentTarget.src = "path/to/fallback/image.png"; | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const Name = ({ className, ...props }: SubComponentProps) => { | ||
| const { identityData, loadingState, address } = useIdentity(); | ||
|
|
||
| if (loadingState === "loading") { | ||
| return ( | ||
| <div className={`namekit-name-skeleton ${className}`} {...props}></div> | ||
| ); | ||
| } | ||
|
|
||
| const displayName = | ||
| identityData?.display_name || | ||
| address.slice(0, 6) + "..." + address.slice(-4); | ||
|
|
||
| return ( | ||
| <div className={`namekit-identity namekit-name ${className}`} {...props}> | ||
| {displayName} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const Address = ({ className, ...props }: SubComponentProps) => { | ||
| const { address } = useIdentity(); | ||
|
|
||
| return ( | ||
| <div className={`namekit-identity namekit-address ${className}`} {...props}> | ||
| {address} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const NameGuardShield = ({ className, ...props }: SubComponentProps) => { | ||
| const { identityData, returnNameGuardReport, loadingState } = useIdentity(); | ||
|
|
||
| if ( | ||
| !returnNameGuardReport || | ||
| loadingState !== "success" || | ||
| !identityData?.nameguard_report | ||
| ) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <div className={`namekit-nameguard-shield ${className}`} {...props}> | ||
| <div className="namekit-nameguard-rating"> | ||
| Rating: {identityData.nameguard_report.rating} | ||
| </div> | ||
| <div className="namekit-nameguard-risk-count"> | ||
| Risks: {identityData.nameguard_report.risk_count} | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const Followers = ({ className, ...props }: SubComponentProps) => { | ||
| const { followersCount, loadingState } = useIdentity(); | ||
|
|
||
| if (loadingState === "loading") { | ||
| return ( | ||
| <div className={`namekit-followers-skeleton ${className}`} {...props}> | ||
| Loading followers... | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (followersCount === undefined) { | ||
| return ( | ||
| <div className={`namekit-followers-loading ${className}`} {...props}> | ||
| Fetching followers... | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={`namekit-identity namekit-followers ${className}`} | ||
| {...props} | ||
| > | ||
| {followersCount} followers | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const ENSLogo = () => ( | ||
| <svg | ||
| fill="none" | ||
| height="16" | ||
| viewBox="0 0 202 231" | ||
| width="14" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <g fill="#0080bc"> | ||
| <path d="m98.3592 2.80337-63.5239 104.52363c-.4982.82-1.6556.911-2.2736.178-5.5924-6.641-26.42692-34.89-.6463-60.6377 23.5249-23.4947 53.4891-40.24601 64.5942-46.035595 1.2599-.656858 2.587.758365 1.8496 1.971665z" /> | ||
| <path d="m94.8459 230.385c1.2678.888 2.8299-.626 1.9802-1.918-14.1887-21.581-61.3548-93.386-67.8702-104.165-6.4264-10.632-19.06614-28.301-20.12056-43.4178-.10524-1.5091-2.19202-1.8155-2.71696-.3963-.8466 2.2888-1.74793 5.0206-2.58796 8.1413-10.60469 39.3938 4.79656 81.1968 38.24488 104.6088l53.0706 37.148z" /> | ||
| <path d="m103.571 228.526 63.524-104.523c.498-.82 1.656-.911 2.274-.178 5.592 6.64 26.427 34.89.646 60.638-23.525 23.494-53.489 40.246-64.594 46.035-1.26.657-2.587-.758-1.85-1.972z" /> | ||
| <path d="m107.154.930762c-1.268-.8873666-2.83.625938-1.98 1.918258 14.189 21.58108 61.355 93.38638 67.87 104.16498 6.427 10.632 19.066 28.301 20.121 43.418.105 1.509 2.192 1.815 2.717.396.846-2.289 1.748-5.02 2.588-8.141 10.604-39.394-4.797-81.1965-38.245-104.609z" /> | ||
| </g> | ||
| </svg> | ||
| ); | ||
|
|
||
| const ENSProfileLink = ({ className, ...props }: SubComponentProps) => { | ||
| const { identityData, loadingState } = useIdentity(); | ||
|
|
||
| if (loadingState !== "success" || !identityData?.display_name) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <a | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Appreciate your advice. Should we use our
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In theory, yes. But I want to make more progress on the core implementation before moving to UI. |
||
| href={`https://app.ens.domains/${identityData.display_name}`} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need some strategy for people who use NameKit React to customize the link target for viewing a detailed profile. This needs to support a variety of cases, including calling a function to a traditional href, customizing opening in a new tab or not, etc.. Additionally, the way this link is built may vary. For example, in some apps the link to a profile is based on an address. In other apps, the link is based on an ENS Name (where possible) and only uses a link with an address as a fallback if the address has no primary name. For example, consider an app such as EFP. When someone clicks on a profile link on their app, they don't want it to open to the official ENS App. They want it to open to the profile page on their own app. I worked on some ideas for how a goal like this in the following PR. Open to other suggested strategies for achieving this dynamic logic: Check the logic around the file packages/nameguard-react/src/utils/openreport.ts it might give some inspiration? https://github.com/namehash/namekit/pull/282/files#diff-4b7ca1b4f908b4cf07782ef287606daa30f6c17f8181e6d7a3ab82eb09137dbd
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lightwalker-eth this is what I have in mind that I would like to experiment this week... 1. Standard implementationThis shows the default link: <Identity.Root address={address}>
<Identity.ENSProfileLink />
</Identity.Root>2. Custom NameKit Configconst efpConfig = {
profileLinks: {
getProfileTarget: ({ address, ensName }) =>
`/profile/${ensName || address}`,
openInNewTab: false,
// Optionally use Next.js Link or other routing component
LinkComponent: NextLink,
}
};
<NameKitConfigProvider config={efpConfig}>
<Identity.Root address={address}>
<Identity.ENSProfileLink />
</Identity.Root>
</NameKitConfigProvider>Optionally, a click handler similar to what you suggested: const handlerConfig = {
profileLinks: {
getProfileTarget: ({ address, ensName }) =>
(e: React.MouseEvent) => {
e.preventDefault();
// Custom handling logic
openProfileModal(address, ensName);
}
}
};This allows us to move towards the "React Query" context provider for data too.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @notrab Hey, thanks for putting this together and sharing it 🙌 🚀 A few suggestions:
Appreciate your advice 👍
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lightwalker-eth I've applied most of this feedback in a new commit. I agree with it all. I also updated the stories to match the new requirements too. I hope I didn't miss anything, it took some refactoring/rethinking to make it all work better 🙏🏻
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lightwalker-eth I made some changes based on what you said about the idea of a class for the 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}`;
}
}This is basic for now, but acts as a way to set a base URL, and we could even export some default ones, such as: export const ENSProfileLink = new ProfileLinkGenerator(
"ENS",
"https://app.ens.domains/",
);Then this can be used wherever: interface ProfileLinkProps {
config?: ProfileLinkGenerator;
className?: string;
children?: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
}
const ProfileLink: React.FC<ProfileLinkProps> = ({ 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 (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="namekit-profile-link"
onClick={onClick}
>
{children || linkConfig.getName()}
</a>
);
};This also allows ProfileLinks to be controlled individually, or part of the context provider down. Giving more flexibility. |
||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className={`namekit-ens-profile-link ${className}`} | ||
| {...props} | ||
| > | ||
| <ENSLogo /> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about moving ideas such as:
This seems more composable? Ex: It's nice to be able to customize that profile link however you want. The main purpose of this Appreciate your advice.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed |
||
| <span className="nk-ml-1">ENS Profile</span> | ||
| </a> | ||
| ); | ||
| }; | ||
|
|
||
| export const Identity = { | ||
| Root, | ||
| Avatar, | ||
| Name, | ||
| Address, | ||
| NameGuardShield, | ||
| ENSProfileLink, | ||
| Followers, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please check all places in the code where you have an address param. These should all use the
Addresstype defined in viem.