chore: monorepo

This commit is contained in:
2023-12-25 14:28:39 +07:00
parent a6da07cd3f
commit 227c2ddefa
374 changed files with 19966 additions and 12758 deletions

View File

@@ -0,0 +1,76 @@
import { NavArrowDownIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { Note } from "..";
export function Reply({
event,
rootEvent,
}: {
event: NDKEventWithReplies;
rootEvent: string;
}) {
const [open, setOpen] = useState(false);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
className="h-14 px-3"
/>
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex items-center justify-between">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge(
"h-3 w-3",
open ? "rotate-180 transform" : "",
)}
/>
{`${event.replies?.length} ${
event.replies?.length === 1 ? "reply" : "replies"
}`}
</div>
</Collapsible.Trigger>
) : null}
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
<div className={twMerge("px-3", open ? "pb-3" : "")}>
{event.replies?.length > 0 ? (
<Collapsible.Content>
{event.replies?.map((childEvent) => (
<Note.Root key={childEvent.id}>
<Note.User pubkey={event.pubkey} time={event.created_at} />
<Note.TextContent
content={event.content}
className="min-w-0 px-3"
/>
<div className="-ml-1 flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
))}
</Collapsible.Content>
) : null}
</div>
</Note.Root>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,82 @@
import { NDKEvent, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { Note } from "..";
import { useArk } from "../../../provider";
export function RepostNote({ event }: { event: NDKEvent }) {
const ark = useArk();
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 NostrEvent;
return new NDKEvent(ark.ndk, embed);
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.getEventById({ id });
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
});
const renderContentByKind = () => {
if (!repostEvent) return null;
switch (repostEvent.kind) {
case NDKKind.Text:
return <Note.TextContent content={repostEvent.content} />;
case 1063:
return <Note.MediaContent tags={repostEvent.tags} />;
default:
return null;
}
};
if (isLoading) {
return <div className="w-full px-3 pb-3" />;
}
if (isError) {
return (
<div className="my-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<div className="relative flex flex-col gap-2">
<div className="px-3">
<p>Failed to load event</p>
</div>
</div>
</div>
</div>
);
}
return (
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
variant="repost"
className="h-14"
/>
<div className="relative flex flex-col gap-2 px-3">
<Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
{renderContentByKind()}
<div className="flex h-14 items-center justify-between">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={repostEvent.id} />
<Note.Reaction event={repostEvent} />
<Note.Repost event={repostEvent} />
<Note.Zap event={repostEvent} />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,24 @@
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>
);
}

View File

