Lens Account Hover Card

PreviousNext

A hover card that displays Lens account details when hovering over an account name or handle.

Docs
lens-blocksblock

Preview

Loading preview…
registry/new-york/blocks/account/lens-account-hover-card.tsx
"use client";

import { Avatar, AvatarFallback, AvatarImage } from "@/registry/new-york/ui/avatar";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/registry/new-york/ui/hover-card";
import { ReactNode, useEffect, useState } from "react";
import { Account, AccountStats, mainnet, PublicClient, SessionClient, testnet, TxHash } from "@lens-protocol/react";
import { fetchAccount, fetchAccountStats } from "@lens-protocol/client/actions";
import { formatFollowerCount, parseUri } from "@/registry/new-york/lib/lens-utils";
import { CircleUserRoundIcon } from "lucide-react";
import { WalletClient } from "viem";
import { LensMarkdown } from "@/registry/new-york/components/common/lens-markdown";
import { LensFollowButton } from "@/registry/new-york/components/account/lens-follow-button";
import { Skeleton } from "@/registry/new-york/ui/skeleton";
import { isResult, Result } from "@/registry/new-york/lib/result";

interface Props {
  /**
   * The component that will trigger the hover card when hovered over.
   *
   */
  children?: ReactNode;

  /**
   * The Lens Account to display information about
   */
  account: Account | Result<Account>;

  /**
   * The Lens Client used for making public and authenticated calls
   */
  session: Result<SessionClient>;

  /**
   * The wallet client from viem used to sign messages for authentication.
   * If not provided, follow/unfollow button will not be rendered.
   */
  wallet?: { data: WalletClient | undefined | null; isLoading?: boolean; error?: unknown };

  publicClient?: PublicClient;

  /**
   * Callback function that is called when the follow operation is successful
   */
  onFollowSuccess?: (account: Account, txHash: TxHash) => void;

  /**
   * Callback function that is called when the unfollow operation is successful
   */
  onUnfollowSuccess?: (account: Account, txHash: TxHash) => void;

  /**
   * Callback function that is called when an error occurs during follow/unfollow operations
   */
  onError?: (account: Account, error: Error) => void;

  useTestnet?: boolean;
}

