fix: context issue in production (#187)
This commit is contained in:
@@ -1,8 +1,4 @@
|
||||
export * from "./user";
|
||||
export * from "./note";
|
||||
export * from "./note/mentions/note";
|
||||
|
||||
// UI
|
||||
export * from "./container";
|
||||
export * from "./box";
|
||||
export * from "./spinner";
|
||||
export * from "./carousel";
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { User } from "../user";
|
||||
|
||||
export function NoteActivity({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
const mentions = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
return (
|
||||
<div className={cn("-mt-3 mb-2", className)}>
|
||||
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
|
||||
<div className="shrink-0 text-sm font-medium">To:</div>
|
||||
{mentions.splice(0, 4).map((mention) => (
|
||||
<User.Provider key={mention} pubkey={mention}>
|
||||
<User.Root>
|
||||
<User.Name className="text-sm font-medium" prefix="@" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
{mentions.length > 4 ? "..." : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { VisitIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteOpenThread() {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_event(event)}
|
||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<VisitIcon className="shrink-0 size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
Open
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ReplyIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor(event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ReplyIcon className="shrink-0 size-4" />
|
||||
{large ? "Reply" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Reply
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { QuoteIcon, RepostIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "../../spinner";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const repost = async () => {
|
||||
try {
|
||||
if (isRepost) return;
|
||||
setLoading(true);
|
||||
|
||||
// repost
|
||||
await ark.repost(event.id, event.pubkey);
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
setIsRepost(true);
|
||||
|
||||
// notify
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error("Repost failed, try again later");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||
large
|
||||
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon
|
||||
className={cn("size-4", isRepost ? "text-blue-500" : "")}
|
||||
/>
|
||||
)}
|
||||
{large ? "Repost" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenu.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{t("note.buttons.repost")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => repost()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<RepostIcon className="size-4" />
|
||||
{t("note.buttons.repost")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor(event.id, true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<QuoteIcon className="size-4" />
|
||||
{t("note.buttons.quote")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark, settings } = useRouteContext({ strict: false });
|
||||
const { account } = useSearch({ strict: false });
|
||||
|
||||
const zap = async () => {
|
||||
try {
|
||||
const nwc = await ark.load_nwc();
|
||||
if (!nwc) {
|
||||
ark.open_nwc();
|
||||
} else {
|
||||
ark.open_zap(event.id, event.pubkey, account);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings.zap) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zap()}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ZapIcon className="size-4" />
|
||||
{large ? "Zap" : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { Images } from "./preview/images";
|
||||
import { Videos } from "./preview/videos";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export function NoteContent({
|
||||
quote = true,
|
||||
mention = true,
|
||||
clean,
|
||||
className,
|
||||
}: {
|
||||
quote?: boolean;
|
||||
mention?: boolean;
|
||||
clean?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const event = useNoteContext();
|
||||
const data = useMemo(() => {
|
||||
const { content, images, videos } = parser(event.content);
|
||||
const words = content.split(/( |\n)/);
|
||||
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)),
|
||||
);
|
||||
|
||||
let richContent: ReactNode[] | string = content;
|
||||
|
||||
try {
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
if (quote) {
|
||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||
<MentionNote key={event + index} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!quote && clean) {
|
||||
richContent = reactStringReplace(richContent, event, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
for (const user of mentions) {
|
||||
if (mention) {
|
||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||
<MentionUser key={user + index} pubkey={user} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!mention && clean) {
|
||||
richContent = reactStringReplace(richContent, user, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
richContent = reactStringReplace(
|
||||
richContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, index) => (
|
||||
<a
|
||||
key={match + index}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="line-clamp-1 text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
|
||||
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||
<div key={nanoid()} className="h-3" />
|
||||
));
|
||||
|
||||
return { content: richContent, images, videos };
|
||||
} catch (e) {
|
||||
return { content, images, videos };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-[15px] text-balance break-words overflow-hidden",
|
||||
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{data.images.length ? <Images urls={data.images} /> : null}
|
||||
{data.videos.length ? <Videos urls={data.videos} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { Settings } from "@lume/types";
|
||||
import {
|
||||
AUDIOS,
|
||||
IMAGES,
|
||||
NOSTR_EVENTS,
|
||||
NOSTR_MENTIONS,
|
||||
VIDEOS,
|
||||
cn,
|
||||
} from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { ImagePreview } from "./preview/image";
|
||||
import { VideoPreview } from "./preview/video";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContentLarge({
|
||||
compact = true,
|
||||
className,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings }: { settings: Settings } = useRouteContext({
|
||||
strict: false,
|
||||
});
|
||||
const event = useNoteContext();
|
||||
const content = useMemo(() => {
|
||||
const text = event.content.trim();
|
||||
const words = text.split(/( |\n)/);
|
||||
|
||||
// @ts-ignore, kaboom !!!
|
||||
let parsedContent: ReactNode[] = compact
|
||||
? text.replace(/\n\s*\n/g, "\n")
|
||||
: 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 (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
parsedContent = reactStringReplace(parsedContent, regex, () => {
|
||||
return <Hashtag key={nanoid()} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
event,
|
||||
(match, i) => <MentionNote key={match + i} eventId={event} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
for (const mention of mentions) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
mention,
|
||||
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, i) => {
|
||||
try {
|
||||
const url = new URL(match);
|
||||
const ext = url.pathname.split(".")[1];
|
||||
|
||||
if (!settings.enhancedPrivacy) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
|
||||
<div key={nanoid()} className="h-1.5" />
|
||||
));
|
||||
}
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/[\r]?\n[\r]?\n/g,
|
||||
(_, index) => <br key={event.id + "_br_" + index} />,
|
||||
);
|
||||
|
||||
return parsedContent;
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("select-text", className)}>
|
||||
<div className="text-[15px] text-balance content-break leading-normal">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { NoteActivity } from "./activity";
|
||||
import { NoteOpenThread } from "./buttons/open";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
import { NoteZap } from "./buttons/zap";
|
||||
import { NoteChild } from "./child";
|
||||
import { NoteContent } from "./content";
|
||||
import { NoteContentLarge } from "./contentLarge";
|
||||
import { NoteMenu } from "./menu";
|
||||
import { NoteProvider } from "./provider";
|
||||
import { NoteRoot } from "./root";
|
||||
import { NoteThread } from "./thread";
|
||||
import { NoteUser } from "./user";
|
||||
|
||||
export const Note = {
|
||||
Provider: NoteProvider,
|
||||
Root: NoteRoot,
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Reply: NoteReply,
|
||||
Repost: NoteRepost,
|
||||
Content: NoteContent,
|
||||
ContentLarge: NoteContentLarge,
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
Thread: NoteThread,
|
||||
Activity: NoteActivity,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="break-all cursor-default leading-normal group"
|
||||
>
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
{tag.replace("#", "")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { memo } from "react";
|
||||
|
||||
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
|
||||
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { User } from "../../user";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
openable = true,
|
||||
}: {
|
||||
eventId: string;
|
||||
openable?: boolean;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-12 items-center gap-2 px-3">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex flex-1 items-center gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
|
||||
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-14 items-center justify-end px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
ark.open_event_id(data.id);
|
||||
}}
|
||||
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { HorizontalDotsIcon } from "@lume/icons";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteMenu() {
|
||||
const event = useNoteContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyID = async () => {
|
||||
await writeText(await ark.event_to_bech32(event.id, [""]));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyRaw = async () => {
|
||||
await writeText(JSON.stringify(event));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyNpub = async () => {
|
||||
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
await writeText(
|
||||
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
|
||||
);
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_event(event)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewThread")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLink()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyLink")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyID()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyNoteId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyNpub()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyAuthorId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
onClick={() => ark.open_profile(event.pubkey)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewAuthor")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyRaw()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyRaw")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { download } from "@tauri-apps/plugin-upload";
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: string }) {
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
|
||||
const downloadImage = async (e: { stopPropagation: () => void }) => {
|
||||
try {
|
||||
e.stopPropagation();
|
||||
|
||||
const downloadDirPath = await downloadDir();
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
await download(url, `${downloadDirPath}/${filename}`);
|
||||
|
||||
setDownloaded(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
const name = new URL(url).pathname.split("/").pop();
|
||||
return new WebviewWindow("image-viewer", {
|
||||
url,
|
||||
title: name,
|
||||
});
|
||||
};
|
||||
|
||||
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
event.currentTarget.src = "/fallback-image.jpg";
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div onClick={() => open()} className="group relative my-1">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
onError={fallback}
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => downloadImage(e)}
|
||||
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-4" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Carousel, CarouselItem } from "../../carousel";
|
||||
|
||||
export function Images({ urls }: { urls: string[] }) {
|
||||
const open = async (url: string) => {
|
||||
const name = new URL(url).pathname
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/[^a-zA-Z ]/g, "");
|
||||
const label = `viewer-${name}`;
|
||||
const window = WebviewWindow.getByLabel(label);
|
||||
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
|
||||
return newWindow;
|
||||
}
|
||||
|
||||
return await window.setFocus();
|
||||
};
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<img
|
||||
src={urls[0]}
|
||||
alt={urls[0]}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(urls[0])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<img
|
||||
src={item}
|
||||
alt={item}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useOpenGraph } from "@lume/utils";
|
||||
|
||||
function isImage(url: string) {
|
||||
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
|
||||
}
|
||||
|
||||
export function LinkPreview({ url }: { url: string }) {
|
||||
const domain = new URL(url);
|
||||
const { isLoading, isError, data } = useOpenGraph(url);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{domain.hostname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title && !data.image && !data.description) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
|
||||
>
|
||||
{isImage(data.image) ? (
|
||||
<img
|
||||
src={data.image}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col items-start p-3">
|
||||
<div className="flex flex-col items-start text-left">
|
||||
{data.title ? (
|
||||
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{data.title}
|
||||
</div>
|
||||
) : null}
|
||||
{data.description ? (
|
||||
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
|
||||
{data.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="break-all text-sm font-semibold text-blue-500">
|
||||
{domain.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-xl">
|
||||
<video
|
||||
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Carousel, CarouselItem } from "../../carousel";
|
||||
|
||||
export function Videos({ urls }: { urls: string[] }) {
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<video
|
||||
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={urls[0]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<video
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls={false}
|
||||
muted
|
||||
>
|
||||
<source src={item} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "..";
|
||||
|
||||
export function ChildReply({ event }: { event: Event; rootEventId?: string }) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="py-2">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content />
|
||||
<div className="flex items-center justify-end gap-4 mt-2">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NavArrowDownIcon } from "@lume/icons";
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from "..";
|
||||
import { ChildReply } from "./childReply";
|
||||
|
||||
export function Reply({
|
||||
event,
|
||||
}: {
|
||||
event: EventWithReplies;
|
||||
}) {
|
||||
const [t] = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="pt-2">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content />
|
||||
<div className="flex items-center justify-between h-14">
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="inline-flex items-center gap-1 font-semibold text-sm text-neutral-600 dark:text-neutral-400 h-14">
|
||||
<NavArrowDownIcon
|
||||
className={cn("size-5", open ? "rotate-180 transform" : "")}
|
||||
/>
|
||||
{`${event.replies?.length} ${
|
||||
event.replies?.length === 1
|
||||
? t("note.reply.single")
|
||||
: t("note.reply.plural")
|
||||
}`}
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
open
|
||||
? "pb-3 border-t border-neutral-100 dark:border-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Content className="divide-y divide-neutral-100 dark:divide-neutral-900 pl-6">
|
||||
{event.replies?.map((childEvent) => (
|
||||
<ChildReply key={childEvent.id} event={childEvent} />
|
||||
))}
|
||||
</Collapsible.Content>
|
||||
) : null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from "..";
|
||||
import { User } from "../../user";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed = JSON.parse(event.content) as Event;
|
||||
return embed;
|
||||
}
|
||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||
return await ark.get_event(id);
|
||||
} catch {
|
||||
throw new Error("Failed to get repost event");
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError || !repostEvent) {
|
||||
return (
|
||||
<Note.Root className={className}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex h-14 gap-2 px-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mb-3 select-text px-3">
|
||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
||||
<p className="text-red-500">Failed to get event</p>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex gap-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Provider event={repostEvent}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="size-10 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Content />
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Provider>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Note } from "..";
|
||||
|
||||
export function NoteSkeleton() {
|
||||
return (
|
||||
<Note.Root>
|
||||
<div className="flex h-min flex-col p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-6 w-full">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 flex gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "..";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="size-11 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Thread className="mb-2" />
|
||||
<Note.Content />
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { Note } from "..";
|
||||
import { User } from "../../user";
|
||||
|
||||
export function ThreadNote({ eventId }: { eventId: string }) {
|
||||
const { isLoading, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex flex-col">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-16 flex-1 items-center gap-3">
|
||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<User.Time time={data.created_at} />
|
||||
<span>·</span>
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Thread className="mb-2" />
|
||||
<Note.Content className="min-w-0" />
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const EventContext = createContext<Event>(null);
|
||||
|
||||
export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: { event: Event; children: ReactNode }) {
|
||||
return (
|
||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
const context = useContext(EventContext);
|
||||
if (!context) {
|
||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NoteRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from ".";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteThread({ className }: { className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
const thread = ark.parse_event_thread({
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!thread) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||
{thread.rootEventId ? (
|
||||
<Note.Child eventId={thread.rootEventId} isRoot />
|
||||
) : null}
|
||||
{thread.replyEventId ? (
|
||||
<Note.Child eventId={thread.replyEventId} />
|
||||
) : null}
|
||||
<div className="inline-flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ark.open_thread(thread.rootEventId || thread.replyEventId)
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { User } from "../user";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteUser({ className }: { className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<HoverCard.Root>
|
||||
<User.Root
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<HoverCard.Trigger className="shrink-0">
|
||||
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||
sideOffset={5}
|
||||
side="right"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="size-11 rounded-lg object-cover" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<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-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAbout({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("content-break select-text", className)}>
|
||||
{user.profile?.about?.trim() || "No bio"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nanoid } from "nanoid";
|
||||
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 || nanoid(), 90, 50),
|
||||
)}`,
|
||||
[user],
|
||||
);
|
||||
|
||||
return (
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user.profile?.picture}
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5", className)}
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={user.pubkey}
|
||||
className={cn("bg-black dark:bg-white", className)}
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserCover({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !user.profile.banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={user.profile.banner}
|
||||
alt="banner"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className={cn("object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "../spinner";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserFollowButton({
|
||||
simple = false,
|
||||
className,
|
||||
}: { simple?: boolean; className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const user = useUserContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
if (!followed) {
|
||||
const add = await ark.follow(user.pubkey);
|
||||
if (add) setFollowed(true);
|
||||
} else {
|
||||
const remove = await ark.unfollow(user.pubkey);
|
||||
if (remove) setFollowed(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function status() {
|
||||
setLoading(true);
|
||||
|
||||
const contacts = await ark.get_contact_list();
|
||||
if (contacts?.includes(user.pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
status();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => toggleFollow()}
|
||||
className={cn("w-max", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : followed ? (
|
||||
!simple ? (
|
||||
t("user.unfollow")
|
||||
) : null
|
||||
) : (
|
||||
t("user.follow")
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { UserAbout } from "./about";
|
||||
import { UserAvatar } from "./avatar";
|
||||
import { UserCover } from "./cover";
|
||||
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,
|
||||
Cover: UserCover,
|
||||
Name: UserName,
|
||||
NIP05: UserNip05,
|
||||
Time: UserTime,
|
||||
About: UserAbout,
|
||||
Button: UserFollowButton,
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { cn, displayNpub } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserName({
|
||||
className,
|
||||
prefix,
|
||||
}: {
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||
{prefix}
|
||||
{user.profile?.display_name ||
|
||||
user.profile?.name ||
|
||||
displayNpub(user.pubkey, 16)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { VerifiedIcon } from "@lume/icons";
|
||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05) return false;
|
||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
||||
return verify;
|
||||
},
|
||||
enabled: !!user.profile,
|
||||
});
|
||||
|
||||
if (!user.profile?.nip05?.length) return;
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger>
|
||||
{!isLoading && verified ? (
|
||||
<VerifiedIcon className="size-4 text-teal-500" />
|
||||
) : null}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{!user.profile?.nip05
|
||||
? displayNpub(user.pubkey, 16)
|
||||
: user.profile?.nip05.length > 50
|
||||
? displayLongHandle(user.profile?.nip05)
|
||||
: user.profile.nip05?.replace("_@", "")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const UserContext = createContext<{
|
||||
pubkey: string;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
profile: Metadata;
|
||||
}>(null);
|
||||
|
||||
export function UserProvider({
|
||||
pubkey,
|
||||
children,
|
||||
embedProfile,
|
||||
}: {
|
||||
pubkey: string;
|
||||
children: ReactNode;
|
||||
embedProfile?: string;
|
||||
}) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserContext() {
|
||||
const context = useContext(UserContext);
|
||||
return context;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function UserRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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("text-neutral-600 dark:text-neutral-400", className)}>
|
||||
{createdAt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user