@@ -0,0 +1,29 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useArk } from "../../../provider";
export function TextNote({ event }: { event: NDKEvent }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
className="h-14 px-3"
/>
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,37 @@
import { PinIcon } from "@lume/icons";
import { WIDGET_KIND } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useWidget } from "../../../hooks/useWidget";
export function NotePin({ eventId }: { eventId: string }) {
const { addWidget } = useWidget();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: eventId,
})
}
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
>
<PinIcon className="size-4" />
Pin
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 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-800 dark:text-neutral-100">
Pin note
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,136 @@
import { ReactionIcon } from "@lume/icons";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Popover from "@radix-ui/react-popover";
import { useState } from "react";
import { toast } from "sonner";
const REACTIONS = [
{
content: "👏",
img: "/clapping_hands.png",
},
{
content: "🤪",
img: "/face_with_tongue.png",
},
{
content: "😮",
img: "/face_with_open_mouth.png",
},
{
content: "😢",
img: "/crying_face.png",
},
{
content: "🤡",
img: "/clown_face.png",
},
];
export function NoteReaction({ event }: { event: NDKEvent }) {
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find(
(el) => el.content === content,
);
return reaction.img;
};
const react = async (content: string) => {
try {
setReaction(content);
// react
await event.react(content);
setOpen(false);
} catch (e) {
toast.error(e);
}
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img
src={getReactionImage(reaction)}
alt={reaction}
className="h-5 w-5"
/>
) : (
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react("👏")}
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/clapping_hands.png"
alt="Clapping Hands"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("🤪")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("😮")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("😢")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/crying_face.png"
alt="Crying Face"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("🤡")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
</button>
</div>
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -0,0 +1,43 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { createSearchParams, useNavigate } from "react-router-dom";
export function NoteReply({
eventId,
rootEventId,
}: {
eventId: string;
rootEventId?: string;
}) {
const navigate = useNavigate();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
navigate({
pathname: "/new/",
search: createSearchParams({
replyTo: eventId,
rootReplyTo: rootEventId,
}).toString(),
})
}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 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-800 dark:text-neutral-100">
Quick reply
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,50 @@
import { RepostIcon } from "@lume/icons";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
export function NoteRepost({ event }: { event: NDKEvent }) {
const [isRepost, setIsRepost] = useState(false);
const submit = async () => {
try {
// repost
await event.repost(true);
// update state
setIsRepost(true);
toast.success("You've reposted this post successfully");
} catch (e) {
toast.error("Repost failed, try again later");
}
};
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={submit}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<RepostIcon
className={twMerge(
"h-5 w-5 group-hover:text-blue-600",
isRepost ? "text-blue-500" : "",
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 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-800 dark:text-neutral-100">
Repost
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,260 @@
import { webln } from "@getalby/sdk";
import { SendPaymentResponse } from "@getalby/sdk/dist/types";
import { CancelIcon, ZapIcon } from "@lume/icons";
import {
compactNumber,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Dialog from "@radix-ui/react-dialog";
import { invoke } from "@tauri-apps/api/primitives";
import { message } from "@tauri-apps/plugin-dialog";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useRef, useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useNavigate } from "react-router-dom";
import { useProfile } from "../../../hooks/useProfile";
import { useArk, useStorage } from "../../../provider";
export function NoteZap({ event }: { event: NDKEvent }) {
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
const [amount, setAmount] = useState<string>("21");
const [zapMessage, setZapMessage] = useState<string>("");
const [invoice, setInvoice] = useState<null | string>(null);
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { user } = useProfile(event.pubkey);
const ark = useArk();
const storage = useStorage();
const nwc = useRef(null);
const navigate = useNavigate();
const createZapRequest = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!res)
return await message("Cannot create zap request", {
title: "Zap",
type: "error",
});
// user don't connect nwc, create QR Code for invoice
if (!walletConnectURL) return setInvoice(res);
// user connect nwc
nwc.current = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: walletConnectURL,
});
await nwc.current.enable();
// start loading
setIsLoading(true);
// send payment via nwc
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
if (send) {
await sendNativeNotification(
`You've tipped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.display_name || user?.displayName
}`,
);
// eose
nwc.current.close();
setIsCompleted(true);
setIsLoading(false);
// reset after 3 secs
const timeout = setTimeout(() => setIsCompleted(false), 3000);
clearTimeout(timeout);
}
} catch (e) {
nwc.current.close();
setIsLoading(false);
await message(JSON.stringify(e), { title: "Zap", type: "error" });
}
};
useEffect(() => {
async function getWalletConnectURL() {
const uri: string = await invoke("secure_load", {
key: `${storage.account.pubkey}-nwc`,
});
if (uri) setWalletConnectURL(uri);
}
if (isOpen) getWalletConnectURL();
return () => {
setAmount("21");
setZapMessage("");
setIsCompleted(false);
setIsLoading(false);
};
}, [isOpen]);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
Send tip to{" "}
{user?.name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<CancelIcon className="h-4 w-4" />
</Dialog.Close>
</div>
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="relative flex h-40 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={"21"}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount("69")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount("100")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount("200")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount("500")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount("1000")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
{isCompleted ? (
<p className="leading-tight">Successfully zapped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="leading-tight">
Waiting for approval
</p>
<p className="text-xs leading-tight text-neutral-100">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="leading-tight">Send zap</p>
<p className="text-xs leading-tight text-neutral-100">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Create Lightning invoice
</button>
)}
</div>
</div>
</>
) : (
<div className="mt-3 flex flex-col items-center justify-center gap-4">
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">Scan to zap</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning
<br />
such as: Blue Wallet, Bitkit, Phoenix,...
</span>
</div>
</div>
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,38 @@
import { useEvent } from '../../hooks/useEvent';
import { NoteChildUser } from './childUser';
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" />
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { displayNpub } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { useProfile } from '../../hooks/useProfile';
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (isLoading) {
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name || user?.name || user?.displayName || fallbackName}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
import { NotePin } from "./buttons/pin";
import { NoteReaction } from "./buttons/reaction";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
import { NoteChild } from "./child";
import { NoteArticleContent } from "./kinds/article";
import { NoteMediaContent } from "./kinds/media";
import { NoteTextContent } from "./kinds/text";
import { NoteMenu } from "./menu";
import { NoteReplies } from "./reply";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
export const Note = {
Root: NoteRoot,
User: NoteUser,
Menu: NoteMenu,
Reply: NoteReply,
Repost: NoteRepost,
Reaction: NoteReaction,
Zap: NoteZap,
Pin: NotePin,
Child: NoteChild,
Thread: NoteThread,
TextContent: NoteTextContent,
MediaContent: NoteMediaContent,
ArticleContent: NoteArticleContent,
Replies: NoteReplies,
};
export * from "./builds/text";
export * from "./builds/repost";
export * from "./builds/skeleton";
export * from "./preview/image";
export * from "./preview/link";
export * from "./preview/video";
export * from "./mentions/note";
export * from "./mentions/user";
export * from "./mentions/hashtag";
export * from "./mentions/invoice";

View File

@@ -0,0 +1,63 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
export function NoteArticleContent({
eventId,
tags,
}: {
eventId: string;
tags: NDKTag[];
}) {
const getMetadata = () => {
const title = tags.find((tag) => tag[0] === 'title')?.[1];
const image = tags.find((tag) => tag[0] === 'image')?.[1];
const summary = tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
return {
title,
image,
publishedAt,
summary,
};
};
const metadata = getMetadata();
return (
<Link
to={`/events/${eventId}`}
preventScrollReset={true}
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
>
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-auto w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-1 rounded-b-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
{metadata.title}
</h5>
{metadata.summary ? (
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
{metadata.summary}
</p>
) : null}
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
{metadata.publishedAt.toString()}
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,80 @@
import { DownloadIcon } from "@lume/icons";
import { fileType } from "@lume/utils";
import { NDKTag } from "@nostr-dev-kit/ndk";
import { downloadDir } from "@tauri-apps/api/path";
import { download } from "@tauri-apps/plugin-upload";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function NoteMediaContent({
tags,
className,
}: {
tags: NDKTag[];
className?: string;
}) {
const url = tags.find((el) => el[0] === "url")[1];
const type = fileType(url);
const downloadImage = async (url: string) => {
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
return await download(url, downloadDirPath + `/${filename}`);
};
if (type === "image") {
return (
<div key={url} className={twMerge("group relative", className)}>
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-auto w-full object-cover"
/>
<button
type="button"
onClick={() => downloadImage(url)}
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
>
<DownloadIcon className="h-5 w-5 text-white" />
</button>
</div>
);
}
if (type === "video") {
return (
<div className={className}>
<MediaPlayer
src={url}
className="w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
</div>
);
}
return (
<div className={className}>
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { twMerge } from "tailwind-merge";
import { useRichContent } from "../../../hooks/useRichContent";
export function NoteTextContent({
content,
className,
}: {
content: string;
className?: string;
}) {
const { parsedContent } = useRichContent(content);
return (
<div
className={twMerge(
"break-p select-text whitespace-pre-line text-balance leading-normal",
className,
)}
>
{parsedContent}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { WIDGET_KIND } from "@lume/utils";
import { useWidget } from "../../../hooks/useWidget";
export function Hashtag({ tag }: { tag: string }) {
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.hashtag,
title: tag,
content: tag.replace("#", ""),
})
}
className="cursor-default break-all text-blue-500 hover:text-blue-600"
>
{tag}
</button>
);
}

View File

@@ -0,0 +1,10 @@
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>
);
});

