feat: improve ui
This commit is contained in:
20
packages/ui/src/box.tsx
Normal file
20
packages/ui/src/box.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Box({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex h-full min-h-0 w-full", className)}>
|
||||
<div className="h-full w-full flex-1 px-2 pb-2">
|
||||
<div className="h-full w-full overflow-hidden overflow-y-auto rounded-xl bg-white px-3 pt-3 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
packages/ui/src/container.tsx
Normal file
26
packages/ui/src/container.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
withDrag = false,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
withDrag?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{withDrag ? (
|
||||
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// New
|
||||
export * from "./user";
|
||||
export * from "./note";
|
||||
export * from "./column";
|
||||
export * from "./emptyFeed";
|
||||
|
||||
// UI
|
||||
export * from "./container";
|
||||
export * from "./box";
|
||||
|
||||
@@ -19,7 +19,7 @@ export function NoteReply() {
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="size07 group inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
<ReplyIcon className="size-5 group-hover:text-blue-500" />
|
||||
</button>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function NoteRepost() {
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="size07 group inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
|
||||
@@ -4,7 +4,7 @@ export function NoteZap() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
<ZapIcon className="size-5 group-hover:text-blue-500" />
|
||||
</button>
|
||||
|
||||
@@ -109,11 +109,11 @@ export function NoteChild({
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
||||
<div className="absolute left-3 top-3 inline-flex items-center gap-1.5 font-semibold leading-tight">
|
||||
<User.Name className="max-w-[10rem] truncate" />
|
||||
<div className="font-normal text-neutral-700 dark:text-neutral-300">
|
||||
<div className="absolute left-3 top-3">
|
||||
<User.Name className="inline font-semibold" />{" "}
|
||||
<span className="inline font-normal text-neutral-700 dark:text-neutral-300">
|
||||
{isRoot ? t("note.posted") : t("note.replied")}:
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
|
||||
@@ -8,104 +8,38 @@ import {
|
||||
canPreview,
|
||||
cn,
|
||||
} from "@lume/utils";
|
||||
import getUrls from "get-urls";
|
||||
import { nanoid } from "nanoid";
|
||||
import { NIP89 } from "./nip89";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { stripHtml } from "string-strip-html";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { nanoid } from "nanoid";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { NIP89 } from "./nip89";
|
||||
import { ImagePreview } from "./preview/image";
|
||||
import { LinkPreview } from "./preview/link";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { VideoPreview } from "./preview/video";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { stripHtml } from "string-strip-html";
|
||||
import getUrl from "get-urls";
|
||||
import { ImagePreview } from "./preview/image";
|
||||
|
||||
export function NoteContent({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
|
||||
const richContent = useMemo(() => {
|
||||
if (event.kind !== Kind.Text) return event.content;
|
||||
|
||||
let parsedContent: string | ReactNode[] = stripHtml(event.content).result;
|
||||
let linkPreview: string = undefined;
|
||||
let images: string[] = [];
|
||||
let videos: string[] = [];
|
||||
let audios: string[] = [];
|
||||
let events: string[] = [];
|
||||
|
||||
const text = parsedContent;
|
||||
const content = useMemo(() => {
|
||||
const text = stripHtml(event.content.trim()).result;
|
||||
const words = text.split(/( |\n)/);
|
||||
const urls = [...getUrls(text)];
|
||||
const urls = [...getUrl(text)];
|
||||
|
||||
images = urls.filter((word) =>
|
||||
IMAGES.some((el) => {
|
||||
const url = new URL(word);
|
||||
const extension = url.pathname.split(".")[1];
|
||||
if (extension === el) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
videos = urls.filter((word) =>
|
||||
VIDEOS.some((el) => {
|
||||
const url = new URL(word);
|
||||
const extension = url.pathname.split(".")[1];
|
||||
if (extension === el) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
audios = urls.filter((word) =>
|
||||
AUDIOS.some((el) => {
|
||||
const url = new URL(word);
|
||||
const extension = url.pathname.split(".")[1];
|
||||
if (extension === el) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
// @ts-ignore, kaboom !!!
|
||||
let parsedContent: ReactNode[] = text;
|
||||
|
||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||
const events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
const mentions = words.filter((word) =>
|
||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
|
||||
try {
|
||||
if (images.length) {
|
||||
for (const image of images) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
image,
|
||||
(match, i) => <ImagePreview key={match + i} url={match} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (videos.length) {
|
||||
for (const video of videos) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
video,
|
||||
(match, i) => <VideoPreview key={match + i} url={match} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (audios.length) {
|
||||
for (const audio of audios) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
audio,
|
||||
(match, i) => <VideoPreview key={match + i} url={match} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
@@ -137,41 +71,54 @@ export function NoteContent({ className }: { className?: string }) {
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/(https?:\/\/\S+)/g,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, i) => {
|
||||
const url = new URL(match);
|
||||
try {
|
||||
const url = new URL(match);
|
||||
const ext = url.pathname.split(".")[1];
|
||||
|
||||
if (!linkPreview && canPreview(match)) {
|
||||
linkPreview = match;
|
||||
return <LinkPreview key={match + i} url={url.toString()} />;
|
||||
if (IMAGES.includes(ext)) {
|
||||
return <ImagePreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
|
||||
if (VIDEOS.includes(ext)) {
|
||||
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
|
||||
if (AUDIOS.includes(ext)) {
|
||||
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={match + i}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<a
|
||||
key={match + i}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={match + i}
|
||||
href={url.toString()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="content-break inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url.toString()}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
parsedContent = reactStringReplace(parsedContent, "\n", () => {
|
||||
return <br key={nanoid()} />;
|
||||
});
|
||||
|
||||
if (typeof parsedContent[0] === "string") {
|
||||
parsedContent[0] = parsedContent[0].trimStart();
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
} catch (e) {
|
||||
console.warn(event.id, `[parser] parse failed: ${e}`);
|
||||
return parsedContent;
|
||||
return text;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -180,9 +127,9 @@ export function NoteContent({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="content-break select-text whitespace-pre-line text-balance leading-normal">
|
||||
{richContent}
|
||||
<div className={cn("select-text", className)}>
|
||||
<div className="content-break whitespace-pre-line text-balance leading-normal">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { User } from "../../user";
|
||||
import { Hashtag } from "./hashtag";
|
||||
import { MentionUser } from "./user";
|
||||
import { useArk, useEvent } from "@lume/ark";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { stripHtml } from "string-strip-html";
|
||||
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
@@ -18,14 +20,15 @@ export function MentionNote({
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
const ark = useArk();
|
||||
const richContent = useMemo(() => {
|
||||
const content = useMemo(() => {
|
||||
if (!data) return "";
|
||||
|
||||
let parsedContent: string | ReactNode[] = data.content;
|
||||
|
||||
const text = parsedContent as string;
|
||||
const text = stripHtml(data.content.trim()).result;
|
||||
const words = text.split(/( |\n)/);
|
||||
|
||||
// @ts-ignore, kaboom !!!
|
||||
let parsedContent: ReactNode[] = text;
|
||||
|
||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||
const mentions = words.filter((word) =>
|
||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||
@@ -75,8 +78,7 @@ export function MentionNote({
|
||||
|
||||
return parsedContent;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return parsedContent;
|
||||
return text;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -118,16 +120,17 @@ export function MentionNote({
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="line-clamp-4 select-text whitespace-normal text-balance leading-normal">
|
||||
{richContent}
|
||||
{content}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_thread(data.id)}
|
||||
className="text-blue-500 hover:text-blue-600"
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
{t("note.showMore")}
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useArk, useProfile } from "@lume/ark";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { isLoading, isError, user } = useProfile(pubkey);
|
||||
const { t } = useTranslation();
|
||||
const ark = useArk();
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="break-words text-start text-blue-500 hover:text-blue-600">
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? pubkey
|
||||
: `@${user?.name || user?.display_name || user?.name || "anon"}`}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
|
||||
<DropdownMenu.Item asChild>
|
||||
<a
|
||||
href={`/users/${pubkey}`}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.buttons.viewProfile")}
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.buttons.pin")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_profile(pubkey)}
|
||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${profile?.name || profile?.display_name || profile?.name || "anon"}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function NoteMenu() {
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
@@ -35,10 +35,7 @@ export function ImagePreview({ url }: { url: string }) {
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
onClick={open}
|
||||
className="group relative my-1 rounded-2xl border border-black/10 dark:border-white/10"
|
||||
>
|
||||
<div onClick={open} className="group relative my-1 rounded-2xl">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
@@ -51,12 +48,12 @@ export function ImagePreview({ url }: { url: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => downloadImage(e)}
|
||||
className="absolute right-2 top-2 z-10 hidden size-10 items-center justify-center rounded-lg bg-white/10 text-black/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
||||
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
||||
>
|
||||
{downloaded ? (
|
||||
<CheckCircleIcon className="size-5" />
|
||||
<CheckCircleIcon className="size-4" />
|
||||
) : (
|
||||
<DownloadIcon className="size-5" />
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-1 w-full overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||
<div className="my-1">
|
||||
<MediaController>
|
||||
<video
|
||||
slot="media"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Note } from ".";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
|
||||
export function NoteThread({ className }: { className?: string }) {
|
||||
const ark = useArk();
|
||||
@@ -25,15 +26,16 @@ export function NoteThread({ className }: { className?: string }) {
|
||||
{thread.replyEventId ? (
|
||||
<Note.Child eventId={thread.replyEventId} />
|
||||
) : null}
|
||||
<div className="inline-flex items-center justify-between">
|
||||
<div className="inline-flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ark.open_thread(thread.rootEventId || thread.rootEventId)
|
||||
ark.open_thread(thread.rootEventId || thread.replyEventId)
|
||||
}
|
||||
className="self-start text-blue-500 hover:text-blue-600"
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
{t("note.showThread")}
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,42 +14,44 @@ export function NoteUser({ className }: { className?: string }) {
|
||||
<User.Root
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex w-full gap-3">
|
||||
<HoverCard.Trigger>
|
||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</HoverCard.Trigger>
|
||||
<div>
|
||||
<User.Name className="font-semibold leading-tight text-neutral-950 dark:text-neutral-50" />
|
||||
<div className="flex-1">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<User.Name className="font-semibold leading-tight text-neutral-950 dark:text-neutral-50" />
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="leading-tight text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<User.NIP05 className="leading-tight text-neutral-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</User.Root>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="data-[side=bottom]:animate-slideUpAndFade w-[300px] rounded-xl bg-white p-3 shadow-lg shadow-neutral-500/20 data-[state=open]:transition-all dark:border dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-none"
|
||||
className="data-[side=bottom]:animate-slideUpAndFade w-[300px] rounded-xl bg-black p-3 data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="size-11 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
<User.Avatar className="size-11 rounded-lg object-cover" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<User.Name className="font-semibold leading-tight" />
|
||||
<User.NIP05 className="text-neutral-600 dark:text-neutral-400" />
|
||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||
<User.NIP05 className="leading-tight text-neutral-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
<User.About className="line-clamp-3" />
|
||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||
<button
|
||||
onClick={() => ark.open_profile(event.pubkey)}
|
||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-white dark:fill-neutral-800" />
|
||||
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { cn, displayNpub } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserName({ className }: { className?: string }) {
|
||||
export function UserName({
|
||||
className,
|
||||
suffix,
|
||||
}: {
|
||||
className?: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
@@ -9,6 +15,7 @@ export function UserName({ className }: { className?: string }) {
|
||||
{user.profile?.display_name ||
|
||||
user.profile?.name ||
|
||||
displayNpub(user.pubkey, 16)}
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,9 @@ export function UserTime({
|
||||
}) {
|
||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||
|
||||
return <div className={cn("leading-tight", className)}>{createdAt}</div>;
|
||||
return (
|
||||
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||
{createdAt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user