export const LensAccountHoverCard = ({
  children,
  account: accountRes,
  session,
  wallet,
  publicClient,
  onFollowSuccess,
  onUnfollowSuccess,
  onError,
  useTestnet,
}: Props) => {
  const sessionClient = session.data;

  const [isOpen, setIsOpen] = useState(false);
  const [accountStats, setAccountStats] = useState<AccountStats>();
  const [showFollowButton, setShowFollowButton] = useState(false);
  const [account, setAccount] = useState<Account | null | undefined>(
    isResult(accountRes) ? accountRes.data : accountRes,
  );

  const isLoading = "loading" in accountRes ? accountRes.loading : false;

  const lensClient =
    publicClient ??
    PublicClient.create({
      environment: useTestnet ? testnet : mainnet,
    });

  useEffect(() => {
    setAccount("data" in accountRes ? accountRes.data : accountRes);
  }, [accountRes]);

  useEffect(() => {
    const fetchStats = async () => {
      console.log("fetching stats for", account?.address);
      if (!isOpen || !account) return;
      const res = await fetchAccountStats(lensClient, {
        account: account.address,
      });
      console.log("fetched stats", res);
      if (res.isOk() && res.value) {
        setAccountStats(res.value);
      }
    };
    fetchStats();
  }, [account, isOpen]);

  useEffect(() => {
    if (!isOpen || !sessionClient) return;

    const user = sessionClient.getAuthenticatedUser();
    if (user.isOk()) {
      setShowFollowButton(
        account?.operations?.canFollow.__typename === "AccountFollowOperationValidationPassed" ||
          account?.operations?.canUnfollow.__typename === "AccountFollowOperationValidationPassed",
      );
    }
  }, [sessionClient, account?.operations]);

  const updateAccount = async () => {
    if (!account) return;
    const client = sessionClient ?? lensClient;
    const res = await fetchAccount(client, {
      address: account.address,
    });
    if (res.isOk() && res.value) {
      setAccount(res.value);
    }
  };

  const handleFollowSuccess = async (txHash: TxHash) => {
    await updateAccount();
    if (account) {
      onFollowSuccess?.(account, txHash);
    }
  };

  const handleUnfollowSuccess = async (txHash: TxHash) => {
    await updateAccount();
    if (account) {
      onUnfollowSuccess?.(account, txHash);
    }
  };

  return (
    <HoverCard open={isOpen} onOpenChange={setIsOpen}>
      <HoverCardTrigger>{children}</HoverCardTrigger>
      <HoverCardContent className="w-80">
        {!account || isLoading ? (
          <HoverCardSkeleton />
        ) : (
          <div className="flex flex-col w-full min-w-0">
            <div className="flex justify-between">
              <Avatar className="w-10 h-10">
                <AvatarImage src={parseUri(account.metadata?.picture)} alt={"@" + account.username?.localName} />
                <AvatarFallback className="bg-primary">
                  <CircleUserRoundIcon className="text-primary-foreground" />
                </AvatarFallback>
              </Avatar>
              {showFollowButton && (
                <LensFollowButton
                  account={accountRes}
                  session={session}
                  wallet={wallet}
                  onFollowSuccess={(_account, txHash) => handleFollowSuccess(txHash)}
                  onUnfollowSuccess={(_account, txHash) => handleUnfollowSuccess(txHash)}
                  onFollowError={onError}
                />
              )}
            </div>
            <div className="flex flex-col pt-2 w-full min-w-0">
              <div className="font-bold text-lg truncate">
                {account.metadata?.name ??
                  (account.username ? "@" + account.username.value.replace("lens/", "") : undefined) ??
                  account.address}
              </div>
              <div className="flex flex-wrap gap-2 items-center -mt-0.5">
                {account.metadata?.name && account.username && (
                  <span className="opacity-60 truncate">{`@${account.username.value.replace("lens/", "")}`}</span>
                )}
                {account.operations?.isFollowingMe && (
                  <span className="h-5 bg-muted text-muted-foreground rounded-full px-2 text-xs font-semibold flex items-center justify-center">
                    Follows you
                  </span>
                )}
              </div>
              {account.metadata?.bio && (
                <LensMarkdown content={account.metadata.bio} className="opacity-70 pt-3 pb-1 line-clamp-5 text-sm" />
              )}
              {accountStats && (
                <div className="flex gap-3 pt-2 text-sm">
                  <div className="flex gap-1">
                    <span className="font-bold">{formatFollowerCount(accountStats.graphFollowStats.following)}</span>
                    <span className="opacity-60">following</span>
                  </div>
                  <div className="flex gap-1">
                    <span className="font-bold">{formatFollowerCount(accountStats.graphFollowStats.followers)}</span>
                    <span className="opacity-60">followers</span>
                  </div>
                </div>
              )}
            </div>
          </div>
        )}
      </HoverCardContent>
    </HoverCard>
  );
};

const HoverCardSkeleton = () => {
  return (
    <div className="w-full flex flex-col w-full min-w-0 gap-1">
      <div className="w-full flex justify-between">
        <Skeleton className="w-10 h-10 rounded-full" />
        <Skeleton className="w-28 h-8 rounded-full" />
      </div>
      <div className="w-full flex flex-col pt-2 w-full min-w-0 gap-2">
        <Skeleton className="h-5 w-32 rounded-full" />
        <Skeleton className="h-4 w-20 rounded-full" />
      </div>
      <div className="flex flex-col gap-1.5 mt-3">
        <Skeleton className="w-11/12 h-4 rounded-full" />
        <Skeleton className="w-full h-4 rounded-full" />
        <Skeleton className="w-2/3 h-4 rounded-full" />
      </div>
      <div className="w-full flex gap-2 text-sm mt-3">
        <div className="w-full flex gap-1">
          <Skeleton className="h-4 w-8 rounded-full" />
          <Skeleton className="h-4 w-16 rounded-full" />
        </div>
        <div className="w-full flex gap-1">
          <Skeleton className="h-4 w-8 rounded-full" />
          <Skeleton className="h-4 w-16 rounded-full" />
        </div>
      </div>
    </div>
  );
};

Installation

npx shadcn@latest add @lens-blocks/account-hover-card

Usage

import { AccountHoverCard } from "@/components/account-hover-card"
<AccountHoverCard />