View File

@@ -0,0 +1,70 @@
import { WIDGET_KIND } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { memo } from "react";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useWidget } from "../../../hooks/useWidget";
export const MentionNote = memo(function MentionNote({
eventId,
}: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <Note.TextContent content={event.content} />;
case NDKKind.Article:
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
case 1063:
return <Note.MediaContent tags={event.tags} />;
default:
return <Note.TextContent content={event.content} />;
}
};
if (isLoading) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Loading
</div>
);
}
if (isError) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Failed to fetch event
</div>
);
}
return (
<Note.Root className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="mt-3 px-3">
<Note.User
pubkey={data.pubkey}
time={data.created_at}
variant="mention"
/>
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: data.id,
})
}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</div>
</Note.Root>
);
});

View File

@@ -0,0 +1,27 @@
import { WIDGET_KIND } from "@lume/utils";
import { memo } from "react";
import { useProfile } from "../../../hooks/useProfile";
import { useWidget } from "../../../hooks/useWidget";
export const MentionUser = memo(function MentionUser({
pubkey,
}: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.user,
title: user?.name || user?.display_name || user?.displayName,
content: pubkey,
})
}
className="break-words text-blue-500 hover:text-blue-600"
>
{`@${user?.name || user?.displayName || user?.username || "unknown"}`}
</button>
);
});

