wip: tired...

This commit is contained in:
2024-02-14 15:51:06 +07:00
parent 60fd09000b
commit 6171b9bed1
87 changed files with 2380 additions and 3667 deletions

View File

@@ -1,6 +0,0 @@
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
export function ColumnContent({ children }: { children: ReactNode }) {
return <Routes>{children}</Routes>;
}

View File

@@ -1,89 +0,0 @@
import {
ChevronDownIcon,
MoveLeftIcon,
MoveRightIcon,
RefreshIcon,
TrashIcon,
} from "@lume/icons";
import { useColumn } from "@lume/storage";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useColumnContext } from "./provider";
export function ColumnHeader({
queryKey,
}: {
queryKey?: string[];
}) {
const { t } = useTranslation();
const { move, remove } = useColumn();
const column = useColumnContext();
const queryClient = useQueryClient();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
return (
<DropdownMenu.Root>
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none"
>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<RefreshIcon className="size-4" />
{t("global.refresh")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "left")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveLeftIcon className="size-4" />
{t("global.moveLeft")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "right")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveRightIcon className="size-4" />
{t("global.moveRight")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => remove(column.id)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />
{t("global.delete")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
}

View File

@@ -1,15 +0,0 @@
import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnProvider } from "./provider";
import { ColumnRoot } from "./root";
export const Column = {
Provider: ColumnProvider,
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,
Content: ColumnContent,
Route: Route,
};

View File

@@ -1,46 +0,0 @@
import { ArrowUpIcon } from "@lume/icons";
import { useEffect, useState } from "react";
export function ColumnLiveWidget({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: (event: NDKEvent[]) => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
const update = async () => {
onClick(events);
// reset
setEvents([]);
};
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 z-40 flex items-center justify-center w-full top-12 h-11">
<button
type="button"
onClick={update}
className="inline-flex items-center justify-center h-9 gap-1 pl-4 pr-3 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
>
{events.length} {events.length === 1 ? "new note" : "new notes"}
<ArrowUpIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { LumeColumn } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
const ColumnContext = createContext<LumeColumn>(null);
export function ColumnProvider({
column,
children,
}: { column: LumeColumn; children: ReactNode }) {
return (
<ColumnContext.Provider value={column}>{children}</ColumnContext.Provider>
);
}
export function useColumnContext() {
const context = useContext(ColumnContext);
if (!context) {
throw new Error(
"Please import Column Provider to use useColumnContext() hook",
);
}
return context;
}

View File

@@ -1,37 +0,0 @@
import { cn } from "@lume/utils";
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { MemoryRouter, UNSAFE_LocationContext } from "react-router-dom";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<UNSAFE_LocationContext.Provider value={null}>
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={cn(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
<MemoryRouter future={{ v7_startTransition: true }}>
{children}
</MemoryRouter>
</Resizable>
</UNSAFE_LocationContext.Provider>
);
}

View File

@@ -1,52 +0,0 @@
import { useQuery } from "@tanstack/react-query";
export function AppHandler({ tag }: { tag: string[] }) {
const ark = useArk();
const { isLoading, isError, data } = useQuery({
queryKey: ["app-handler", tag[1]],
queryFn: async () => {
const ref = tag[1].split(":");
const event = await ark.getEventByFilter({
filter: {
kinds: [Number(ref[0])],
authors: [ref[1]],
"#d": [ref[2]],
},
});
if (!event) return null;
const app = NDKAppHandlerEvent.from(event);
return await app.fetchProfile();
},
refetchOnWindowFocus: false,
});
if (isLoading) {
<div>Loading...</div>;
}
if (isError || !data) {
return <div>Error</div>;
}
return (
<div className="flex items-center gap-2 p-2 rounded-md bg-neutral-200 dark:bg-neutral-800 hover:ring-1 hover:ring-blue-500">
<img
src={data?.picture || data?.image}
alt={data.pubkey}
decoding="async"
className="object-cover bg-white rounded-lg h-9 w-9 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<div className="flex flex-col">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{data.name}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-1">
{data.about}
</div>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { PinIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "../provider";
export function NotePin() {
const event = useNoteContext();
const { t } = useTranslation();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
>
<PinIcon className="size-4" />
{t("note.buttons.pin")}
</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">
{t("note.buttons.pinTooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,137 +0,0 @@
import { ReactionIcon } from "@lume/icons";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useState } from "react";
import { useNoteContext } from "../provider";
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() {
const event = useNoteContext();
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) {
console.error(e);
}
};
return (
<HoverCard.Root open={open} onOpenChange={setOpen}>
<HoverCard.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img
src={getReactionImage(reaction)}
alt={reaction}
className="size-6"
/>
) : (
<ReactionIcon className="size-5 group-hover:text-blue-500" />
)}
</button>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
className="select-none rounded-lg bg-neutral-950 dark:bg-neutral-50 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"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react("👏")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/clapping_hands.png"
alt="Clapping Hands"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("🤪")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("😮")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("😢")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/crying_face.png"
alt="Crying Face"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("🤡")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img src="/clown_face.png" alt="Clown Face" className="size-6" />
</button>
</div>
<HoverCard.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}

View File

@@ -1,34 +0,0 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useNoteContext } from "../provider";
export function NoteReply() {
const event = useNoteContext();
const navigate = useNavigate();
const { t } = useTranslation();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="size-5 group-hover:text-blue-500" />
</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">
{t("note.menu.viewThread")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,117 +0,0 @@
import { LoaderIcon, ReplyIcon, RepostIcon } from "@lume/icons";
import { cn, editorAtom, editorValueAtom } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
export function NoteRepost() {
const event = useNoteContext();
const setEditorValue = useSetAtom(editorValueAtom);
const setIsEditorOpen = useSetAtom(editorAtom);
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const [open, setOpen] = useState(false);
const repost = async () => {
try {
setLoading(true);
// repost
await event.repost(true);
// 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");
}
};
const quote = () => {
setEditorValue([
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
// @ts-expect-error, useless
eventId: `nostr:${nip19.noteEncode(event.id)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
]);
setIsEditorOpen(true);
};
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RepostIcon
className={cn(
"size-5 group-hover:text-blue-600",
isRepost ? "text-blue-500" : "",
)}
/>
)}
</button>
</Tooltip.Trigger>
</DropdownMenu.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">
{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] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={repost}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={quote}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<ReplyIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -1,260 +0,0 @@
import { webln } from "@getalby/sdk";
import { type SendPaymentResponse } from "@getalby/sdk/dist/types";
import { CancelIcon, LoaderIcon, ZapIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { cn, compactNumber, displayNpub } from "@lume/utils";
import * as Dialog from "@radix-ui/react-dialog";
import * as Tooltip from "@radix-ui/react-tooltip";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useProfile } from "../../../hooks/useProfile";
import { useNoteContext } from "../provider";
export function NoteZap() {
const storage = useStorage();
const event = useNoteContext();
const [amount, setAmount] = useState<string>("21");
const [zapMessage, setZapMessage] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [invoice, setInvoice] = useState<string>(null);
const { t } = useTranslation();
const { user } = useProfile(event.pubkey);
const createZapRequest = async (instant?: boolean) => {
if (instant && !storage.nwc) return;
let nwc: webln.NostrWebLNProvider = undefined;
try {
// start loading
setIsLoading(true);
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!storage.nwc) return setInvoice(res);
// user connect nwc
nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: storage.nwc,
});
await nwc.enable();
// send payment via nwc
const send: SendPaymentResponse = await nwc.sendPayment(res);
if (send) {
toast.success(
`You've zapped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.displayName || "anon"
}`,
);
// reset after 1.5 secs
if (!instant) {
const timeout = setTimeout(() => setIsCompleted(false), 1500);
clearTimeout(timeout);
}
}
// eose
nwc.close();
// update state
setIsCompleted(true);
setIsLoading(false);
} catch (e) {
nwc?.close();
setIsLoading(false);
toast.error(String(e));
}
};
if (storage.settings.instantZap) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => createZapRequest(true)}
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
{isLoading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<ZapIcon
className={cn(
"size-5 group-hover:text-blue-500",
isCompleted ? "text-blue-500" : "",
)}
/>
)}
</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">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Dialog.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
</Dialog.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">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-white/20" />
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
<Dialog.Close className="absolute top-5 right-5 z-50">
<div className="flex flex-col gap-1.5">
<div className="inline-flex items-center justify-center size-10 rounded-lg bg-white dark:bg-black">
<CancelIcon className="size-5" />
</div>
<span className="text-sm font-medium">Esc</span>
</div>
</Dialog.Close>
<div className="relative w-full max-w-xl bg-white h-min rounded-xl dark:bg-black">
<div className="inline-flex items-center justify-center w-full px-5 py-3 shrink-0">
<div className="w-6" />
<Dialog.Title className="font-semibold text-center">
{t("note.zap.modalTitle")}{" "}
{user?.name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</Dialog.Title>
</div>
{!invoice ? (
<div className="px-5 pb-5 overflow-x-hidden overflow-y-auto">
<div className="relative flex flex-col h-36">
<div className="inline-flex items-center justify-center flex-1 h-full 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="flex-1 w-full text-4xl font-semibold text-right bg-transparent border-none placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="flex-1 w-full text-4xl font-semibold text-left text-neutral-500 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 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount("100")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount("200")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount("500")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount("1000")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="flex flex-col w-full gap-2 mt-4">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder={t("note.zap.messagePlaceholder")}
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-950 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex items-center justify-center w-full pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isCompleted
? t("note.zap.buttonFinish")
: isLoading
? t("note.zap.buttonLoading")
: t("note.zap.zap")}
</button>
</div>
</div>
</div>
) : (
<div className="px-5 pb-5 flex flex-col items-center justify-center gap-4">
<div className="rounded-lg 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">
{t("note.zap.invoiceButton")}
</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
{t("note.zap.invoiceFooter")}
</span>
</div>
</div>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,122 +0,0 @@
import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { useEvent } from "../../hooks/useEvent";
import { User } from "../user";
import { Hashtag } from "./mentions/hashtag";
import { MentionUser } from "./mentions/user";
export function NoteChild({
eventId,
isRoot,
}: { eventId: string; isRoot?: boolean }) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent as string;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
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 (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
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 || !data) {
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">
{t("note.error")}
</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">
{richContent}
</div>
</div>
<User.Provider pubkey={data.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<User.Name className="max-w-[10rem] truncate" />
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{isRoot ? t("note.posted") : t("note.replied")}:
</div>
</div>
</User.Root>
</User.Provider>
</div>
);
}

View File

@@ -1,256 +0,0 @@
import { useStorage } from "@lume/storage";
import { Kind } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
canPreview,
cn,
regionNames,
} from "@lume/utils";
import { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls";
import { nanoid } from "nanoid";
import { ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { toast } from "sonner";
import { stripHtml } from "string-strip-html";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { NIP89 } from "./nip89";
import { ImagePreview } from "./preview/image";
import { LinkPreview } from "./preview/link";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContent({
className,
}: {
className?: string;
}) {
const storage = useStorage();
const event = useNoteContext();
const [content, setContent] = useState(event.content);
const [translate, setTranslate] = useState({
translatable: false,
translated: false,
});
const richContent = useMemo(() => {
if (event.kind !== Kind.Text) return content;
let parsedContent: string | ReactNode[] = stripHtml(
content.replace(/\n{2,}\s*/g, "\n"),
).result;
let linkPreview: string = undefined;
let images: string[] = [];
let videos: string[] = [];
let audios: string[] = [];
let events: string[] = [];
const text = parsedContent;
const words = text.split(/( |\n)/);
const urls = [...getUrls(text)];
if (storage.settings.media && !storage.settings.lowPower) {
images = urls.filter((word) =>
IMAGES.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
videos = urls.filter((word) =>
VIDEOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
audios = urls.filter((word) =>
AUDIOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
}
events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (images.length) {
for (const image of images) {
parsedContent = reactStringReplace(
parsedContent,
image,
(match, i) => <ImagePreview key={match + i} url={match} />,
);
}
}
if (videos.length) {
for (const video of videos) {
parsedContent = reactStringReplace(
parsedContent,
video,
(match, i) => <VideoPreview key={match + i} url={match} />,
);
}
}
if (audios.length) {
for (const audio of audios) {
parsedContent = reactStringReplace(
parsedContent,
audio,
(match, i) => <VideoPreview key={match + i} url={match} />,
);
}
}
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
if (storage.settings.hashtag)
return <Hashtag key={nanoid()} tag={hashtag} />;
return null;
});
}
}
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+)/g,
(match, i) => {
const url = new URL(match);
if (!linkPreview && canPreview(match)) {
linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />;
}
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p truncate inline-block w-full font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
parsedContent = reactStringReplace(parsedContent, "\n", () => {
return <div key={nanoid()} className="h-3" />;
});
if (typeof parsedContent[0] === "string") {
parsedContent[0] = parsedContent[0].trimStart();
}
return parsedContent;
} catch (e) {
console.warn(event.id, `[parser] parse failed: ${e}`);
return parsedContent;
}
}, [content]);
const translateContent = async () => {
try {
if (!translate.translatable) return;
const res = await fetch("https://translate.nostr.wine/translate", {
method: "POST",
body: JSON.stringify({
q: event.content,
target: storage.locale.slice(0, 2),
api_key: storage.settings.translateApiKey,
}),
headers: { "Content-Type": "application/json" },
});
if (!res.ok)
toast.error(
"Cannot connect to translate service, please try again later.",
);
const data = await res.json();
setContent(data.translatedText);
setTranslate((state) => ({ ...state, translated: true }));
} catch (e) {
console.error("translate api: ", String(e));
}
};
if (event.kind !== Kind.Text) {
return <NIP89 className={className} />;
}
return (
<div className={cn(className)}>
<div className="break-p select-text text-balance leading-normal whitespace-pre-line">
{richContent}
</div>
{storage.settings.translation && translate.translatable ? (
translate.translated ? (
<button
type="button"
onClick={() => setContent(event.content)}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
>
Show original content
</button>
) : (
<button
type="button"
onClick={translateContent}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
>
Translate to {regionNames.of(storage.locale)}
</button>
)
) : null}
</div>
);
}

View File

@@ -1,27 +0,0 @@
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 { NoteContent } from "./content";
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,
Reaction: NoteReaction,
Content: NoteContent,
Zap: NoteZap,
Pin: NotePin,
Child: NoteChild,
Thread: NoteThread,
};

View File

@@ -1,10 +0,0 @@
export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
className="text-blue-500 break-all cursor-default hover:text-blue-600"
>
{tag}
</button>
);
}

View File

@@ -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>
);
});

View File

@@ -1,144 +0,0 @@
import { PinIcon } from "@lume/icons";
import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { useEvent } from "../../../hooks/useEvent";
import { User } from "../../user";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
export function MentionNote({
eventId,
openable = true,
}: { eventId: string; openable?: boolean }) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent as string;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
parsedContent = reactStringReplace(
parsedContent,
hashtag,
(match, i) => {
return <Hashtag key={match + i} tag={hashtag} />;
},
);
}
}
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+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p inline-block truncate w-full font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
if (isLoading) {
return (
<div
contentEditable={false}
className="flex items-center justify-between w-full p-3 my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900"
>
<p>Loading...</p>
</div>
);
}
if (isError || !data) {
return (
<div
contentEditable={false}
className="w-full p-3 my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900"
>
{t("note.error")}
</div>
);
}
return (
<div className="flex flex-col w-full my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900 border border-black/5 dark:border-white/5">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-10 px-3 items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-md object-cover" />
<div className="flex-1 inline-flex 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="px-3 select-text text-balance leading-normal line-clamp-4 whitespace-pre-line">
{richContent}
</div>
{openable ? (
<div className="px-3 h-10 flex items-center justify-between">
<Link
to={`/events/${data.id}`}
className="text-sm text-blue-500 hover:text-blue-600"
>
{t("note.showMore")}
</Link>
<button
type="button"
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
}

View File

@@ -1,40 +0,0 @@
import { COL_TYPES } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useProfile } from "../../../hooks/useProfile";
export function MentionUser({ pubkey }: { pubkey: string }) {
const { isLoading, isError, user } = useProfile(pubkey);
const { t } = useTranslation();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="text-start text-blue-500 break-words hover:text-blue-600">
{isLoading
? "@anon"
: isError
? pubkey
: `@${user?.name || user?.display_name || user?.name || "anon"}`}
</DropdownMenu.Trigger>
<DropdownMenu.Content className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none">
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.buttons.viewProfile")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.buttons.pin")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}

View File

@@ -1,112 +0,0 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useArk } from "../../provider";
import { useNoteContext } from "./provider";
export function NoteMenu() {
const ark = useArk();
const event = useNoteContext();
const navigate = useNavigate();
const { t } = useTranslation();
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center size-6"
>
<HorizontalDotsIcon className="size-4 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to={`/users/${event.pubkey}`}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.viewAuthor")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.pinAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -1,55 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useArk } from "../../provider";
import { AppHandler } from "./appHandler";
import { useNoteContext } from "./provider";
export function NIP89({ className }: { className?: string }) {
const ark = useArk();
const event = useNoteContext();
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({
queryKey: ["app-recommend", event.id],
queryFn: () => {
return ark.getAppRecommend({
unknownKind: event.kind.toString(),
author: event.pubkey,
});
},
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: Infinity,
});
if (isLoading) {
<div>Loading...</div>;
}
if (isError || !data) {
return <div>Error</div>;
}
return (
<div className={className}>
<div className="flex flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="inline-flex items-center justify-between h-10 px-3 border-b shrink-0 border-neutral-200 dark:border-neutral-800">
<p className="text-sm font-medium text-amber-400">
{t("nip89.unsupported")}
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{event.kind}
</p>
</div>
<div className="flex flex-col flex-1 gap-2 px-3 py-3">
<span className="text-sm font-medium uppercase text-neutral-600 dark:text-neutral-400">
{t("nip89.openWith")}
</span>
{data.map((item) => (
<AppHandler key={item[1]} tag={item} />
))}
</div>
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { WebviewWindow } from "@tauri-apps/api/webview";
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 = 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="relative mt-1 mb-2.5 group">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="object-cover w-full h-auto border rounded-xl border-neutral-200/50 dark:border-neutral-800/50"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute z-10 items-center justify-center hidden size-10 bg-white/10 text-black/70 backdrop-blur-xl rounded-lg right-2 top-2 group-hover:inline-flex hover:bg-blue-500 hover:text-white"
>
{downloaded ? (
<CheckCircleIcon className="size-5" />
) : (
<DownloadIcon className="size-5" />
)}
</button>
</div>
);
}

View File

@@ -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="mb-2.5 mt-1 flex w-full flex-col overflow-hidden rounded-xl border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900">
<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="text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="mb-2.5 mt-1 flex w-full flex-col overflow-hidden rounded-xl border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900"
>
{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="break-p text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="break-p 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>
);
}

View File

@@ -1,30 +0,0 @@
import {
MediaControlBar,
MediaController,
MediaMuteButton,
MediaPlayButton,
MediaTimeDisplay,
MediaTimeRange,
} from "media-chrome/dist/react";
export function VideoPreview({ url }: { url: string }) {
return (
<div className="mt-1 mb-2.5 w-full rounded-xl overflow-hidden">
<MediaController>
<video
slot="media"
src={url}
preload="auto"
muted
className="w-full h-auto"
/>
<MediaControlBar>
<MediaPlayButton />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
</MediaControlBar>
</MediaController>
</div>
);
}

View File

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

View File

@@ -1,69 +0,0 @@
import { NavArrowDownIcon } from "@lume/icons";
import { 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>
);
}

View File

@@ -1,110 +0,0 @@
import { RepostIcon } from "@lume/icons";
import { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { useArk } from "../../../provider";
import { User } from "../../user";
export function RepostNote({
event,
className,
}: { event: Event; className?: string }) {
const ark = useArk();
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 gap-2 px-3 h-14">
<div className="inline-flex shrink-0 w-10 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="px-3 mb-3 select-text">
<div className="flex flex-col items-start justify-start px-3 py-3 bg-red-100 rounded-lg dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-2 px-3 h-14">
<div className="inline-flex shrink-0 w-10 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>
<Note.Provider event={repostEvent}>
<div className="relative flex flex-col gap-2 px-3">
<div className="flex items-center justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Content />
<div className="flex items-center justify-between h-14">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
}

View File

@@ -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>
);
}

View File

@@ -1,34 +0,0 @@
import { 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(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,43 +0,0 @@
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
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 rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex items-center justify-between px-3 h-16">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-16 items-center gap-3 flex-1">
<User.Avatar className="size-10 shrink-0 rounded-lg 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 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,21 +0,0 @@
import { Event } from "@lume/types";
import { 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;
}

View File

@@ -1,19 +0,0 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn("h-min w-full overflow-hidden", className)}
contentEditable={false}
>
{children}
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { PinIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Note } from ".";
import { useArk } from "../../provider";
import { useNoteContext } from "./provider";
export function NoteThread({
className,
}: {
className?: string;
}) {
const ark = useArk();
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 px-3", className)}>
<div className="flex flex-col w-full gap-3 p-3 rounded-lg h-min bg-neutral-100 dark:bg-neutral-900">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<div className="inline-flex items-center justify-between">
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
{t("note.showThread")}
</Link>
<button
type="button"
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card";
import { Link } from "react-router-dom";
import { User } from "../user";
import { useNoteContext } from "./provider";
export function NoteUser({
className,
}: {
className?: string;
}) {
const event = useNoteContext();
return (
<User.Provider pubkey={event.pubkey}>
<HoverCard.Root>
<User.Root className={cn("flex items-center gap-3", className)}>
<HoverCard.Trigger>
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</HoverCard.Trigger>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="data-[side=bottom]:animate-slideUpAndFade w-[300px] shadow-lg shadow-neutral-500/20 rounded-xl bg-white dark:shadow-none dark:bg-neutral-900 dark:border dark:border-neutral-800 p-5 data-[state=open]:transition-all"
sideOffset={5}
>
<div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-col gap-2">
<div>
<User.Name className="font-semibold leading-tight" />
<User.NIP05 className="text-neutral-600 dark:text-neutral-400" />
</div>
<User.About className="line-clamp-3" />
<Link
to={`/users/${event.pubkey}`}
className="mt-3 w-full h-8 text-sm font-medium bg-neutral-100 dark:bg-neutral-900 hover:bg-neutral-200 dark:hover:bg-neutral-800 rounded-lg inline-flex items-center justify-center"
>
View profile
</Link>
</div>
</div>
<HoverCard.Arrow className="fill-white dark:fill-neutral-800" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</User.Provider>
);
}

View File

@@ -1,37 +0,0 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
if (!user.profile) {
return (
<div className="flex flex-col gap-1">
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
<div
className={cn(
"h-4 w-full bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
<div
className={cn(
"h-4 w-24 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
</div>
);
}
return (
<div className={cn("select-text break-p", className)}>
{user.profile.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -1,65 +0,0 @@
import { useStorage } from "@lume/storage";
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 storage = useStorage();
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user?.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
if (!user.profile) {
return (
<div className="shrink-0">
<div
className={cn(
"bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
</div>
);
}
return (
<Avatar.Root className="shrink-0">
{storage.settings.lowPower ? (
<Avatar.Image
src={fallbackAvatar}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn(
"bg-black dark:bg-white ring-1 ring-black/5 dark:ring-white/5",
className,
)}
/>
) : (
<Avatar.Image
src={user.profile.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
)}
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -1,59 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useArk } from "../../provider";
export function UserFollowButton({
target,
className,
}: { target: string; className?: string }) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.createContact(target);
if (add) setFollowed(true);
} else {
const remove = await ark.deleteContact(target);
if (remove) setFollowed(false);
}
setLoading(false);
};
useEffect(() => {
async function status() {
setLoading(true);
const contacts = await ark.getUserContacts();
if (contacts?.includes(target)) {
setFollowed(true);
}
setLoading(false);
}
status();
}, []);
return (
<button
type="button"
disabled={loading}
onClick={toggleFollow}
className={cn("w-max", className)}
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : followed ? (
t("user.unfollow")
) : (
t("user.follow")
)}
</button>
);
}

View File

@@ -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,
};

View File

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

View File

@@ -1,44 +0,0 @@
import { VerifiedIcon } from "@lume/icons";
import { cn, displayNpub } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider";
export function UserNip05({ className }: { className?: string }) {
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.profile.nip05],
queryFn: async () => {
if (!user) return false;
if (!user.profile.nip05) return false;
return false;
},
enabled: !!user,
});
if (!user.profile) {
return (
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
);
}
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm", className)}>
{!user?.profile.nip05
? displayNpub(user.pubkey, 16)
: user?.profile.nip05?.startsWith("_@")
? user?.profile.nip05?.replace("_@", "")
: user?.profile.nip05}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="text-teal-500 size-4" />
) : null}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
export function UserProvider({
pubkey,
children,
embed,
}: { pubkey: string; children: ReactNode; embed?: string }) {
const { data: profile } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata;
const profile: Metadata = await invoke("get_profile", { id: pubkey });
if (!profile)
throw new Error(
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`,
);
return profile;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
retry: 2,
});
return (
<UserContext.Provider value={{ pubkey, profile }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

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

View File

@@ -1,11 +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("", className)}>{createdAt}</div>;
}

View File

@@ -1,18 +1,3 @@
export * from "./provider";
export * from "./hooks/useEvent";
export * from "./hooks/useProfile";
export * from "./components/user";
export * from "./components/column";
export * from "./components/note";
export * from "./components/note/primitives/text";
export * from "./components/note/primitives/repost";
export * from "./components/note/primitives/skeleton";
export * from "./components/note/primitives/thread";
export * from "./components/note/primitives/reply";
export * from "./components/note/preview/image";
export * from "./components/note/preview/link";
export * from "./components/note/preview/video";
export * from "./components/note/mentions/note";
export * from "./components/note/mentions/user";
export * from "./components/note/mentions/hashtag";
export * from "./components/note/mentions/invoice";