wip: tired...

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

View File

@@ -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:^",

View File

@@ -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,20 +20,24 @@ 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" : "",
)}
>
<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-black" : "",
isActive ? "bg-white shadow dark:bg-neutral-950" : "",
)}
>
{isActive ? (
@@ -49,7 +54,7 @@ function App() {
<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 ? "bg-white shadow dark:bg-neutral-950" : "",
)}
>
{isActive ? (
@@ -66,7 +71,7 @@ function App() {
<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 ? "bg-white shadow dark:bg-neutral-950" : "",
)}
>
{isActive ? (
@@ -79,6 +84,10 @@ function App() {
)}
</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">
<Outlet />

View File

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

View File

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

View File

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

View File

@@ -1,122 +0,0 @@
import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { useEvent } from "../../hooks/useEvent";
import { User } from "../user";
import { Hashtag } from "./mentions/hashtag";
import { MentionUser } from "./mentions/user";
export function NoteChild({
eventId,
isRoot,
}: { eventId: string; isRoot?: boolean }) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent as string;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
{t("note.error")}
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" />
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{richContent}
</div>
</div>
<User.Provider pubkey={data.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<User.Name className="max-w-[10rem] truncate" />
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{isRoot ? t("note.posted") : t("note.replied")}:
</div>
</div>
</User.Root>
</User.Provider>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
import { RepostIcon } from "@lume/icons";
import { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { useArk } from "../../../provider";
import { User } from "../../user";
export function RepostNote({
event,
className,
}: { event: Event; className?: string }) {
const ark = useArk();
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as Event;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isError || !repostEvent) {
return (
<Note.Root className={className}>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-2 px-3 h-14">
<div className="inline-flex shrink-0 w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="px-3 mb-3 select-text">
<div className="flex flex-col items-start justify-start px-3 py-3 bg-red-100 rounded-lg dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-2 px-3 h-14">
<div className="inline-flex shrink-0 w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="relative flex flex-col gap-2 px-3">
<div className="flex items-center justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Content />
<div className="flex items-center justify-between h-14">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
}

View File

@@ -1,43 +0,0 @@
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { User } from "../../user";
export function ThreadNote({ eventId }: { eventId: string }) {
const { isLoading, data } = useEvent(eventId);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Note.Provider event={data}>
<Note.Root className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex items-center justify-between px-3 h-16">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-16 items-center gap-3 flex-1">
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-1 flex-col">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<User.Time time={data.created_at} />
<span>·</span>
<User.NIP05 />
</div>
</div>
</User.Root>
</User.Provider>
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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";

View File

@@ -4,9 +4,9 @@ import { WindowVirtualizer } from "virtua";
export function HomeRoute({ id }: { id: string }) {
return (
<div className="pb-5 overflow-y-auto">
<div className="overflow-y-auto pb-5">
<WindowVirtualizer>
<div className="px-3 mt-3">
<div className="mt-3 px-3">
<ThreadNote eventId={id} />
<ReplyList eventId={id} className="mt-5" />
</div>

View File

@@ -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"

View File

@@ -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.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-full rounded-xl object-cover"
className="aspect-square h-auto w-7 rounded-lg 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"
className="aspect-square h-auto w-7 rounded-full 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",
)}
/>
</div>
</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>

View File

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

View File

@@ -1,9 +1,7 @@
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";
@@ -34,6 +32,7 @@ import {
insertNostrEvent,
isImageUrl,
} from "./utils";
import { MentionNote } from "../note/mentions/note";
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
@@ -113,7 +112,7 @@ const Image = ({ attributes, children, element }) => {
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",
"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}
@@ -122,7 +121,7 @@ const Image = ({ attributes, children, element }) => {
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"
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>
@@ -141,7 +140,7 @@ const Mention = ({ attributes, element }) => {
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
@@ -157,7 +156,7 @@ const Event = ({ attributes, element, children }) => {
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="relative user-select-none my-2"
className="user-select-none relative my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
@@ -279,7 +278,7 @@ export function EditorForm() {
}, [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">
<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}
@@ -310,7 +309,7 @@ export function EditorForm() {
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 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>
@@ -318,11 +317,11 @@ export function EditorForm() {
<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" />
<div className="mx-3 h-6 w-px 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"
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" />
@@ -332,7 +331,7 @@ export function EditorForm() {
</button>
</div>
</div>
<div className="py-6 h-full overflow-y-auto px-7">
<div className="h-full overflow-y-auto px-7 py-6">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
@@ -347,7 +346,7 @@ export function EditorForm() {
<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"
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
@@ -358,11 +357,11 @@ export function EditorForm() {
insertMention(editor, contact);
setTarget(null);
}}
className="p-2 flex flex-col w-full rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
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="w-full flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-lg object-cover shrink-0" />
<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>

View File

@@ -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";
@@ -32,6 +31,9 @@ import {
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;
@@ -111,7 +113,7 @@ const Image = ({ attributes, children, element }) => {
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",
"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}
@@ -120,7 +122,7 @@ const Image = ({ attributes, children, element }) => {
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"
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>
@@ -139,7 +141,7 @@ const Mention = ({ attributes, element }) => {
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
@@ -155,7 +157,7 @@ const Event = ({ attributes, element, children }) => {
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="relative user-select-none"
className="user-select-none relative"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
@@ -188,7 +190,10 @@ const Element = (props) => {
export function ReplyForm({
eventId,
className,
}: { eventId: string; className?: string }) {
}: {
eventId: string;
className?: string;
}) {
const ark = useArk();
const storage = useStorage();
const ref = useRef<HTMLDivElement | null>();
@@ -329,7 +334,7 @@ export function ReplyForm({
setTarget(null);
}}
>
<div className="overflow-y-auto p-3 bg-neutral-100 dark:bg-neutral-900 rounded-xl">
<div className="overflow-y-auto rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={false}
@@ -338,13 +343,13 @@ export function ReplyForm({
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.replyPlaceholder")}
className="focus:outline-none h-28"
className="h-28 focus:outline-none"
/>
{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"
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>
@@ -355,11 +360,11 @@ export function ReplyForm({
insertMention(editor, contact);
setTarget(null);
}}
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
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 rounded-lg object-cover shrink-0" />
<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>
@@ -371,17 +376,17 @@ export function ReplyForm({
</Portal>
)}
</div>
<div className="mt-3 flex items-center justify-between shrink-0">
<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="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
<div className="mx-3 h-6 w-px 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"
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" />

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,16 +1,19 @@
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 }) {
}: {
eventId: string;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
@@ -60,7 +63,7 @@ export function ReplyList({
>
<ReplyForm
eventId={eventId}
className="py-4 border-t border-neutral-100 dark:border-neutral-900"
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">

View File

@@ -1,27 +1,27 @@
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();
return (
<div className="pb-5 overflow-y-auto">
<div className="overflow-y-auto pb-5">
<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">
<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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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" />

View File

@@ -1,10 +1,10 @@
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",
@@ -53,27 +53,27 @@ export function SuggestRoute({ queryKey }: { queryKey: string }) {
};
return (
<div className="pb-5 overflow-y-auto">
<div className="overflow-y-auto pb-5">
<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">
<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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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 items-center h-16">
<h3 className="font-semibold text-xl">{t("suggestion.title")}</h3>
<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 ? (
@@ -88,7 +88,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string }) {
data?.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="py-5 h-max w-full overflow-hidden"
className="h-max w-full overflow-hidden py-5"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
@@ -96,14 +96,14 @@ export function SuggestRoute({ queryKey }: { queryKey: string }) {
<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" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</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"
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 text-neutral-800 dark:text-neutral-400 max-w-none select-text" />
<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>
@@ -111,11 +111,11 @@ export function SuggestRoute({ queryKey }: { queryKey: string }) {
))
)}
</div>
<div className="sticky z-10 flex items-center justify-center w-full bottom-0">
<div className="sticky bottom-0 z-10 flex w-full items-center justify-center">
<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"
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>

View File

@@ -1,4 +1,4 @@
import { RepostNote, TextNote, User, useArk } from "@lume/ark";
import { useArk } from "@lume/ark";
import {
ArrowLeftIcon,
ArrowRightCircleIcon,
@@ -12,6 +12,7 @@ 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();
@@ -67,19 +68,19 @@ export function UserRoute() {
};
return (
<div className="pb-5 overflow-y-auto">
<div className="overflow-y-auto pb-5">
<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">
<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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
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" />
@@ -92,7 +93,7 @@ export function UserRoute() {
<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"
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">
@@ -107,7 +108,7 @@ export function UserRoute() {
</div>
</User.Root>
</User.Provider>
<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
<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>
@@ -125,7 +126,7 @@ export function UserRoute() {
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"
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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

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

View 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
View File

@@ -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