View File

@@ -0,0 +1,63 @@
import { HorizontalDotsIcon } from '@lume/icons';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useState } from 'react';
import { Link } from 'react-router-dom';
export function NoteMenu({ eventId, pubkey }: { eventId: string; pubkey: string }) {
const [open, setOpen] = useState(false);
const copyID = async () => {
await writeText(nip19.neventEncode({ id: eventId, author: pubkey } as EventPointer));
setOpen(false);
};
const copyLink = async () => {
await writeText(
`https://njump.me/${nip19.neventEncode({ id: eventId, author: pubkey } as EventPointer)}`
);
setOpen(false);
};
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button type="button" className="inline-flex h-6 w-6 items-center justify-center">
<HorizontalDotsIcon className="h-4 w-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy shareable link
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy ID
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
View profile
</Link>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,57 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { Window } from "@tauri-apps/api/window";
import { download } from "@tauri-apps/plugin-upload";
import { 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 = () => {
return new Window("image-viewer", { url, title: "Image Viewer" });
};
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-2">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="h-auto w-full rounded-lg border border-neutral-200/50 object-cover dark:border-neutral-800/50"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-10 hidden h-10 w-10 items-center justify-center rounded-lg bg-blue-500 group-hover:inline-flex hover:bg-blue-600"
>
{downloaded ? (
<CheckCircleIcon className="h-5 w-5 text-white" />
) : (
<DownloadIcon className="h-5 w-5 text-white" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useOpenGraph } from "@lume/utils";
import { Link } from "react-router-dom";
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 { status, data } = useOpenGraph(url);
if (status === "pending") {
return (
<div className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="h-48 w-full 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) {
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
);
}
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
className="h-48 w-full rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start px-3 py-3">
<div className="flex flex-col items-start gap-1 text-left">
{data.title ? (
<div className="break-all text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="mb-2 line-clamp-3 break-all text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,19 @@
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
export function VideoPreview({ url }: { url: string }) {
return (
<MediaPlayer
src={url}
className="my-2 w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
}

View File

@@ -0,0 +1,67 @@
import { LoaderIcon } from '@lume/icons';
import { NDKEventWithReplies } from '@lume/types';
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
import { Reply } from './builds/reply';
export function NoteReplies({ eventId }: { eventId: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={twMerge(
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { WIDGET_KIND } from '@lume/utils';
import { twMerge } from 'tailwind-merge';
import { Note } from '.';
import { useWidget } from '../../hooks/useWidget';
export function NoteThread({
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { RepostIcon } from '@lume/icons';
import { displayNpub, formatCreatedAt } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useProfile } from '../../hooks/useProfile';
export function NoteUser({
pubkey,
time,
variant = 'text',
className,
}: {
pubkey: string;
time: number;
variant?: 'text' | 'repost' | 'mention';
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (variant === 'mention') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
if (variant === 'repost') {
if (isLoading) {
return (
<div className={twMerge('flex gap-3', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className={twMerge('flex gap-2', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
);
}