Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
63 changes: 63 additions & 0 deletions apps/storybook.namekit.io/stories/Namekit/Identity.stories.tsx
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;
Copy link
Member

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 Address type defined in viem.

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
/>
</>
),
};
1 change: 1 addition & 0 deletions packages/namekit-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@headlessui/react": "1.7.17",
"@namehash/ens-utils": "workspace:*",
"@namehash/ens-webfont": "workspace:*",
"@namehash/nameguard": "workspace:*",
Copy link
Member

Choose a reason for hiding this comment

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

Your suggestion to make this a peer dependency sounds good 👍

Copy link
Member Author

Choose a reason for hiding this comment

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

FYI: This hasn't been changed yet.

"classcat": "5.0.5"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/namekit-react/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import "@namehash/ens-webfont";
import "./styles.css";

Expand All @@ -12,3 +14,5 @@ export {
CurrencySymbolSize,
} from "./components/CurrencySymbol/CurrencySymbol";
export { TruncatedText } from "./components/TruncatedText";

export { Identity } from "./components/Identity";
292 changes: 292 additions & 0 deletions packages/namekit-react/src/components/Identity.tsx
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;
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;
Copy link
Member

Choose a reason for hiding this comment

The 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:

  1. NameGuardReport.
  2. EFP
    1. Stats (https://docs.ethfollow.xyz/api/users/stats/ -- you've already implemented this in a way, just suggesting it is restructured to be optional).
    2. Common Followers (https://docs.ethfollow.xyz/api/users/commonfollowers/)
    3. Is current user following this identity? (https://docs.ethfollow.xyz/api/users/followerstate/)
  3. ENS Profile, for example, social media handles, etc.. To save some time, we should take advantage of existing services for this. Suggest we use this service for now: https://enstate.rs/docs#tag/single-profile/GET/u/{name_or_address}

A few other important suggestions here:

  1. For items 2 and 3 listed under EFP above, we also need to know the address of the current user (as opposed to the address of the identity we want to show data about). Please see my separate comment where I suggest we add wagmi as a dependency. If we do this, we can take advantage of the https://wagmi.sh/react/api/hooks/useAccount hook for this purpose. Then, based on the value returned by the useAccount hook, we could have some intelligent logic inside the EFP related components: If the current user is known, we also work to load the items 2 and 3 listed under EFP above. If we don't know who the current user is, then we don't load items 2 and 3 listed above, since that context only makes sense in relation to knowing who the current user is.
  2. Strongly suggest we don't try to do all this data fusion ourselves between all these different API calls on our frontend. Instead, suggest we move this data fusion API calls inside our own backend for a number of reasons. Let's keep it maximally simple for now and just do the most basic implementation of this API inside the backend of one of our existing open source Next.js apps that are in our monorepo (such as the NameGuard app). We can do a more advanced version of this API in a separate future PR that would create a new app for api.namekit.io where we could move this backend logic in the future).
    1. Therefore, the API call to NameGuard would move to our backend and not happen in the frontend here. Please ask me if any questions. I'm trying to simplify the frontend so that it only needs to make 1 API call per Identity.
    2. One theoretical downside to moving all this work to the backend is increased latency for showing partial results. Ex: If data needs to be loaded via 4 external API calls, and the backend waits for all 4 API responses before returning its own response, the frontend might have to wait longer if 1 of those 4 API responses is meaningfully slower than others. However, I don't suggest we worry about that for now. If we identity this as an issue / opportunity for the future we can pick from a number of techniques where our backend API can stream data to the frontend as it loads.
  3. It seems to me it would be a nice DX to make it so the Identity context / hook would include info on which items were requested to be loaded from the API. In other words, it seems nice if these components could separately identify the case of we never requested to load the ENS Profile vs there being no ENS profile for a given identity. Does that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

The 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 Identity.Followers then it knows to fetch followers (like it does now). The only thing not following this is nameGuardReport, it could be messy but I think we could export another component that is Identity.NameGuardReport and if that's used at all, the initial query returns the report too. If that makes sense.

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, {
Copy link
Member

Choose a reason for hiding this comment

The 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:

  1. wagmi. https://wagmi.sh/ The same team that built wagmi built viem https://viem.sh/. Pretty much everyone is using both libraries in their web3 apps. viem is a more low-level library that's suitable for use on a backend or frontend. wagmi is a frontend library that wraps viem. wagmi uses ReactQuery for all their network requests.
  2. onchainkit. https://onchainkit.xyz/ This is built as a layer on top of wagmi.
  3. rainbowkit. https://www.rainbowkit.com/ This is also built as a layer on top of wagmi.
  4. Etc..

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 () => {
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
Copy link
Member

Choose a reason for hiding this comment

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

Appreciate your advice. Should we use our <Link for this instead of <a?

Copy link
Member Author

Choose a reason for hiding this comment

The 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}`}
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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 implementation

This shows the default link:

<Identity.Root address={address}>
  <Identity.ENSProfileLink />
</Identity.Root>

2. Custom NameKit Config

const 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.

Copy link
Member

@lightwalker-eth lightwalker-eth Oct 29, 2024

Choose a reason for hiding this comment

The 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:

  1. getProfileTarget am I correct to understand this is supposed to return a string representing a URL? If so, suggest renaming this to getProfileURL.
    1. I also see your separate click handler example, but confused how everything fits together here. Because here the getProfileTarget doesn't return a string, it returns a function that takes a React.MouseEvent param.
  2. openInNewTab and LinkComponent I like the general direction you're working towards here for supporting more customizability. But maybe we use a different strategy? What do you think of modifying the approach there to instead have a function called getProfileLink that takes in a url (as might be generated by getProfileURL and then returns a DOM element tree for turning that url into the HTML for a link.
    1. This strategy not only would make customizing link targets / link components no problem, but I believe it should also give a solution for the "click handler" case.
    2. Maybe instead of getProfileLink taking as input a url as a param, it should instead take the same param type as getProfileTarget / getProfileURL. Then, if getProfileLink wants to build a link with a URL in it, it can call getProfileURL itself to do that. Alternatively, if getProfileLink wants to do some javascript-based "click handler" it can then more easily define that logic if the param passed to getProfileLink were an address or an ensName.
  3. For now, maybe we exclusively build support for getting the profiles using address, not using ensName. I'm worried supporting both will slow us down right now. It's definitely nice to ultimately add support for both in the future, but suggest we come back to that later. For example, in the future we might define some special interface for what gets passed into Identity.Root where it is either a name or an address and not both. This same interface could then be passed around to other downstream components such as the callback functions defined in the config, etc.. But suggest we keep things more simple for now and only focus on identities that are anchored in an address and not an ensName.
  4. Suggest that we define some default handlers for the profile link configs. The default should result in opening to view the profile of the provided address on the ens.domains website in a new tab. Ex: https://app.ens.domains/0xf81bc66316A3f2A60Adc258F97F61dFcBdd23Bb1
  5. It seems nice to make it so that Identity.ENSProfileLink just defines a link, not the anchor elements for the link. As I understand we should allow any children to be passed into Identity.ENSProfileLink which would then become the anchor for the link. As I understand, just doing <Identity.ENSProfileLink /> should be similar to doing <a /> in that there shouldn't be anything for a user to see or interact with until you define the children beneath that.
  6. Whatever interface we decide on for setting the config for profile links, it might be nice to allow a custom config for a specific Identity.ENSProfileLink instance to be passed in. By default people wouldn't do that, so then it would fallback to the default config set in NameKitConfigProvider.
  7. Perhaps good to rename Identity.ENSProfileLink to just Identity.ProfileLink. I like making "ENS" implied rather than explicit. For example, use name instead of EnsName, etc.. If we don't do this we'll have "ENS" too much all over our code.

Appreciate your advice 👍

Copy link
Member Author

Choose a reason for hiding this comment

The 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 🙏🏻

Copy link
Member Author

Choose a reason for hiding this comment

The 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 ProfileLinkGenerator.

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 />
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about moving ideas such as:

<ENSLogo /> and the subsequent <span ...> aren't hard coded into
ENSProfileLink but we just put any children that might be defined there instead?

This seems more composable? Ex: It's nice to be able to customize that profile link however you want.

The main purpose of this ENSProfileLink as I see it is to manage the logic for how you generate the link for the profile. But the click target ideally isn't hardcoded.

Appreciate your advice.

Copy link
Member Author

Choose a reason for hiding this comment

The 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,
};
Loading