mirror of
https://github.com/lumehq/lume.git
synced 2025-03-17 21:32:32 +01:00
wip: tired...
This commit is contained in:
parent
60fd09000b
commit
6171b9bed1
@ -23,7 +23,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.0.2",
|
||||
"sonner": "^1.4.0"
|
||||
"sonner": "^1.4.0",
|
||||
"virtua": "^0.23.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
|
@ -10,6 +10,7 @@ import { useStorage } from "@lume/storage";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { cn } from "@lume/utils";
|
||||
import { ActiveAccount } from "@lume/ui";
|
||||
|
||||
export const Route = createFileRoute("/app")({
|
||||
component: App,
|
||||
@ -19,65 +20,73 @@ function App() {
|
||||
const storage = useStorage();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-800 dark:to-neutral-900">
|
||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center gap-2",
|
||||
"flex h-11 shrink-0 items-center justify-between",
|
||||
storage.platform === "macos" ? "pl-24" : "",
|
||||
)}
|
||||
>
|
||||
<Link to="/app/home">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-black" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<HomeFilledIcon className="size-5" />
|
||||
) : (
|
||||
<HomeIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Home</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/app/space">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-black" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<SpaceFilledIcon className="size-5" />
|
||||
) : (
|
||||
<SpaceIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Space</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/app/activity">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-black" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<BellFilledIcon className="size-5" />
|
||||
) : (
|
||||
<BellIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Activity</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full flex-1 items-center gap-2"
|
||||
>
|
||||
<Link to="/app/home">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-neutral-950" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<HomeFilledIcon className="size-5" />
|
||||
) : (
|
||||
<HomeIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Home</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/app/space">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-neutral-950" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<SpaceFilledIcon className="size-5" />
|
||||
) : (
|
||||
<SpaceIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Space</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/app/activity">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
||||
isActive ? "bg-white shadow dark:bg-neutral-950" : "",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<BellFilledIcon className="size-5" />
|
||||
) : (
|
||||
<BellIcon className="size-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Activity</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div data-tauri-drag-region className="flex items-center gap-2 pr-4">
|
||||
<ActiveAccount />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div className="h-full w-full flex-1 px-2 pb-2">
|
||||
|
@ -1,13 +1,116 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons";
|
||||
import { Event, Kind } from "@lume/types";
|
||||
import { EmptyFeed } from "@lume/ui";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { CacheSnapshot, VList, VListHandle } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/app/home")({
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
const ark = useArk();
|
||||
const ref = useRef<VListHandle>();
|
||||
const cacheKey = "timeline-vlist";
|
||||
|
||||
const [offset, cache] = useMemo(() => {
|
||||
const serialized = sessionStorage.getItem(cacheKey);
|
||||
if (!serialized) return [];
|
||||
return JSON.parse(serialized) as [number, CacheSnapshot];
|
||||
}, []);
|
||||
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["timeline"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_text_events(FETCH_LIMIT, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return <p key={event.id}>{event.content}</p>;
|
||||
case Kind.Repost:
|
||||
return <p key={event.id}>{event.content}</p>;
|
||||
default:
|
||||
return <p key={event.id}>{event.content}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const handle = ref.current;
|
||||
|
||||
if (offset) {
|
||||
handle.scrollTo(offset);
|
||||
}
|
||||
|
||||
return () => {
|
||||
sessionStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify([handle.scrollOffset, handle.cache]),
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
||||
<p>Home</p>
|
||||
<div className="mx-auto h-full w-full max-w-2xl pt-10">
|
||||
<VList ref={ref} cache={cache} overscan={2}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="mt-3 px-3">
|
||||
<EmptyFeed />
|
||||
<a
|
||||
href="/suggest"
|
||||
className="mt-3 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
Find accounts to follow
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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";
|
||||
|
@ -3,14 +3,14 @@ import { ReplyList } from "@lume/ui";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
|
||||
export function HomeRoute({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="pb-5 overflow-y-auto">
|
||||
<WindowVirtualizer>
|
||||
<div className="px-3 mt-3">
|
||||
<ThreadNote eventId={id} />
|
||||
<ReplyList eventId={id} className="mt-5" />
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-y-auto pb-5">
|
||||
<WindowVirtualizer>
|
||||
<div className="mt-3 px-3">
|
||||
<ThreadNote eventId={id} />
|
||||
<ReplyList eventId={id} className="mt-5" />
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@getalby/sdk": "^3.2.3",
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/storage": "workspace:^",
|
||||
@ -12,24 +13,34 @@
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.18.1",
|
||||
"@tanstack/react-router": "^1.16.0",
|
||||
"framer-motion": "^11.0.3",
|
||||
"get-urls": "^12.1.0",
|
||||
"jotai": "^2.6.4",
|
||||
"media-chrome": "^2.1.0",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.5",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"re-resizable": "^6.9.11",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.14",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.0.2",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.101.5",
|
||||
"slate-react": "^0.101.6",
|
||||
"sonner": "^1.4.0",
|
||||
"string-strip-html": "^13.4.6",
|
||||
"uqr": "^0.1.2",
|
||||
"use-debounce": "^10.0.0",
|
||||
"virtua": "^0.23.3"
|
||||
|
@ -6,8 +6,7 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Logout } from "./logout";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { LogoutDialog } from "./logoutDialog";
|
||||
|
||||
export function ActiveAccount() {
|
||||
const ark = useArk();
|
||||
@ -26,58 +25,55 @@ export function ActiveAccount() {
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<div className="relative">
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture}
|
||||
alt={ark.account.npub}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="aspect-square h-auto w-full rounded-xl object-cover"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.npub}
|
||||
className="aspect-square h-auto w-full rounded-xl bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 right-0 block size-3 rounded-full ring-2 ring-neutral-200 dark:ring-neutral-800",
|
||||
isOnline ? "bg-teal-500" : "bg-red-500",
|
||||
)}
|
||||
<Avatar.Root
|
||||
className={cn(
|
||||
"rounded-full ring-1 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950",
|
||||
isOnline ? "ring-teal-500" : "ring-red-500",
|
||||
)}
|
||||
>
|
||||
<Avatar.Image
|
||||
src={user?.picture}
|
||||
alt={ark.account.npub}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="aspect-square h-auto w-7 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.npub}
|
||||
className="aspect-square h-auto w-7 rounded-full bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
side="right"
|
||||
sideOffset={5}
|
||||
className="relative top-5 flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"
|
||||
side="left"
|
||||
sideOffset={10}
|
||||
className="relative top-2 flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"
|
||||
>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
to="/settings/profile"
|
||||
<a
|
||||
href="/settings/profile"
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<UserIcon className="size-4" />
|
||||
{t("user.editProfile")}
|
||||
</Link>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
to="/settings/"
|
||||
<a
|
||||
href="/settings/"
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
{t("user.settings")}
|
||||
</Link>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
|
||||
<Logout />
|
||||
<LogoutDialog />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
@ -6,7 +6,7 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Logout() {
|
||||
export function LogoutDialog() {
|
||||
const ark = useArk();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
@ -1,263 +1,262 @@
|
||||
import { MentionNote, User, useArk, useColumnContext } from "@lume/ark";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
import { COL_TYPES, cn, editorValueAtom } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { cn, editorValueAtom } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Descendant,
|
||||
Editor,
|
||||
Node,
|
||||
Range,
|
||||
Transforms,
|
||||
createEditor,
|
||||
Descendant,
|
||||
Editor,
|
||||
Node,
|
||||
Range,
|
||||
Transforms,
|
||||
createEditor,
|
||||
} from "slate";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
import { EditorAddMedia } from "./addMedia";
|
||||
import {
|
||||
Portal,
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
Portal,
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
} from "./utils";
|
||||
import { MentionNote } from "../note/mentions/note";
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withMentions = (editor: ReactEditor) => {
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withImages = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const Image = ({ attributes, children, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative my-2">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative my-2">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Mention = ({ attributes, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Event = ({ attributes, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="relative user-select-none my-2"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative my-2"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Element = (props) => {
|
||||
const { attributes, children, element } = props;
|
||||
const { attributes, children, element } = props;
|
||||
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function EditorForm() {
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
|
||||
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
|
||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
|
||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filters = contacts
|
||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
||||
?.slice(0, 10);
|
||||
const filters = contacts
|
||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
||||
?.slice(0, 10);
|
||||
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const content = serialize(editor.children);
|
||||
const publish = await invoke("publish", { content });
|
||||
const content = serialize(editor.children);
|
||||
const publish = await invoke("publish", { content });
|
||||
|
||||
if (publish) {
|
||||
console.log(publish);
|
||||
toast.success(t("editor.successMessage"));
|
||||
if (publish) {
|
||||
console.log(publish);
|
||||
toast.success(t("editor.successMessage"));
|
||||
|
||||
return reset();
|
||||
}
|
||||
return reset();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
/*
|
||||
useEffect(() => {
|
||||
async function loadContacts() {
|
||||
const res = await storage.getAllCacheUsers();
|
||||
@ -268,113 +267,113 @@ export function EditorForm() {
|
||||
}, []);
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-between rounded-xl overflow-hidden bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col justify-between overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 pl-7 pr-3 border-b shrink-0 border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
||||
<div>
|
||||
<h3 className="font-medium">{t("editor.title")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<EditorAddMedia />
|
||||
</div>
|
||||
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex items-center justify-center w-20 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"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("global.post")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-6 h-full overflow-y-auto px-7">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={t("editor.placeholder")}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-2 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-xl shadow-lg"
|
||||
>
|
||||
{filters.map((contact, i) => (
|
||||
<button
|
||||
key={contact.npub}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="p-2 flex flex-col w-full rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User.Provider pubkey={contact.npub}>
|
||||
<User.Root className="w-full flex items-center gap-2.5">
|
||||
<User.Avatar className="size-8 rounded-lg object-cover shrink-0" />
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50 pl-7 pr-3 dark:border-neutral-900 dark:bg-neutral-950">
|
||||
<div>
|
||||
<h3 className="font-medium">{t("editor.title")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<EditorAddMedia />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("global.post")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full overflow-y-auto px-7 py-6">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={t("editor.placeholder")}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
||||
>
|
||||
{filters.map((contact, i) => (
|
||||
<button
|
||||
key={contact.npub}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User.Provider pubkey={contact.npub}>
|
||||
<User.Root className="flex w-full items-center gap-2.5">
|
||||
<User.Avatar className="size-8 shrink-0 rounded-lg object-cover" />
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { MentionNote, User, useArk } from "@lume/ark";
|
||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
@ -8,391 +7,397 @@ import { Portal } from "@radix-ui/react-dropdown-menu";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Descendant,
|
||||
Editor,
|
||||
Node,
|
||||
Range,
|
||||
Transforms,
|
||||
createEditor,
|
||||
Descendant,
|
||||
Editor,
|
||||
Node,
|
||||
Range,
|
||||
Transforms,
|
||||
createEditor,
|
||||
} from "slate";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
import { EditorAddMedia } from "./addMedia";
|
||||
import {
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
} from "./utils";
|
||||
import { MentionNote } from "../note/mentions/note";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { User } from "../user";
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withMentions = (editor: ReactEditor) => {
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withImages = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const Image = ({ attributes, children, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Mention = ({ attributes, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Event = ({ attributes, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="relative user-select-none"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Element = (props) => {
|
||||
const { attributes, children, element } = props;
|
||||
const { attributes, children, element } = props;
|
||||
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ReplyForm({
|
||||
eventId,
|
||||
className,
|
||||
}: { eventId: string; className?: string }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
eventId,
|
||||
className,
|
||||
}: {
|
||||
eventId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
|
||||
const [editorValue, setEditorValue] = useState([
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
]);
|
||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
const [editorValue, setEditorValue] = useState([
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
]);
|
||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filters = contacts
|
||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
||||
?.slice(0, 10);
|
||||
const filters = contacts
|
||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
||||
?.slice(0, 10);
|
||||
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const event = new NDKEvent(ark.ndk);
|
||||
event.kind = NDKKind.Text;
|
||||
event.content = serialize(editor.children);
|
||||
const event = new NDKEvent(ark.ndk);
|
||||
event.kind = NDKKind.Text;
|
||||
event.content = serialize(editor.children);
|
||||
|
||||
const rootEvent = await ark.getEventById(eventId);
|
||||
event.tag(rootEvent, "root");
|
||||
const rootEvent = await ark.getEventById(eventId);
|
||||
event.tag(rootEvent, "root");
|
||||
|
||||
const publish = await event.publish();
|
||||
const publish = await event.publish();
|
||||
|
||||
if (publish) {
|
||||
toast.success(
|
||||
`Event has been published successfully to ${publish.size} relays.`,
|
||||
);
|
||||
if (publish) {
|
||||
toast.success(
|
||||
`Event has been published successfully to ${publish.size} relays.`,
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
|
||||
return reset();
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
return reset();
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadContacts() {
|
||||
const res = await storage.getAllCacheUsers();
|
||||
if (res) setContacts(res);
|
||||
}
|
||||
useEffect(() => {
|
||||
async function loadContacts() {
|
||||
const res = await storage.getAllCacheUsers();
|
||||
if (res) setContacts(res);
|
||||
}
|
||||
|
||||
loadContacts();
|
||||
}, []);
|
||||
loadContacts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3", className)}>
|
||||
<User.Provider pubkey={ark.account.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex-1">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
return (
|
||||
<div className={cn("flex gap-3", className)}>
|
||||
<User.Provider pubkey={ark.account.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex-1">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto p-3 bg-neutral-100 dark:bg-neutral-900 rounded-xl">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={t("editor.replyPlaceholder")}
|
||||
className="focus:outline-none h-28"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-1 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-lg shadow-lg"
|
||||
>
|
||||
{filters.map((contact, i) => (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
key={contact.npub}
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User.Provider pubkey={contact.npub}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-10 rounded-lg object-cover shrink-0" />
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<User.Name className="max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between shrink-0">
|
||||
<div />
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<EditorAddMedia />
|
||||
</div>
|
||||
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex items-center justify-center w-20 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"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("global.post")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={t("editor.replyPlaceholder")}
|
||||
className="h-28 focus:outline-none"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-lg border border-neutral-50 bg-white p-1 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
||||
>
|
||||
{filters.map((contact, i) => (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
key={contact.npub}
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="rounded-md px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User.Provider pubkey={contact.npub}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<User.Name className="max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between">
|
||||
<div />
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<EditorAddMedia />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("global.post")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,14 @@
|
||||
export * from "./account/active";
|
||||
export * from "./account/logout";
|
||||
export * from "./navigation";
|
||||
export * from "./titlebar";
|
||||
export * from "./layouts/app";
|
||||
export * from "./layouts/auth";
|
||||
export * from "./layouts/home";
|
||||
export * from "./layouts/settings";
|
||||
export * from "./mentions";
|
||||
export * from "./replyList";
|
||||
export * from "./emptyFeed";
|
||||
// New
|
||||
export * from "./user";
|
||||
export * from "./note";
|
||||
export * from "./column";
|
||||
|
||||
// Deprecated
|
||||
export * from "./routes/event";
|
||||
export * from "./routes/user";
|
||||
export * from "./routes/suggest";
|
||||
export * from "./mentions";
|
||||
export * from "./replyList";
|
||||
export * from "./emptyFeed";
|
||||
export * from "./translateRegisterModal";
|
||||
export * from "./user";
|
||||
export * from "./account/active";
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Editor } from "../editor/column";
|
||||
import { Navigation } from "../navigation";
|
||||
import { SearchDialog } from "../search/dialog";
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tl from-neutral-50 to-neutral-200 dark:from-neutral-950 dark:to-neutral-800">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<div className="flex w-full h-full min-h-0">
|
||||
<Navigation />
|
||||
<Editor />
|
||||
<SearchDialog />
|
||||
<div className="flex-1 h-full px-1 pb-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { ArrowLeftIcon } from "@lume/icons";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
export function AuthLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const canGoBack = location.pathname.length > 6;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen bg-black text-neutral-50">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute top-8 z-10 flex items-center justify-between w-full px-9">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center justify-center rounded-lg size-10 group"
|
||||
>
|
||||
<ArrowLeftIcon className="size-6 text-neutral-700 group-hover:text-neutral-500" />
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { OnboardingModal } from "../onboarding/modal";
|
||||
|
||||
export function HomeLayout() {
|
||||
return (
|
||||
<>
|
||||
<OnboardingModal />
|
||||
<div className="h-full w-full rounded-xl overflow-hidden bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import {
|
||||
AdvancedSettingsIcon,
|
||||
InfoIcon,
|
||||
SecureIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
export function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col rounded-xl overflow-y-auto">
|
||||
<div className="flex h-24 shrink-0 w-full items-center justify-center px-2 bg-white/50 backdrop-blur-xl dark:bg-black/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavLink
|
||||
end
|
||||
to="/settings/"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsIcon className="size-6" />
|
||||
<p className="text-sm font-medium">{t("settings.general.title")}</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings/profile"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<UserIcon className="size-6" />
|
||||
<p className="text-sm font-medium">{t("settings.user.title")}</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings/nwc"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ZapIcon className="size-6" />
|
||||
<p className="text-sm font-medium">{t("settings.zap.title")}</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings/backup"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SecureIcon className="size-6" />
|
||||
<p className="text-sm font-medium">{t("settings.backup.title")}</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings/advanced"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<AdvancedSettingsIcon className="size-6" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.advanced.title")}
|
||||
</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings/about"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-6" />
|
||||
<p className="text-sm font-medium">{t("settings.about.title")}</p>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 py-6 min-h-0 overflow-y-auto bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
import {
|
||||
ArrowUpSquareIcon,
|
||||
BellFilledIcon,
|
||||
BellIcon,
|
||||
HomeFilledIcon,
|
||||
HomeIcon,
|
||||
PlusIcon,
|
||||
SearchFilledIcon,
|
||||
SearchIcon,
|
||||
SettingsFilledIcon,
|
||||
SettingsIcon,
|
||||
} from "@lume/icons";
|
||||
import { cn, editorAtom, searchAtom } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { Update, check } from "@tauri-apps/plugin-updater";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { ActiveAccount } from "./account/active";
|
||||
import { UnreadActivity } from "./unread";
|
||||
|
||||
export function Navigation() {
|
||||
const [isEditorOpen, setIsEditorOpen] = useAtom(editorAtom);
|
||||
const [search, setSearch] = useAtom(searchAtom);
|
||||
const [update, setUpdate] = useState<Update>(null);
|
||||
|
||||
// shortcut for editor
|
||||
useHotkeys("meta+n", () => setIsEditorOpen((state) => !state), []);
|
||||
|
||||
const installNewUpdate = async () => {
|
||||
if (!update) return;
|
||||
|
||||
const yes = await confirm(update.body, {
|
||||
title: `v${update.version} is available`,
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (yes) {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function checkNewUpdate() {
|
||||
const newVersion = await check();
|
||||
setUpdate(newVersion);
|
||||
}
|
||||
checkNewUpdate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full w-20 shrink-0 flex-col justify-between px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-col gap-3">
|
||||
<ActiveAccount />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen((state) => !state)}
|
||||
className={cn(
|
||||
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
|
||||
isEditorOpen
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600",
|
||||
)}
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto my-5 h-px w-2/3 bg-black/10 dark:bg-white/10" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to="/app/space"
|
||||
className="inline-flex flex-col items-center justify-center"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
|
||||
isActive
|
||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
|
||||
: "text-neutral-600 dark:text-neutral-400",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<HomeFilledIcon className="size-6" />
|
||||
) : (
|
||||
<HomeIcon className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/app/activity"
|
||||
className="inline-flex flex-col items-center justify-center"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
|
||||
isActive
|
||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
|
||||
: "text-neutral-600 dark:text-neutral-400",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<BellFilledIcon className="size-6" />
|
||||
) : (
|
||||
<BellIcon className="size-6" />
|
||||
)}
|
||||
<UnreadActivity />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{update ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={installNewUpdate}
|
||||
className="relative inline-flex flex-col items-center justify-center"
|
||||
>
|
||||
<span className="inline-flex items-center rounded-full bg-teal-500/60 px-2 py-1 text-xs font-semibold text-teal-50 ring-1 ring-inset ring-teal-500/80 dark:bg-teal-500/10 dark:text-teal-400 dark:ring-teal-500/20">
|
||||
Update
|
||||
</span>
|
||||
<div className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl text-black/50 dark:text-neutral-400">
|
||||
<ArrowUpSquareIcon className="size-6" />
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch((open) => !open)}
|
||||
className="inline-flex flex-col items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
|
||||
search
|
||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
|
||||
: "text-neutral-600 dark:text-neutral-400",
|
||||
)}
|
||||
>
|
||||
{search ? (
|
||||
<SearchFilledIcon className="size-6" />
|
||||
) : (
|
||||
<SearchIcon className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex flex-col items-center justify-center"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
|
||||
isActive
|
||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
|
||||
: "text-neutral-600 dark:text-neutral-400",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<SettingsFilledIcon className="size-6" />
|
||||
) : (
|
||||
<SettingsIcon className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
53
packages/ui/src/note/appHandler.tsx
Normal file
53
packages/ui/src/note/appHandler.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
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 rounded-md bg-neutral-200 p-2 hover:ring-1 hover:ring-blue-500 dark:bg-neutral-800">
|
||||
<img
|
||||
src={data?.picture || data?.image}
|
||||
alt={data.pubkey}
|
||||
decoding="async"
|
||||
className="h-9 w-9 shrink-0 rounded-lg bg-white object-cover 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="line-clamp-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{data.about}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
260
packages/ui/src/note/buttons/zap.tsx
Normal file
260
packages/ui/src/note/buttons/zap.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
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 { useNoteContext } from "../provider";
|
||||
import { useProfile } from "@lume/ark";
|
||||
|
||||
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="group inline-flex size-7 items-center justify-center 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="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 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{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="group inline-flex size-7 items-center justify-center 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="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 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{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 min-h-full items-center justify-center">
|
||||
<Dialog.Close className="absolute right-5 top-5 z-50">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="inline-flex size-10 items-center justify-center 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 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-center px-5 py-3">
|
||||
<div className="w-6" />
|
||||
<Dialog.Title className="text-center font-semibold">
|
||||
{t("note.zap.modalTitle")}{" "}
|
||||
{user?.name ||
|
||||
user?.displayName ||
|
||||
displayNpub(event.pubkey, 16)}
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
{!invoice ? (
|
||||
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
|
||||
<div className="relative flex h-36 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-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="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={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 h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark: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="flex flex-col items-center justify-center gap-4 px-5 pb-5">
|
||||
<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>
|
||||
);
|
||||
}
|
125
packages/ui/src/note/child.tsx
Normal file
125
packages/ui/src/note/child.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
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 { User } from "../user";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { useEvent } from "@lume/ark";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
147
packages/ui/src/note/mentions/note.tsx
Normal file
147
packages/ui/src/note/mentions/note.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
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 { User } from "../../user";
|
||||
import { Hashtag } from "./hashtag";
|
||||
import { MentionUser } from "./user";
|
||||
import { useEvent } from "@lume/ark";
|
||||
|
||||
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 w-full truncate 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="my-1 flex w-full cursor-default items-center justify-between rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
|
||||
>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="my-1 w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
|
||||
>
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1 flex w-full cursor-default flex-col rounded-lg border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-10 items-center gap-2 px-3">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-md object-cover" />
|
||||
<div className="inline-flex flex-1 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="line-clamp-4 select-text whitespace-pre-line text-balance px-3 leading-normal">
|
||||
{richContent}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-10 items-center justify-between px-3">
|
||||
<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 size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
39
packages/ui/src/note/mentions/user.tsx
Normal file
39
packages/ui/src/note/mentions/user.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { isLoading, isError, user } = useProfile(pubkey);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="break-words text-start text-blue-500 hover:text-blue-600">
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? pubkey
|
||||
: `@${user?.name || user?.display_name || user?.name || "anon"}`}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
to={`/users/${pubkey}`}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.buttons.viewProfile")}
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.buttons.pin")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
112
packages/ui/src/note/menu.tsx
Normal file
112
packages/ui/src/note/menu.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
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 { useNoteContext } from "./provider";
|
||||
import { useArk } from "@lume/ark";
|
||||
|
||||
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 size-6 items-center justify-center"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-4 hover:text-blue-500 dark:text-neutral-200" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLink()}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.viewThread")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/events/${event.id}`)}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.copyLink")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyID()}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.copyNoteId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyNpub()}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.copyAuthorId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
to={`/users/${event.pubkey}`}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.viewAuthor")}
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.pinAuthor")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyRaw()}
|
||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{t("note.menu.copyRaw")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
55
packages/ui/src/note/nip89.tsx
Normal file
55
packages/ui/src/note/nip89.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppHandler } from "./appHandler";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { useArk } from "@lume/ark";
|
||||
|
||||
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 h-10 shrink-0 items-center justify-between border-b border-neutral-200 px-3 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-1 flex-col 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>
|
||||
);
|
||||
}
|
113
packages/ui/src/note/primitives/repost.tsx
Normal file
113
packages/ui/src/note/primitives/repost.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
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 { User } from "../../user";
|
||||
import { useArk } from "@lume/ark";
|
||||
|
||||
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 h-14 gap-2 px-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mb-3 select-text px-3">
|
||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
||||
<p className="text-red-500">Failed to get event</p>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex h-14 gap-2 px-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<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 h-14 items-center justify-between">
|
||||
<Note.Pin />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Provider>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
43
packages/ui/src/note/primitives/thread.tsx
Normal file
43
packages/ui/src/note/primitives/thread.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { Note } from "..";
|
||||
import { User } from "../../user";
|
||||
|
||||
export function ThreadNote({ eventId }: { eventId: string }) {
|
||||
const { isLoading, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<div className="flex h-16 items-center justify-between px-3">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-16 flex-1 items-center gap-3">
|
||||
<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 h-14 items-center justify-between px-3">
|
||||
<Note.Pin />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
47
packages/ui/src/note/thread.tsx
Normal file
47
packages/ui/src/note/thread.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
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 { useNoteContext } from "./provider";
|
||||
import { useArk } from "@lume/ark";
|
||||
|
||||
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 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}
|
||||
<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 size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
51
packages/ui/src/note/user.tsx
Normal file
51
packages/ui/src/note/user.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
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] rounded-xl bg-white p-5 shadow-lg shadow-neutral-500/20 data-[state=open]:transition-all dark:border dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-none"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="size-11 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
<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" />
|
||||
<a
|
||||
href={`/users/${event.pubkey}`}
|
||||
className="mt-3 inline-flex h-8 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
View profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-white dark:fill-neutral-800" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
@ -1,83 +1,86 @@
|
||||
import { Reply, useArk } from "@lume/ark";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { NDKEventWithReplies } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReplyForm } from "./editor/replyForm";
|
||||
import { Reply } from "./note/primitives/reply";
|
||||
|
||||
export function ReplyList({
|
||||
eventId,
|
||||
className,
|
||||
}: { eventId: string; className?: string }) {
|
||||
const ark = useArk();
|
||||
eventId,
|
||||
className,
|
||||
}: {
|
||||
eventId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
let isCancelled = false;
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
let isCancelled = false;
|
||||
|
||||
async function fetchRepliesAndSub() {
|
||||
const id = ark.getCleanEventId(eventId);
|
||||
const events = await ark.getThreads(id);
|
||||
async function fetchRepliesAndSub() {
|
||||
const id = ark.getCleanEventId(eventId);
|
||||
const events = await ark.getThreads(id);
|
||||
|
||||
if (!isCancelled) {
|
||||
setData(events);
|
||||
}
|
||||
if (!isCancelled) {
|
||||
setData(events);
|
||||
}
|
||||
|
||||
if (!sub) {
|
||||
sub = ark.subscribe({
|
||||
filter: {
|
||||
"#e": [id],
|
||||
kinds: [NDKKind.Text],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
closeOnEose: false,
|
||||
cb: (event: NDKEventWithReplies) =>
|
||||
setData((prev) => [event, ...prev]),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!sub) {
|
||||
sub = ark.subscribe({
|
||||
filter: {
|
||||
"#e": [id],
|
||||
kinds: [NDKKind.Text],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
closeOnEose: false,
|
||||
cb: (event: NDKEventWithReplies) =>
|
||||
setData((prev) => [event, ...prev]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe for new replies
|
||||
fetchRepliesAndSub();
|
||||
// subscribe for new replies
|
||||
fetchRepliesAndSub();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [eventId]);
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReplyForm
|
||||
eventId={eventId}
|
||||
className="py-4 border-t border-neutral-100 dark:border-neutral-900"
|
||||
/>
|
||||
{!data ? (
|
||||
<div className="mt-4 flex h-16 items-center justify-center p-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="mt-4 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">
|
||||
{t("note.reply.empty")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <Reply key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReplyForm
|
||||
eventId={eventId}
|
||||
className="border-t border-neutral-100 py-4 dark:border-neutral-900"
|
||||
/>
|
||||
{!data ? (
|
||||
<div className="mt-4 flex h-16 items-center justify-center p-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="mt-4 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">
|
||||
{t("note.reply.empty")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <Reply key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,37 +1,37 @@
|
||||
import { ThreadNote } from "@lume/ark";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "../replyList";
|
||||
import { ThreadNote } from "../note/primitives/thread";
|
||||
|
||||
export function EventRoute() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="pb-5 overflow-y-auto">
|
||||
<WindowVirtualizer>
|
||||
<div className="relative z-50 h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<ThreadNote eventId={id} />
|
||||
<ReplyList eventId={id} className="mt-3" />
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-y-auto pb-5">
|
||||
<WindowVirtualizer>
|
||||
<div className="relative z-50 mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<ThreadNote eventId={id} />
|
||||
<ReplyList eventId={id} className="mt-3" />
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,127 +1,127 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { User } from "../user";
|
||||
|
||||
const POPULAR_USERS = [
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
|
||||
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
|
||||
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
|
||||
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
|
||||
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
|
||||
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
|
||||
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
|
||||
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
|
||||
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
|
||||
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
|
||||
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
|
||||
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
|
||||
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
|
||||
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
|
||||
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
|
||||
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
|
||||
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
|
||||
];
|
||||
|
||||
const LUME_USERS = [
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
|
||||
];
|
||||
|
||||
export function SuggestRoute({ queryKey }: { queryKey: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["trending-users"],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch trending users from nostr.band API.");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["trending-users"],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch trending users from nostr.band API.");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await queryClient.refetchQueries({ queryKey: [queryKey] });
|
||||
return navigate("/", { replace: true });
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
try {
|
||||
await queryClient.refetchQueries({ queryKey: [queryKey] });
|
||||
return navigate("/", { replace: true });
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-5 overflow-y-auto">
|
||||
<WindowVirtualizer>
|
||||
<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3">
|
||||
<div className="flex items-center h-16">
|
||||
<h3 className="font-semibold text-xl">{t("suggestion.title")}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
|
||||
{isLoading ? (
|
||||
<div className="flex h-44 w-full items-center justify-center">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex h-44 w-full items-center justify-center">
|
||||
{t("suggestion.error")}
|
||||
</div>
|
||||
) : (
|
||||
data?.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="py-5 h-max w-full overflow-hidden"
|
||||
>
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-10 shrink-0 rounded-lg" />
|
||||
<User.Name className="max-w-[15rem] truncate font-semibold leadning-tight" />
|
||||
</div>
|
||||
<User.Button
|
||||
target={item.pubkey}
|
||||
className="w-20 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"
|
||||
/>
|
||||
</div>
|
||||
<User.About className="mt-1 line-clamp-3 text-neutral-800 dark:text-neutral-400 max-w-none select-text" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky z-10 flex items-center justify-center w-full bottom-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex items-center justify-center gap-2 px-6 font-medium shadow-xl dark:shadow-none shadow-neutral-500/50 text-white transform bg-blue-500 rounded-full active:translate-y-1 w-44 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("suggestion.button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-y-auto pb-5">
|
||||
<WindowVirtualizer>
|
||||
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3">
|
||||
<div className="flex h-16 items-center">
|
||||
<h3 className="text-xl font-semibold">{t("suggestion.title")}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
|
||||
{isLoading ? (
|
||||
<div className="flex h-44 w-full items-center justify-center">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex h-44 w-full items-center justify-center">
|
||||
{t("suggestion.error")}
|
||||
</div>
|
||||
) : (
|
||||
data?.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="h-max w-full overflow-hidden py-5"
|
||||
>
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-10 shrink-0 rounded-lg" />
|
||||
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
<User.Button
|
||||
target={item.pubkey}
|
||||
className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 flex w-full items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-11 w-44 transform items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white shadow-xl shadow-neutral-500/50 hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:cursor-not-allowed dark:shadow-none"
|
||||
>
|
||||
{t("suggestion.button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { RepostNote, TextNote, User, useArk } from "@lume/ark";
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowRightIcon,
|
||||
LoaderIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowRightIcon,
|
||||
LoaderIcon,
|
||||
} from "@lume/icons";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
@ -12,136 +12,137 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { User } from "../user";
|
||||
|
||||
export function UserRoute() {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { id } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["user-posts", id],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: [id],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
const { id } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["user-posts", id],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: [id],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} className="mt-3" />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||
}
|
||||
};
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} className="mt-3" />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-5 overflow-y-auto">
|
||||
<WindowVirtualizer>
|
||||
<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<User.Avatar className="h-12 w-12 shrink-0 rounded-lg object-cover" />
|
||||
<User.Button
|
||||
target={id}
|
||||
className="inline-flex items-center justify-center w-24 text-sm 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-col">
|
||||
<User.Name className="text-lg font-semibold" />
|
||||
<User.NIP05
|
||||
pubkey={id}
|
||||
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<User.About className="text-neutral-900 dark:text-neutral-100" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t("user.latestPosts")}
|
||||
</h3>
|
||||
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 font-medium bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800 rounded-xl focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
{t("global.loadMore")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-y-auto pb-5">
|
||||
<WindowVirtualizer>
|
||||
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<User.Avatar className="h-12 w-12 shrink-0 rounded-lg object-cover" />
|
||||
<User.Button
|
||||
target={id}
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 text-sm font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-col">
|
||||
<User.Name className="text-lg font-semibold" />
|
||||
<User.NIP05
|
||||
pubkey={id}
|
||||
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<User.About className="text-neutral-900 dark:text-neutral-100" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mt-2 border-t border-neutral-100 pt-2 dark:border-neutral-900">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t("user.latestPosts")}
|
||||
</h3>
|
||||
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
{t("global.loadMore")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export function WindowButton({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex cursor-default items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,140 +0,0 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const WindowIcons = {
|
||||
minimizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.498047 1.00098C0.429688 1.00098 0.364583 0.987956 0.302734 0.961914C0.244141 0.935872 0.192057 0.900065 0.146484 0.854492C0.100911 0.808919 0.0651042 0.756836 0.0390625 0.698242C0.0130208 0.636393 0 0.571289 0 0.50293C0 0.43457 0.0130208 0.371094 0.0390625 0.3125C0.0651042 0.250651 0.100911 0.19694 0.146484 0.151367C0.192057 0.102539 0.244141 0.0651042 0.302734 0.0390625C0.364583 0.0130208 0.429688 0 0.498047 0H9.50195C9.57031 0 9.63379 0.0130208 9.69238 0.0390625C9.75423 0.0651042 9.80794 0.102539 9.85352 0.151367C9.89909 0.19694 9.9349 0.250651 9.96094 0.3125C9.98698 0.371094 10 0.43457 10 0.50293C10 0.571289 9.98698 0.636393 9.96094 0.698242C9.9349 0.756836 9.89909 0.808919 9.85352 0.854492C9.80794 0.900065 9.75423 0.935872 9.69238 0.961914C9.63379 0.987956 9.57031 1.00098 9.50195 1.00098H0.498047Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8956"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
maximizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M1.47461 10.001C1.2793 10.001 1.09212 9.96191 0.913086 9.88379C0.734049 9.80241 0.576172 9.69499 0.439453 9.56152C0.30599 9.4248 0.198568 9.26693 0.117188 9.08789C0.0390625 8.90885 0 8.72168 0 8.52637V1.47559C0 1.28027 0.0390625 1.0931 0.117188 0.914062C0.198568 0.735026 0.30599 0.578776 0.439453 0.445312C0.576172 0.308594 0.734049 0.201172 0.913086 0.123047C1.09212 0.0416667 1.2793 0.000976562 1.47461 0.000976562H8.52539C8.7207 0.000976562 8.90788 0.0416667 9.08691 0.123047C9.26595 0.201172 9.4222 0.308594 9.55566 0.445312C9.69238 0.578776 9.7998 0.735026 9.87793 0.914062C9.95931 1.0931 10 1.28027 10 1.47559V8.52637C10 8.72168 9.95931 8.90885 9.87793 9.08789C9.7998 9.26693 9.69238 9.4248 9.55566 9.56152C9.4222 9.69499 9.26595 9.80241 9.08691 9.88379C8.90788 9.96191 8.7207 10.001 8.52539 10.001H1.47461ZM8.50098 9C8.56934 9 8.63281 8.98698 8.69141 8.96094C8.75326 8.9349 8.80697 8.89909 8.85254 8.85352C8.89811 8.80794 8.93392 8.75586 8.95996 8.69727C8.986 8.63542 8.99902 8.57031 8.99902 8.50195V1.5C8.99902 1.43164 8.986 1.36816 8.95996 1.30957C8.93392 1.24772 8.89811 1.19401 8.85254 1.14844C8.80697 1.10286 8.75326 1.06706 8.69141 1.04102C8.63281 1.01497 8.56934 1.00195 8.50098 1.00195H1.49902C1.43066 1.00195 1.36556 1.01497 1.30371 1.04102C1.24512 1.06706 1.19303 1.10286 1.14746 1.14844C1.10189 1.19401 1.06608 1.24772 1.04004 1.30957C1.014 1.36816 1.00098 1.43164 1.00098 1.5V8.50195C1.00098 8.57031 1.014 8.63542 1.04004 8.69727C1.06608 8.75586 1.10189 8.80794 1.14746 8.85352C1.19303 8.89909 1.24512 8.9349 1.30371 8.96094C1.36556 8.98698 1.43066 9 1.49902 9H8.50098Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8956"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
maximizeRestoreWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8.99902 2.98096C8.99902 2.71077 8.94531 2.45687 8.83789 2.21924C8.73047 1.97835 8.58398 1.77002 8.39844 1.59424C8.21615 1.4152 8.00293 1.27523 7.75879 1.17432C7.5179 1.07015 7.264 1.01807 6.99707 1.01807H2.08496C2.13704 0.868327 2.21029 0.731608 2.30469 0.60791C2.39909 0.484212 2.50814 0.378418 2.63184 0.290527C2.75553 0.202637 2.89062 0.135905 3.03711 0.090332C3.18685 0.0415039 3.34147 0.0170898 3.50098 0.0170898H6.99707C7.41048 0.0170898 7.79948 0.0968424 8.16406 0.256348C8.52865 0.412598 8.84603 0.625814 9.11621 0.895996C9.38965 1.16618 9.60449 1.48356 9.76074 1.84814C9.92025 2.21273 10 2.60173 10 3.01514V6.51611C10 6.67562 9.97559 6.83024 9.92676 6.97998C9.88118 7.12646 9.81445 7.26156 9.72656 7.38525C9.63867 7.50895 9.53288 7.618 9.40918 7.7124C9.28548 7.8068 9.14876 7.88005 8.99902 7.93213V2.98096ZM1.47461 10.0171C1.2793 10.0171 1.09212 9.97803 0.913086 9.8999C0.734049 9.81852 0.576172 9.7111 0.439453 9.57764C0.30599 9.44092 0.198568 9.28304 0.117188 9.104C0.0390625 8.92497 0 8.73779 0 8.54248V3.49365C0 3.29508 0.0390625 3.10791 0.117188 2.93213C0.198568 2.75309 0.30599 2.59684 0.439453 2.46338C0.576172 2.32666 0.732422 2.21924 0.908203 2.14111C1.08724 2.05973 1.27604 2.01904 1.47461 2.01904H6.52344C6.72201 2.01904 6.91081 2.05973 7.08984 2.14111C7.26888 2.21924 7.42513 2.32503 7.55859 2.4585C7.69206 2.59196 7.79785 2.74821 7.87598 2.92725C7.95736 3.10628 7.99805 3.29508 7.99805 3.49365V8.54248C7.99805 8.74105 7.95736 8.92985 7.87598 9.10889C7.79785 9.28467 7.69043 9.44092 7.55371 9.57764C7.42025 9.7111 7.264 9.81852 7.08496 9.8999C6.90918 9.97803 6.72201 10.0171 6.52344 10.0171H1.47461ZM6.49902 9.01611C6.56738 9.01611 6.63086 9.00309 6.68945 8.97705C6.7513 8.95101 6.80501 8.9152 6.85059 8.86963C6.89941 8.82406 6.93685 8.77197 6.96289 8.71338C6.98893 8.65153 7.00195 8.58643 7.00195 8.51807V3.51807C7.00195 3.44971 6.98893 3.3846 6.96289 3.32275C6.93685 3.2609 6.90104 3.20719 6.85547 3.16162C6.8099 3.11605 6.75618 3.08024 6.69434 3.0542C6.63249 3.02816 6.56738 3.01514 6.49902 3.01514H1.49902C1.43066 3.01514 1.36556 3.02816 1.30371 3.0542C1.24512 3.08024 1.19303 3.11768 1.14746 3.1665C1.10189 3.21208 1.06608 3.26579 1.04004 3.32764C1.014 3.38623 1.00098 3.44971 1.00098 3.51807V8.51807C1.00098 8.58643 1.014 8.65153 1.04004 8.71338C1.06608 8.77197 1.10189 8.82406 1.14746 8.86963C1.19303 8.9152 1.24512 8.95101 1.30371 8.97705C1.36556 9.00309 1.43066 9.01611 1.49902 9.01611H6.49902Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8956"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
closeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5 5.70898L0.854492 9.85449C0.756836 9.95215 0.639648 10.001 0.50293 10.001C0.359701 10.001 0.239258 9.95378 0.141602 9.85938C0.0472005 9.76172 0 9.64128 0 9.49805C0 9.36133 0.0488281 9.24414 0.146484 9.14648L4.29199 5.00098L0.146484 0.855469C0.0488281 0.757812 0 0.638997 0 0.499023C0 0.430664 0.0130208 0.36556 0.0390625 0.303711C0.0651042 0.241862 0.100911 0.189779 0.146484 0.147461C0.192057 0.101888 0.245768 0.0660807 0.307617 0.0400391C0.369466 0.0139974 0.43457 0.000976562 0.50293 0.000976562C0.639648 0.000976562 0.756836 0.0498047 0.854492 0.147461L5 4.29297L9.14551 0.147461C9.24316 0.0498047 9.36198 0.000976562 9.50195 0.000976562C9.57031 0.000976562 9.63379 0.0139974 9.69238 0.0400391C9.75423 0.0660807 9.80794 0.101888 9.85352 0.147461C9.89909 0.193034 9.9349 0.246745 9.96094 0.308594C9.98698 0.367188 10 0.430664 10 0.499023C10 0.638997 9.95117 0.757812 9.85352 0.855469L5.70801 5.00098L9.85352 9.14648C9.95117 9.24414 10 9.36133 10 9.49805C10 9.56641 9.98698 9.63151 9.96094 9.69336C9.9349 9.75521 9.89909 9.80892 9.85352 9.85449C9.8112 9.90007 9.75911 9.93587 9.69727 9.96191C9.63542 9.98796 9.57031 10.001 9.50195 10.001C9.36198 10.001 9.24316 9.95215 9.14551 9.85449L5 5.70898Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8956"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
closeMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="6"
|
||||
height="6"
|
||||
viewBox="0 0 16 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.7522 4.44381L11.1543 9.04165L15.7494 13.6368C16.0898 13.9771 16.078 14.5407 15.724 14.8947L13.8907 16.728C13.5358 17.0829 12.9731 17.0938 12.6328 16.7534L8.03766 12.1583L3.44437 16.7507C3.10402 17.091 2.54132 17.0801 2.18645 16.7253L0.273257 14.8121C-0.0807018 14.4572 -0.0925004 13.8945 0.247845 13.5542L4.84024 8.96087L0.32499 4.44653C-0.0153555 4.10619 -0.00355681 3.54258 0.350402 3.18862L2.18373 1.35529C2.53859 1.00042 3.1013 0.989533 3.44164 1.32988L7.95689 5.84422L12.5556 1.24638C12.8951 0.906035 13.4587 0.917833 13.8126 1.27179L15.7267 3.18589C16.0807 3.53985 16.0925 4.10346 15.7522 4.44381Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
minMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 17 6"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_20_2051)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.47211 1.18042H15.4197C15.8052 1.18042 16.1179 1.50551 16.1179 1.90769V3.73242C16.1179 4.13387 15.8052 4.80006 15.4197 4.80006H1.47211C1.08665 4.80006 0.773926 4.47497 0.773926 4.07278V1.90769C0.773926 1.50551 1.08665 1.18042 1.47211 1.18042Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
fullMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="6"
|
||||
height="6"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_20_2057)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.53068 0.433838L15.0933 12.0409C15.0933 12.0409 15.0658 5.35028 15.0658 4.01784C15.0658 1.32095 14.1813 0.433838 11.5378 0.433838C10.6462 0.433838 3.53068 0.433838 3.53068 0.433838ZM12.4409 15.5378L0.87735 3.93073C0.87735 3.93073 0.905794 10.6214 0.905794 11.9538C0.905794 14.6507 1.79024 15.5378 4.43291 15.5378C5.32535 15.5378 12.4409 15.5378 12.4409 15.5378Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
plusMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_20_2053)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.5308 9.80147H10.3199V15.0095C10.3199 15.3949 9.9941 15.7076 9.59265 15.7076H7.51555C7.11337 15.7076 6.78828 15.3949 6.78828 15.0095V9.80147H1.58319C1.19774 9.80147 0.88501 9.47638 0.88501 9.07419V6.90619C0.88501 6.50401 1.19774 6.17892 1.58319 6.17892H6.78828V1.06183C6.78828 0.676375 7.11337 0.363647 7.51555 0.363647H9.59265C9.9941 0.363647 10.3199 0.676375 10.3199 1.06183V6.17892H15.5308C15.9163 6.17892 16.229 6.50401 16.229 6.90619V9.07419C16.229 9.47638 15.9163 9.80147 15.5308 9.80147Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
};
|
@ -1,115 +0,0 @@
|
||||
import { type Window, getCurrent } from "@tauri-apps/api/window";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import React, { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface AppWindowContextType {
|
||||
appWindow: Window | null;
|
||||
isWindowMaximized: boolean;
|
||||
minimizeWindow: () => Promise<void>;
|
||||
maximizeWindow: () => Promise<void>;
|
||||
fullscreenWindow: () => Promise<void>;
|
||||
closeWindow: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AppWindowContext = createContext<AppWindowContextType>({
|
||||
appWindow: null,
|
||||
isWindowMaximized: false,
|
||||
minimizeWindow: () => Promise.resolve(),
|
||||
maximizeWindow: () => Promise.resolve(),
|
||||
fullscreenWindow: () => Promise.resolve(),
|
||||
closeWindow: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
interface AppWindowProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppWindowProvider: React.FC<AppWindowProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [appWindow, setAppWindow] = useState<Window | null>(null);
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const window = getCurrent();
|
||||
setAppWindow(window);
|
||||
}, []);
|
||||
|
||||
const updateIsWindowMaximized = useCallback(async () => {
|
||||
if (appWindow) {
|
||||
const _isWindowMaximized = await appWindow.isMaximized();
|
||||
setIsWindowMaximized(_isWindowMaximized);
|
||||
}
|
||||
}, [appWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: () => void = () => {};
|
||||
|
||||
async function getOsType() {
|
||||
const osname = await type();
|
||||
|
||||
if (osname !== "macos") {
|
||||
updateIsWindowMaximized();
|
||||
|
||||
const listen = async () => {
|
||||
if (appWindow) {
|
||||
unlisten = await appWindow.onResized(() => {
|
||||
updateIsWindowMaximized();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
listen();
|
||||
}
|
||||
}
|
||||
|
||||
getOsType();
|
||||
|
||||
// Cleanup the listener when the component unmounts
|
||||
return () => unlisten?.();
|
||||
}, [appWindow, updateIsWindowMaximized]);
|
||||
|
||||
const minimizeWindow = async () => {
|
||||
if (appWindow) {
|
||||
await appWindow.minimize();
|
||||
}
|
||||
};
|
||||
|
||||
const maximizeWindow = async () => {
|
||||
if (appWindow) {
|
||||
await appWindow.toggleMaximize();
|
||||
}
|
||||
};
|
||||
|
||||
const fullscreenWindow = async () => {
|
||||
if (appWindow) {
|
||||
const fullscreen = await appWindow.isFullscreen();
|
||||
if (fullscreen) {
|
||||
await appWindow.setFullscreen(false);
|
||||
} else {
|
||||
await appWindow.setFullscreen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeWindow = async () => {
|
||||
if (appWindow) {
|
||||
await appWindow.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppWindowContext.Provider
|
||||
value={{
|
||||
appWindow,
|
||||
isWindowMaximized,
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
fullscreenWindow,
|
||||
closeWindow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppWindowContext.Provider>
|
||||
);
|
||||
};
|
@ -1,40 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { HTMLProps, useContext } from "react";
|
||||
import { WindowButton } from "../components/button";
|
||||
import { WindowIcons } from "../components/icons";
|
||||
import { AppWindowContext } from "../context";
|
||||
|
||||
export function Gnome({ className, ...props }: HTMLProps<HTMLDivElement>) {
|
||||
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
|
||||
useContext(AppWindowContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mr-[10px] h-auto items-center space-x-[13px]", className)}
|
||||
{...props}
|
||||
>
|
||||
<WindowButton
|
||||
onClick={minimizeWindow}
|
||||
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
|
||||
>
|
||||
<WindowIcons.minimizeWin className="h-[9px] w-[9px]" />
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
onClick={maximizeWindow}
|
||||
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
|
||||
>
|
||||
{!isWindowMaximized ? (
|
||||
<WindowIcons.maximizeWin className="h-2 w-2" />
|
||||
) : (
|
||||
<WindowIcons.maximizeRestoreWin className="h-[9px] w-[9px]" />
|
||||
)}
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
onClick={closeWindow}
|
||||
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
|
||||
>
|
||||
<WindowIcons.closeWin className="h-2 w-2" />
|
||||
</WindowButton>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { HTMLProps, useContext, useEffect, useState } from "react";
|
||||
import { WindowButton } from "../components/button";
|
||||
import { WindowIcons } from "../components/icons";
|
||||
import { AppWindowContext } from "../context";
|
||||
|
||||
export function MacOS({ className, ...props }: HTMLProps<HTMLDivElement>) {
|
||||
const { minimizeWindow, maximizeWindow, fullscreenWindow, closeWindow } =
|
||||
useContext(AppWindowContext);
|
||||
|
||||
const [isAltKeyPressed, setIsAltKeyPressed] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const last = isAltKeyPressed ? (
|
||||
<WindowIcons.plusMac />
|
||||
) : (
|
||||
<WindowIcons.fullMac />
|
||||
);
|
||||
const key = "Alt";
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovering(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovering(false);
|
||||
};
|
||||
|
||||
const handleAltKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === key) {
|
||||
setIsAltKeyPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAltKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === key) {
|
||||
setIsAltKeyPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Attach event listeners when the component mounts
|
||||
window.addEventListener("keydown", handleAltKeyDown);
|
||||
window.addEventListener("keyup", handleAltKeyUp);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-x-2 px-3 text-black active:text-black dark:text-black",
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<WindowButton
|
||||
onClick={closeWindow}
|
||||
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ff544d] text-center text-black/60 hover:bg-[#ff544d] active:bg-[#bf403a] active:text-black/60 dark:border-none"
|
||||
>
|
||||
{isHovering && <WindowIcons.closeMac />}
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
onClick={minimizeWindow}
|
||||
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ffbd2e] text-center text-black/60 hover:bg-[#ffbd2e] active:bg-[#bf9122] active:text-black/60 dark:border-none"
|
||||
>
|
||||
{isHovering && <WindowIcons.minMac />}
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
// onKeyDown={handleAltKeyDown}
|
||||
// onKeyUp={handleAltKeyUp}
|
||||
onClick={isAltKeyPressed ? maximizeWindow : fullscreenWindow}
|
||||
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#28c93f] text-center text-black/60 hover:bg-[#28c93f] active:bg-[#1e9930] active:text-black/60 dark:border-none"
|
||||
>
|
||||
{isHovering && last}
|
||||
</WindowButton>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { HTMLProps, useContext } from "react";
|
||||
import { WindowButton } from "../components/button";
|
||||
import { WindowIcons } from "../components/icons";
|
||||
import { AppWindowContext } from "../context";
|
||||
|
||||
export function Windows({ className, ...props }: HTMLProps<HTMLDivElement>) {
|
||||
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
|
||||
useContext(AppWindowContext);
|
||||
|
||||
return (
|
||||
<div className={cn("h-8", className)} {...props}>
|
||||
<WindowButton
|
||||
onClick={minimizeWindow}
|
||||
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]"
|
||||
>
|
||||
<WindowIcons.minimizeWin />
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
onClick={maximizeWindow}
|
||||
className={cn(
|
||||
"max-h-8 w-[46px] cursor-default rounded-none bg-transparent",
|
||||
"text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]",
|
||||
// !isMaximizable && "text-white/[.36]",
|
||||
)}
|
||||
>
|
||||
{!isWindowMaximized ? (
|
||||
<WindowIcons.maximizeWin />
|
||||
) : (
|
||||
<WindowIcons.maximizeRestoreWin />
|
||||
)}
|
||||
</WindowButton>
|
||||
<WindowButton
|
||||
onClick={closeWindow}
|
||||
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-[#c42b1c] hover:text-white active:bg-[#c42b1c]/90 dark:text-white"
|
||||
>
|
||||
<WindowIcons.closeWin />
|
||||
</WindowButton>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
export * from './context';
|
||||
export * from './components/button';
|
||||
export * from './components/icons';
|
||||
export * from './controls/gnome';
|
||||
export * from './controls/windows';
|
||||
export * from './controls/macos';
|
||||
export * from './titleBar';
|
@ -1,31 +0,0 @@
|
||||
import { Platform } from "@tauri-apps/plugin-os";
|
||||
import { AppWindowProvider } from "./context";
|
||||
import { Gnome } from "./controls/gnome";
|
||||
import { MacOS } from "./controls/macos";
|
||||
import { Windows } from "./controls/windows";
|
||||
|
||||
export function WindowTitleBar({ platform }: { platform: Platform }) {
|
||||
const ControlsComponent = () => {
|
||||
switch (platform) {
|
||||
case "windows":
|
||||
return <Windows className="ml-auto flex" />;
|
||||
case "macos":
|
||||
return <MacOS className="ml-0 flex" />;
|
||||
case "linux":
|
||||
return <Gnome className="ml-auto flex" />;
|
||||
default:
|
||||
return <Windows className="ml-auto flex" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppWindowProvider>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="bg-background flex select-none flex-row overflow-hidden"
|
||||
>
|
||||
<ControlsComponent />
|
||||
</div>
|
||||
</AppWindowProvider>
|
||||
);
|
||||
}
|
@ -1,604 +0,0 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import { displayNpub, formatCreatedAt } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
export const User = memo(function User({
|
||||
pubkey,
|
||||
time,
|
||||
variant = "default",
|
||||
subtext,
|
||||
}: {
|
||||
pubkey: string;
|
||||
time?: number;
|
||||
variant?:
|
||||
| "default"
|
||||
| "simple"
|
||||
| "mention"
|
||||
| "notify"
|
||||
| "notify2"
|
||||
| "repost"
|
||||
| "chat"
|
||||
| "large"
|
||||
| "thread"
|
||||
| "miniavatar"
|
||||
| "avatar"
|
||||
| "stacked"
|
||||
| "ministacked"
|
||||
| "childnote";
|
||||
subtext?: string;
|
||||
}) {
|
||||
const { isLoading, user } = useProfile(pubkey);
|
||||
|
||||
const createdAt = useMemo(
|
||||
() => formatCreatedAt(time, variant === "chat"),
|
||||
[time, variant],
|
||||
);
|
||||
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
||||
const fallbackAvatar = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(pubkey, 90, 50),
|
||||
)}`,
|
||||
[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="w-6 h-6 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Root>
|
||||
<div className="flex items-baseline flex-1 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 items-center h-6 gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-6 h-6 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-6 h-6 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex items-baseline flex-1 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 === "notify2") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="w-8 h-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-8 h-8 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{fallbackName}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="w-8 h-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="w-8 h-8 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-8 h-8 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<h5 className="max-w-[8rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
fallbackName}
|
||||
</h5>
|
||||
<p>{subtext}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "notify") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="w-8 h-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-8 h-8 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{fallbackName}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="w-8 h-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="w-8 h-8 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-8 h-8 bg-black rounded-md dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name || user?.display_name || fallbackName}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "large") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg h-14 w-14 shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-20 bg-gray-200 rounded-t-lg dark:bg-gray-800">
|
||||
{user?.banner ? (
|
||||
<img
|
||||
src={user.banner}
|
||||
alt="banner"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col gap-2.5 px-3 -mt-6">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture}
|
||||
alt={pubkey}
|
||||
decoding="async"
|
||||
className="object-cover rounded-lg size-11"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="bg-black rounded-lg size-11 dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-col items-start text-start">
|
||||
<p className="max-w-[15rem] truncate text-lg font-semibold leadning-tight">
|
||||
{user?.name || user?.display_name}
|
||||
</p>
|
||||
<p className="whitespace-pre-line select-text break-p text-neutral-700 dark:text-neutral-600 max-w-none">
|
||||
{user?.about || "No bio"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "simple") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col items-start w-full gap-1">
|
||||
<div className="h-4 rounded w-36 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar.Root className="w-10 h-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="object-cover w-10 h-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-10 h-10 bg-black rounded-lg dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<h3 className="max-w-[15rem] truncate text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name || user?.display_name || user?.displayName}
|
||||
</h3>
|
||||
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">
|
||||
{user?.nip05 || user?.username || fallbackName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "avatar") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-lg animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-12 h-12 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-12 h-12 bg-black rounded-lg dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "miniavatar") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar.Root className="w-10 h-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-10 h-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-10 h-10 bg-black rounded-lg dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "childnote") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Avatar.Root className="w-10 h-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="object-cover w-10 h-10 bg-black rounded-lg 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="w-10 h-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="object-cover w-10 h-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-10 h-10 bg-black rounded-lg 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "stacked") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="inline-block w-8 h-8 rounded-full animate-pulse bg-neutral-300 ring-1 ring-neutral-200 dark:bg-neutral-700 dark:ring-neutral-800" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="inline-block w-8 h-8 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-800"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="inline-block w-8 h-8 bg-black rounded-full ring-1 ring-neutral-200 dark:bg-white dark:ring-neutral-800"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "ministacked") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="inline-block w-6 h-6 rounded-full animate-pulse bg-neutral-300 ring-1 ring-white dark:ring-black" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="inline-block w-6 h-6 rounded-full ring-1 ring-white dark:ring-black"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="inline-block w-6 h-6 bg-black rounded-full ring-1 ring-white dark:bg-white dark:ring-black"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "repost") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10">
|
||||
<RepostIcon className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-3">
|
||||
<div className="inline-flex items-center justify-center w-10">
|
||||
<RepostIcon className="w-5 h-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="object-cover w-6 h-6 rounded"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-6 h-6 bg-black rounded 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 (variant === "thread") {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center h-16 gap-3 px-3">
|
||||
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
<div className="h-4 rounded w-36 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="w-24 h-3 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-16 gap-3 px-3">
|
||||
<Avatar.Root className="w-10 h-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="object-cover w-10 h-10 rounded-lg ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="w-10 h-10 bg-black rounded-lg ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-col flex-1">
|
||||
<h5 className="max-w-[15rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name || user?.display_name || user?.displayName || "Anon"}
|
||||
</h5>
|
||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<span>{createdAt}</span>
|
||||
<span>·</span>
|
||||
<span>{fallbackName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<Avatar.Root className="h-9 w-9 shrink-0">
|
||||
<Avatar.Image
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="bg-black rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
|
||||
/>
|
||||
</Avatar.Root>
|
||||
<div className="flex-1 h-6">
|
||||
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
|
||||
{fallbackName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<Avatar.Root className="h-9 w-9 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="object-cover bg-white rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="bg-black rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex items-start flex-1 h-6 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="inline-flex items-center gap-3 ml-auto">
|
||||
<div className="text-neutral-500 dark:text-neutral-400">
|
||||
{createdAt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
62
packages/ui/src/user/followButton.tsx
Normal file
62
packages/ui/src/user/followButton.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
45
packages/ui/src/user/provider.tsx
Normal file
45
packages/ui/src/user/provider.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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;
|
||||
try {
|
||||
const profile: Metadata = await invoke("get_profile", { id: pubkey });
|
||||
return profile;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@ -292,6 +292,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(react-dom@18.2.0)(react@18.2.0)
|
||||
virtua:
|
||||
specifier: ^0.23.3
|
||||
version: 0.23.3(react-dom@18.2.0)(react@18.2.0)
|
||||
devDependencies:
|
||||
'@lume/tailwindcss':
|
||||
specifier: workspace:^
|
||||
@ -1057,6 +1060,9 @@ importers:
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@getalby/sdk':
|
||||
specifier: ^3.2.3
|
||||
version: 3.2.3(typescript@5.3.3)
|
||||
'@lume/ark':
|
||||
specifier: workspace:^
|
||||
version: link:../ark
|
||||
@ -1081,6 +1087,9 @@ importers:
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -1093,6 +1102,9 @@ importers:
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.18.1
|
||||
version: 5.18.1(react@18.2.0)
|
||||
@ -1102,15 +1114,33 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3(react-dom@18.2.0)(react@18.2.0)
|
||||
get-urls:
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0
|
||||
jotai:
|
||||
specifier: ^2.6.4
|
||||
version: 2.6.4(@types/react@18.2.52)(react@18.2.0)
|
||||
media-chrome:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
minidenticons:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
nanoid:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
qrcode.react:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react@18.2.0)
|
||||
re-resizable:
|
||||
specifier: ^6.9.11
|
||||
version: 6.9.11(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-currency-input-field:
|
||||
specifier: ^3.6.14
|
||||
version: 3.6.14(react@18.2.0)
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
@ -1126,6 +1156,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.22.0
|
||||
version: 6.22.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react-string-replace:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
slate:
|
||||
specifier: ^0.101.5
|
||||
version: 0.101.5
|
||||
@ -1135,6 +1168,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(react-dom@18.2.0)(react@18.2.0)
|
||||
string-strip-html:
|
||||
specifier: ^13.4.6
|
||||
version: 13.4.6
|
||||
uqr:
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
|
Loading…
x
Reference in New Issue
Block a user