feat(ark): add user component

This commit is contained in:
2024-01-13 08:21:49 +07:00
parent 0487b8a801
commit 1822eac488
18 changed files with 324 additions and 317 deletions

View File

@@ -0,0 +1,48 @@
import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user?.pubkey, 90, 50),
)}`,
[user],
);
if (!user) {
return (
<div className="shrink-0">
<div
className={cn(
"bg-black/20 dark:bg-white/20 animate-pulse",
className,
)}
/>
</div>
);
}
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user.image}
alt={user.pubkey}
loading="eager"
decoding="async"
className={className}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -0,0 +1,35 @@
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useArk } from "../../hooks/useArk";
export function UserFollowButton({
target,
className,
}: { target: string; className?: string }) {
const ark = useArk();
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
if (!followed) {
const add = await ark.createContact(target);
if (add) setFollowed(true);
} else {
const remove = await ark.deleteContact(target);
if (remove) setFollowed(false);
}
};
useEffect(() => {
async function status() {
const contacts = await ark.getUserContacts();
if (contacts?.includes(target)) setFollowed(true);
}
status();
}, []);
return (
<button type="button" onClick={toggleFollow} className={cn("", className)}>
{followed ? "Unfollow" : "Follow"}
</button>
);
}

View File

@@ -0,0 +1,17 @@
import { UserAvatar } from "./avatar";
import { UserFollowButton } from "./followButton";
import { UserName } from "./name";
import { UserNip05 } from "./nip05";
import { UserProvider } from "./provider";
import { UserRoot } from "./root";
import { UserTime } from "./time";
export const User = {
Provider: UserProvider,
Root: UserRoot,
Avatar: UserAvatar,
Name: UserName,
NIP05: UserNip05,
Time: UserTime,
Button: UserFollowButton,
};

View File

@@ -0,0 +1,23 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserName({ className }: { className?: string }) {
const user = useUserContext();
if (!user) {
return (
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 animate-pulse",
className,
)}
/>
);
}
return (
<div className={cn("w-full max-w-[15rem] truncate", className)}>
{user.displayName || user.name || "Anon"}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { UnverifiedIcon, VerifiedIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useArk } from "../../hooks/useArk";
import { useUserContext } from "./provider";
export function UserNip05({ className }: { className?: string }) {
const ark = useArk();
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
return ark.validateNIP05({
pubkey: user?.pubkey,
nip05: user?.nip05,
signal,
});
},
});
if (!user) {
return (
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 animate-pulse",
className,
)}
/>
);
}
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm font-medium", className)}>
{user.nip05.startsWith("_@")
? user.nip05.replace("_@", "")
: user.nip05}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="size-5 text-teal-500" />
) : (
<UnverifiedIcon className="size-5 text-red-500" />
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { ReactNode, createContext, useContext } from "react";
import { useArk } from "../../hooks/useArk";
const UserContext = createContext<NDKUserProfile>(null);
export function UserProvider({
pubkey,
children,
}: { pubkey: string; children: ReactNode }) {
const ark = useArk();
const { data: user } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
const profile = await ark.getUserProfile(pubkey);
if (!profile)
throw new Error(
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`,
);
return profile;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
retry: 2,
});
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function UserRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn(className)}>{children}</div>;
}

View File

@@ -0,0 +1,11 @@
import { cn, formatCreatedAt } from "@lume/utils";
import { useMemo } from "react";
export function UserTime({
time,
className,
}: { time: number; className?: string }) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
return <div className={cn("", className)}>{createdAt}</div>;
}