diff --git a/apps/desktop2/package.json b/apps/desktop2/package.json
index 473629b6..1e2b0d9b 100644
--- a/apps/desktop2/package.json
+++ b/apps/desktop2/package.json
@@ -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:^",
diff --git a/apps/desktop2/src/routes/app.tsx b/apps/desktop2/src/routes/app.tsx
index f6e80b05..22088838 100644
--- a/apps/desktop2/src/routes/app.tsx
+++ b/apps/desktop2/src/routes/app.tsx
@@ -10,6 +10,7 @@ import { useStorage } from "@lume/storage";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { cn } from "@lume/utils";
+import { ActiveAccount } from "@lume/ui";
export const Route = createFileRoute("/app")({
component: App,
@@ -19,65 +20,73 @@ function App() {
const storage = useStorage();
return (
-
+
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
- Home
-
- )}
-
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
- Space
-
- )}
-
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
- Activity
-
- )}
-
+
+
+ {({ isActive }) => (
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+ Home
+
+ )}
+
+
+ {({ isActive }) => (
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+ Space
+
+ )}
+
+
+ {({ isActive }) => (
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+ Activity
+
+ )}
+
+
+
diff --git a/apps/desktop2/src/routes/app/home.lazy.tsx b/apps/desktop2/src/routes/app/home.lazy.tsx
index 03a36f31..75dd53ae 100644
--- a/apps/desktop2/src/routes/app/home.lazy.tsx
+++ b/apps/desktop2/src/routes/app/home.lazy.tsx
@@ -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
();
+ 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 {event.content}
;
+ case Kind.Repost:
+ return {event.content}
;
+ default:
+ return {event.content}
;
+ }
+ };
+
+ 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 (
-
Home
+
+
+ {isLoading ? (
+
+
+
+ ) : !data.length ? (
+
+ ) : (
+ data.map((item) => renderItem(item))
+ )}
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
);
}
diff --git a/packages/ark/src/components/note/appHandler.tsx b/packages/ark/src/components/note/appHandler.tsx
deleted file mode 100644
index d0e5dd90..00000000
--- a/packages/ark/src/components/note/appHandler.tsx
+++ /dev/null
@@ -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) {
- Loading...
;
- }
-
- if (isError || !data) {
- return Error
;
- }
-
- return (
-
-

-
-
- {data.name}
-
-
- {data.about}
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/buttons/zap.tsx b/packages/ark/src/components/note/buttons/zap.tsx
deleted file mode 100644
index c27e3505..00000000
--- a/packages/ark/src/components/note/buttons/zap.tsx
+++ /dev/null
@@ -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("21");
- const [zapMessage, setZapMessage] = useState("");
- const [isOpen, setIsOpen] = useState(false);
- const [isCompleted, setIsCompleted] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
- const [invoice, setInvoice] = useState(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 (
-
-
-
-
-
-
-
- {t("note.zap.tooltip")}
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {t("note.zap.tooltip")}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("note.zap.modalTitle")}{" "}
- {user?.name ||
- user?.displayName ||
- displayNpub(event.pubkey, 16)}
-
-
- {!invoice ? (
-
-
-
- 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"
- />
-
- sats
-
-
-
-
-
-
-
-
-
-
-
-
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"
- />
-
-
-
-
-
- ) : (
-
-
-
-
-
-
- {t("note.zap.invoiceButton")}
-
-
- {t("note.zap.invoiceFooter")}
-
-
-
- )}
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/child.tsx b/packages/ark/src/components/note/child.tsx
deleted file mode 100644
index ef839800..00000000
--- a/packages/ark/src/components/note/child.tsx
+++ /dev/null
@@ -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 ;
- });
- }
- }
-
- if (mentions.length) {
- for (const mention of mentions) {
- parsedContent = reactStringReplace(
- parsedContent,
- mention,
- (match, i) => ,
- );
- }
- }
-
- parsedContent = reactStringReplace(
- parsedContent,
- /(https?:\/\/\S+)/g,
- (match, i) => {
- const url = new URL(match);
- return (
-
- {url.toString()}
-
- );
- },
- );
-
- return parsedContent;
- } catch (e) {
- console.log(e);
- return parsedContent;
- }
- }, [data]);
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (isError || !data) {
- return (
-
-
- {t("note.error")}
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- {isRoot ? t("note.posted") : t("note.replied")}:
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/mentions/note.tsx b/packages/ark/src/components/note/mentions/note.tsx
deleted file mode 100644
index 4a108997..00000000
--- a/packages/ark/src/components/note/mentions/note.tsx
+++ /dev/null
@@ -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 ;
- },
- );
- }
- }
-
- if (mentions.length) {
- for (const mention of mentions) {
- parsedContent = reactStringReplace(
- parsedContent,
- mention,
- (match, i) => ,
- );
- }
- }
-
- parsedContent = reactStringReplace(
- parsedContent,
- /(https?:\/\/\S+)/g,
- (match, i) => {
- const url = new URL(match);
- return (
-
- {url.toString()}
-
- );
- },
- );
-
- return parsedContent;
- } catch (e) {
- console.log(e);
- return parsedContent;
- }
- }, [data]);
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (isError || !data) {
- return (
-
- {t("note.error")}
-
- );
- }
-
- return (
-
-
-
-
-
-
- ·
-
-
-
-
-
- {richContent}
-
- {openable ? (
-
-
- {t("note.showMore")}
-
-
-
- ) : (
-
- )}
-
- );
-}
diff --git a/packages/ark/src/components/note/mentions/user.tsx b/packages/ark/src/components/note/mentions/user.tsx
deleted file mode 100644
index e18d0e1a..00000000
--- a/packages/ark/src/components/note/mentions/user.tsx
+++ /dev/null
@@ -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 (
-
-
- {isLoading
- ? "@anon"
- : isError
- ? pubkey
- : `@${user?.name || user?.display_name || user?.name || "anon"}`}
-
-
-
-
- {t("note.buttons.viewProfile")}
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/menu.tsx b/packages/ark/src/components/note/menu.tsx
deleted file mode 100644
index 6bd531c8..00000000
--- a/packages/ark/src/components/note/menu.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("note.menu.viewAuthor")}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/nip89.tsx b/packages/ark/src/components/note/nip89.tsx
deleted file mode 100644
index c58037f4..00000000
--- a/packages/ark/src/components/note/nip89.tsx
+++ /dev/null
@@ -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) {
- Loading...
;
- }
-
- if (isError || !data) {
- return Error
;
- }
-
- return (
-
-
-
-
- {t("nip89.unsupported")}
-
-
- {event.kind}
-
-
-
-
- {t("nip89.openWith")}
-
- {data.map((item) => (
-
- ))}
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/primitives/repost.tsx b/packages/ark/src/components/note/primitives/repost.tsx
deleted file mode 100644
index a7fe66b3..00000000
--- a/packages/ark/src/components/note/primitives/repost.tsx
+++ /dev/null
@@ -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 Loading...
;
- }
-
- if (isError || !repostEvent) {
- return (
-
-
-
-
-
-
-
-
-
-
- {t("note.reposted")}
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {t("note.reposted")}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/primitives/thread.tsx b/packages/ark/src/components/note/primitives/thread.tsx
deleted file mode 100644
index 6d6a4ea3..00000000
--- a/packages/ark/src/components/note/primitives/thread.tsx
+++ /dev/null
@@ -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 Loading...
;
- }
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/thread.tsx b/packages/ark/src/components/note/thread.tsx
deleted file mode 100644
index e4faeb00..00000000
--- a/packages/ark/src/components/note/thread.tsx
+++ /dev/null
@@ -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 (
-
-
- {thread.rootEventId ? (
-
- ) : null}
- {thread.replyEventId ? (
-
- ) : null}
-
-
- {t("note.showThread")}
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/note/user.tsx b/packages/ark/src/components/note/user.tsx
deleted file mode 100644
index 4518138b..00000000
--- a/packages/ark/src/components/note/user.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- View profile
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ark/src/components/user/followButton.tsx b/packages/ark/src/components/user/followButton.tsx
deleted file mode 100644
index 082ac77b..00000000
--- a/packages/ark/src/components/user/followButton.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/packages/ark/src/components/user/provider.tsx b/packages/ark/src/components/user/provider.tsx
deleted file mode 100644
index 8f259a26..00000000
--- a/packages/ark/src/components/user/provider.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
-
-export function useUserContext() {
- const context = useContext(UserContext);
- return context;
-}
diff --git a/packages/ark/src/index.ts b/packages/ark/src/index.ts
index 9e3cc694..3f712077 100644
--- a/packages/ark/src/index.ts
+++ b/packages/ark/src/index.ts
@@ -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";
diff --git a/packages/lume-column-thread/src/home.tsx b/packages/lume-column-thread/src/home.tsx
index 0de9041f..6587e4bb 100644
--- a/packages/lume-column-thread/src/home.tsx
+++ b/packages/lume-column-thread/src/home.tsx
@@ -3,14 +3,14 @@ import { ReplyList } from "@lume/ui";
import { WindowVirtualizer } from "virtua";
export function HomeRoute({ id }: { id: string }) {
- return (
-
- );
+ return (
+
+ );
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a595b253..4acfda27 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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"
diff --git a/packages/ui/src/account/active.tsx b/packages/ui/src/account/active.tsx
index d261ee81..61512b72 100644
--- a/packages/ui/src/account/active.tsx
+++ b/packages/ui/src/account/active.tsx
@@ -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 (
-
-
-
-
-
-
-
-
+
-
+
+
+
+
-
{t("user.editProfile")}
-
+
-
{t("user.settings")}
-
+
-
+
diff --git a/packages/ui/src/account/logout.tsx b/packages/ui/src/account/logoutDialog.tsx
similarity index 98%
rename from packages/ui/src/account/logout.tsx
rename to packages/ui/src/account/logoutDialog.tsx
index 50e6b2f4..d016a679 100644
--- a/packages/ui/src/account/logout.tsx
+++ b/packages/ui/src/account/logoutDialog.tsx
@@ -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();
diff --git a/packages/ark/src/components/column/content.tsx b/packages/ui/src/column/content.tsx
similarity index 100%
rename from packages/ark/src/components/column/content.tsx
rename to packages/ui/src/column/content.tsx
diff --git a/packages/ark/src/components/column/header.tsx b/packages/ui/src/column/header.tsx
similarity index 100%
rename from packages/ark/src/components/column/header.tsx
rename to packages/ui/src/column/header.tsx
diff --git a/packages/ark/src/components/column/index.ts b/packages/ui/src/column/index.ts
similarity index 100%
rename from packages/ark/src/components/column/index.ts
rename to packages/ui/src/column/index.ts
diff --git a/packages/ark/src/components/column/live.tsx b/packages/ui/src/column/live.tsx
similarity index 100%
rename from packages/ark/src/components/column/live.tsx
rename to packages/ui/src/column/live.tsx
diff --git a/packages/ark/src/components/column/provider.tsx b/packages/ui/src/column/provider.tsx
similarity index 100%
rename from packages/ark/src/components/column/provider.tsx
rename to packages/ui/src/column/provider.tsx
diff --git a/packages/ark/src/components/column/root.tsx b/packages/ui/src/column/root.tsx
similarity index 100%
rename from packages/ark/src/components/column/root.tsx
rename to packages/ui/src/column/root.tsx
diff --git a/packages/ui/src/editor/form.tsx b/packages/ui/src/editor/form.tsx
index 7e4b482c..f75de83d 100644
--- a/packages/ui/src/editor/form.tsx
+++ b/packages/ui/src/editor/form.tsx
@@ -1,263 +1,262 @@
-import { MentionNote, User, useArk, useColumnContext } from "@lume/ark";
+import { useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
-import { NDKCacheUserProfile } from "@lume/types";
-import { COL_TYPES, cn, editorValueAtom } from "@lume/utils";
-import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
+import { cn, editorValueAtom } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { useAtom } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
- Descendant,
- Editor,
- Node,
- Range,
- Transforms,
- createEditor,
+ Descendant,
+ Editor,
+ Node,
+ Range,
+ Transforms,
+ createEditor,
} from "slate";
import {
- Editable,
- ReactEditor,
- Slate,
- useFocused,
- useSelected,
- useSlateStatic,
- withReact,
+ Editable,
+ ReactEditor,
+ Slate,
+ useFocused,
+ useSelected,
+ useSlateStatic,
+ withReact,
} from "slate-react";
import { toast } from "sonner";
import { EditorAddMedia } from "./addMedia";
import {
- Portal,
- insertImage,
- insertMention,
- insertNostrEvent,
- isImageUrl,
+ Portal,
+ insertImage,
+ insertMention,
+ insertNostrEvent,
+ isImageUrl,
} from "./utils";
+import { MentionNote } from "../note/mentions/note";
const withNostrEvent = (editor: ReactEditor) => {
- const { insertData, isVoid } = editor;
+ const { insertData, isVoid } = editor;
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "event" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "event" ? true : isVoid(element);
+ };
- editor.insertData = (data) => {
- const text = data.getData("text/plain");
+ editor.insertData = (data) => {
+ const text = data.getData("text/plain");
- if (text.startsWith("nevent1") || text.startsWith("note1")) {
- insertNostrEvent(editor, text);
- } else {
- insertData(data);
- }
- };
+ if (text.startsWith("nevent1") || text.startsWith("note1")) {
+ insertNostrEvent(editor, text);
+ } else {
+ insertData(data);
+ }
+ };
- return editor;
+ return editor;
};
const withMentions = (editor: ReactEditor) => {
- const { isInline, isVoid, markableVoid } = editor;
+ const { isInline, isVoid, markableVoid } = editor;
- editor.isInline = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" ? true : isInline(element);
- };
+ editor.isInline = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" ? true : isInline(element);
+ };
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" ? true : isVoid(element);
+ };
- editor.markableVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" || markableVoid(element);
- };
+ editor.markableVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" || markableVoid(element);
+ };
- return editor;
+ return editor;
};
const withImages = (editor: ReactEditor) => {
- const { insertData, isVoid } = editor;
+ const { insertData, isVoid } = editor;
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "image" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "image" ? true : isVoid(element);
+ };
- editor.insertData = (data) => {
- const text = data.getData("text/plain");
+ editor.insertData = (data) => {
+ const text = data.getData("text/plain");
- if (isImageUrl(text)) {
- insertImage(editor, text);
- } else {
- insertData(data);
- }
- };
+ if (isImageUrl(text)) {
+ insertImage(editor, text);
+ } else {
+ insertData(data);
+ }
+ };
- return editor;
+ return editor;
};
const Image = ({ attributes, children, element }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- const selected = useSelected();
- const focused = useFocused();
+ const selected = useSelected();
+ const focused = useFocused();
- return (
-
- {children}
-
-

-
-
-
- );
+ return (
+
+ {children}
+
+

+
+
+
+ );
};
const Mention = ({ attributes, element }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- return (
- Transforms.removeNodes(editor, { at: path })}
- className="inline-block text-blue-500 align-baseline hover:text-blue-600"
- >{`@${element.name}`}
- );
+ return (
+ Transforms.removeNodes(editor, { at: path })}
+ className="inline-block align-baseline text-blue-500 hover:text-blue-600"
+ >{`@${element.name}`}
+ );
};
const Event = ({ attributes, element, children }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- return (
-
- {children}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents:
*/}
- Transforms.removeNodes(editor, { at: path })}
- className="relative user-select-none my-2"
- >
-
-
-
- );
+ return (
+
+ {children}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents:
*/}
+ Transforms.removeNodes(editor, { at: path })}
+ className="user-select-none relative my-2"
+ >
+
+
+
+ );
};
const Element = (props) => {
- const { attributes, children, element } = props;
+ const { attributes, children, element } = props;
- switch (element.type) {
- case "image":
- return ;
- case "mention":
- return ;
- case "event":
- return ;
- default:
- return (
-
- {children}
-
- );
- }
+ switch (element.type) {
+ case "image":
+ return ;
+ case "mention":
+ return ;
+ case "event":
+ return ;
+ default:
+ return (
+
+ {children}
+
+ );
+ }
};
export function EditorForm() {
- const ref = useRef();
+ const ref = useRef();
- const [editorValue, setEditorValue] = useAtom(editorValueAtom);
- const [contacts, setContacts] = useState([]);
- const [target, setTarget] = useState();
- const [index, setIndex] = useState(0);
- const [search, setSearch] = useState("");
- const [loading, setLoading] = useState(false);
- const [editor] = useState(() =>
- withMentions(withNostrEvent(withImages(withReact(createEditor())))),
- );
+ const [editorValue, setEditorValue] = useAtom(editorValueAtom);
+ const [contacts, setContacts] = useState([]);
+ const [target, setTarget] = useState();
+ const [index, setIndex] = useState(0);
+ const [search, setSearch] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [editor] = useState(() =>
+ withMentions(withNostrEvent(withImages(withReact(createEditor())))),
+ );
- const { t } = useTranslation();
+ const { t } = useTranslation();
- const filters = contacts
- ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
- ?.slice(0, 10);
+ const filters = contacts
+ ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
+ ?.slice(0, 10);
- const reset = () => {
- // @ts-expect-error, backlog
- editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
- setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
- };
+ const reset = () => {
+ // @ts-expect-error, backlog
+ editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
+ setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
+ };
- const serialize = (nodes: Descendant[]) => {
- return nodes
- .map((n) => {
- // @ts-expect-error, backlog
- if (n.type === "image") return n.url;
- // @ts-expect-error, backlog
- if (n.type === "event") return n.eventId;
+ const serialize = (nodes: Descendant[]) => {
+ return nodes
+ .map((n) => {
+ // @ts-expect-error, backlog
+ if (n.type === "image") return n.url;
+ // @ts-expect-error, backlog
+ if (n.type === "event") return n.eventId;
- // @ts-expect-error, backlog
- if (n.children.length) {
- // @ts-expect-error, backlog
- return n.children
- .map((n) => {
- if (n.type === "mention") return n.npub;
- return Node.string(n).trim();
- })
- .join(" ");
- }
+ // @ts-expect-error, backlog
+ if (n.children.length) {
+ // @ts-expect-error, backlog
+ return n.children
+ .map((n) => {
+ if (n.type === "mention") return n.npub;
+ return Node.string(n).trim();
+ })
+ .join(" ");
+ }
- return Node.string(n);
- })
- .join("\n");
- };
+ return Node.string(n);
+ })
+ .join("\n");
+ };
- const submit = async () => {
- try {
- setLoading(true);
+ const submit = async () => {
+ try {
+ setLoading(true);
- const content = serialize(editor.children);
- const publish = await invoke("publish", { content });
+ const content = serialize(editor.children);
+ const publish = await invoke("publish", { content });
- if (publish) {
- console.log(publish);
- toast.success(t("editor.successMessage"));
+ if (publish) {
+ console.log(publish);
+ toast.success(t("editor.successMessage"));
- return reset();
- }
+ return reset();
+ }
- setLoading(false);
- } catch (e) {
- setLoading(false);
- toast.error(String(e));
- }
- };
+ setLoading(false);
+ } catch (e) {
+ setLoading(false);
+ toast.error(String(e));
+ }
+ };
- /*
+ /*
useEffect(() => {
async function loadContacts() {
const res = await storage.getAllCacheUsers();
@@ -268,113 +267,113 @@ export function EditorForm() {
}, []);
*/
- useEffect(() => {
- if (target && filters.length > 0) {
- const el = ref.current;
- const domRange = ReactEditor.toDOMRange(editor, target);
- const rect = domRange.getBoundingClientRect();
- el.style.top = `${rect.top + window.pageYOffset + 24}px`;
- el.style.left = `${rect.left + window.pageXOffset}px`;
- }
- }, [filters.length, editor, index, search, target]);
+ useEffect(() => {
+ if (target && filters.length > 0) {
+ const el = ref.current;
+ const domRange = ReactEditor.toDOMRange(editor, target);
+ const rect = domRange.getBoundingClientRect();
+ el.style.top = `${rect.top + window.pageYOffset + 24}px`;
+ el.style.left = `${rect.left + window.pageXOffset}px`;
+ }
+ }, [filters.length, editor, index, search, target]);
- return (
-
-
{
- const { selection } = editor;
+ return (
+
+
{
+ const { selection } = editor;
- if (selection && Range.isCollapsed(selection)) {
- const [start] = Range.edges(selection);
- const wordBefore = Editor.before(editor, start, { unit: "word" });
- const before = wordBefore && Editor.before(editor, wordBefore);
- const beforeRange = before && Editor.range(editor, before, start);
- const beforeText =
- beforeRange && Editor.string(editor, beforeRange);
- const beforeMatch = beforeText?.match(/^@(\w+)$/);
- const after = Editor.after(editor, start);
- const afterRange = Editor.range(editor, start, after);
- const afterText = Editor.string(editor, afterRange);
- const afterMatch = afterText.match(/^(\s|$)/);
+ if (selection && Range.isCollapsed(selection)) {
+ const [start] = Range.edges(selection);
+ const wordBefore = Editor.before(editor, start, { unit: "word" });
+ const before = wordBefore && Editor.before(editor, wordBefore);
+ const beforeRange = before && Editor.range(editor, before, start);
+ const beforeText =
+ beforeRange && Editor.string(editor, beforeRange);
+ const beforeMatch = beforeText?.match(/^@(\w+)$/);
+ const after = Editor.after(editor, start);
+ const afterRange = Editor.range(editor, start, after);
+ const afterText = Editor.string(editor, afterRange);
+ const afterMatch = afterText.match(/^(\s|$)/);
- if (beforeMatch && afterMatch) {
- setTarget(beforeRange);
- setSearch(beforeMatch[1]);
- setIndex(0);
- return;
- }
- }
+ if (beforeMatch && afterMatch) {
+ setTarget(beforeRange);
+ setSearch(beforeMatch[1]);
+ setIndex(0);
+ return;
+ }
+ }
- setTarget(null);
- }}
- >
-
-
-
{t("editor.title")}
-
-
-
-
-
-
-
-
-
-
-
}
- placeholder={t("editor.placeholder")}
- className="focus:outline-none"
- />
- {target && filters.length > 0 && (
-
-
- {filters.map((contact, i) => (
-
- ))}
-
-
- )}
-
-
-
- );
+ setTarget(null);
+ }}
+ >
+
+
+
{t("editor.title")}
+
+
+
+
+
+
+
+
+
+
+
}
+ placeholder={t("editor.placeholder")}
+ className="focus:outline-none"
+ />
+ {target && filters.length > 0 && (
+
+
+ {filters.map((contact, i) => (
+
+ ))}
+
+
+ )}
+
+
+
+ );
}
diff --git a/packages/ui/src/editor/replyForm.tsx b/packages/ui/src/editor/replyForm.tsx
index 274ae395..fa306318 100644
--- a/packages/ui/src/editor/replyForm.tsx
+++ b/packages/ui/src/editor/replyForm.tsx
@@ -1,4 +1,3 @@
-import { MentionNote, User, useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKCacheUserProfile } from "@lume/types";
@@ -8,391 +7,397 @@ import { Portal } from "@radix-ui/react-dropdown-menu";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
- Descendant,
- Editor,
- Node,
- Range,
- Transforms,
- createEditor,
+ Descendant,
+ Editor,
+ Node,
+ Range,
+ Transforms,
+ createEditor,
} from "slate";
import {
- Editable,
- ReactEditor,
- Slate,
- useFocused,
- useSelected,
- useSlateStatic,
- withReact,
+ Editable,
+ ReactEditor,
+ Slate,
+ useFocused,
+ useSelected,
+ useSlateStatic,
+ withReact,
} from "slate-react";
import { toast } from "sonner";
import { EditorAddMedia } from "./addMedia";
import {
- insertImage,
- insertMention,
- insertNostrEvent,
- isImageUrl,
+ insertImage,
+ insertMention,
+ insertNostrEvent,
+ isImageUrl,
} from "./utils";
+import { MentionNote } from "../note/mentions/note";
+import { useArk } from "@lume/ark";
+import { User } from "../user";
const withNostrEvent = (editor: ReactEditor) => {
- const { insertData, isVoid } = editor;
+ const { insertData, isVoid } = editor;
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "event" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "event" ? true : isVoid(element);
+ };
- editor.insertData = (data) => {
- const text = data.getData("text/plain");
+ editor.insertData = (data) => {
+ const text = data.getData("text/plain");
- if (text.startsWith("nevent1") || text.startsWith("note1")) {
- insertNostrEvent(editor, text);
- } else {
- insertData(data);
- }
- };
+ if (text.startsWith("nevent1") || text.startsWith("note1")) {
+ insertNostrEvent(editor, text);
+ } else {
+ insertData(data);
+ }
+ };
- return editor;
+ return editor;
};
const withMentions = (editor: ReactEditor) => {
- const { isInline, isVoid, markableVoid } = editor;
+ const { isInline, isVoid, markableVoid } = editor;
- editor.isInline = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" ? true : isInline(element);
- };
+ editor.isInline = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" ? true : isInline(element);
+ };
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" ? true : isVoid(element);
+ };
- editor.markableVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "mention" || markableVoid(element);
- };
+ editor.markableVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "mention" || markableVoid(element);
+ };
- return editor;
+ return editor;
};
const withImages = (editor: ReactEditor) => {
- const { insertData, isVoid } = editor;
+ const { insertData, isVoid } = editor;
- editor.isVoid = (element) => {
- // @ts-expect-error, wtf
- return element.type === "image" ? true : isVoid(element);
- };
+ editor.isVoid = (element) => {
+ // @ts-expect-error, wtf
+ return element.type === "image" ? true : isVoid(element);
+ };
- editor.insertData = (data) => {
- const text = data.getData("text/plain");
+ editor.insertData = (data) => {
+ const text = data.getData("text/plain");
- if (isImageUrl(text)) {
- insertImage(editor, text);
- } else {
- insertData(data);
- }
- };
+ if (isImageUrl(text)) {
+ insertImage(editor, text);
+ } else {
+ insertData(data);
+ }
+ };
- return editor;
+ return editor;
};
const Image = ({ attributes, children, element }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- const selected = useSelected();
- const focused = useFocused();
+ const selected = useSelected();
+ const focused = useFocused();
- return (
-
- {children}
-
-

-
-
-
- );
+ return (
+
+ {children}
+
+

+
+
+
+ );
};
const Mention = ({ attributes, element }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- return (
- Transforms.removeNodes(editor, { at: path })}
- className="inline-block text-blue-500 align-baseline hover:text-blue-600"
- >{`@${element.name}`}
- );
+ return (
+ Transforms.removeNodes(editor, { at: path })}
+ className="inline-block align-baseline text-blue-500 hover:text-blue-600"
+ >{`@${element.name}`}
+ );
};
const Event = ({ attributes, element, children }) => {
- const editor = useSlateStatic();
- const path = ReactEditor.findPath(editor as ReactEditor, element);
+ const editor = useSlateStatic();
+ const path = ReactEditor.findPath(editor as ReactEditor, element);
- return (
-
- {children}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents:
*/}
- Transforms.removeNodes(editor, { at: path })}
- className="relative user-select-none"
- >
-
-
-
- );
+ return (
+
+ {children}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents:
*/}
+ Transforms.removeNodes(editor, { at: path })}
+ className="user-select-none relative"
+ >
+
+
+
+ );
};
const Element = (props) => {
- const { attributes, children, element } = props;
+ const { attributes, children, element } = props;
- switch (element.type) {
- case "image":
- return ;
- case "mention":
- return ;
- case "event":
- return ;
- default:
- return (
-
- {children}
-
- );
- }
+ switch (element.type) {
+ case "image":
+ return ;
+ case "mention":
+ return ;
+ case "event":
+ return ;
+ default:
+ return (
+
+ {children}
+
+ );
+ }
};
export function ReplyForm({
- eventId,
- className,
-}: { eventId: string; className?: string }) {
- const ark = useArk();
- const storage = useStorage();
- const ref = useRef();
+ eventId,
+ className,
+}: {
+ eventId: string;
+ className?: string;
+}) {
+ const ark = useArk();
+ const storage = useStorage();
+ const ref = useRef();
- const [editorValue, setEditorValue] = useState([
- {
- type: "paragraph",
- children: [{ text: "" }],
- },
- ]);
- const [contacts, setContacts] = useState([]);
- const [target, setTarget] = useState();
- const [index, setIndex] = useState(0);
- const [search, setSearch] = useState("");
- const [loading, setLoading] = useState(false);
- const [editor] = useState(() =>
- withMentions(withNostrEvent(withImages(withReact(createEditor())))),
- );
+ const [editorValue, setEditorValue] = useState([
+ {
+ type: "paragraph",
+ children: [{ text: "" }],
+ },
+ ]);
+ const [contacts, setContacts] = useState([]);
+ const [target, setTarget] = useState();
+ const [index, setIndex] = useState(0);
+ const [search, setSearch] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [editor] = useState(() =>
+ withMentions(withNostrEvent(withImages(withReact(createEditor())))),
+ );
- const { t } = useTranslation();
+ const { t } = useTranslation();
- const filters = contacts
- ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
- ?.slice(0, 10);
+ const filters = contacts
+ ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
+ ?.slice(0, 10);
- const reset = () => {
- // @ts-expect-error, backlog
- editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
- setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
- };
+ const reset = () => {
+ // @ts-expect-error, backlog
+ editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
+ setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
+ };
- const serialize = (nodes: Descendant[]) => {
- return nodes
- .map((n) => {
- // @ts-expect-error, backlog
- if (n.type === "image") return n.url;
- // @ts-expect-error, backlog
- if (n.type === "event") return n.eventId;
+ const serialize = (nodes: Descendant[]) => {
+ return nodes
+ .map((n) => {
+ // @ts-expect-error, backlog
+ if (n.type === "image") return n.url;
+ // @ts-expect-error, backlog
+ if (n.type === "event") return n.eventId;
- // @ts-expect-error, backlog
- if (n.children.length) {
- // @ts-expect-error, backlog
- return n.children
- .map((n) => {
- if (n.type === "mention") return n.npub;
- return Node.string(n).trim();
- })
- .join(" ");
- }
+ // @ts-expect-error, backlog
+ if (n.children.length) {
+ // @ts-expect-error, backlog
+ return n.children
+ .map((n) => {
+ if (n.type === "mention") return n.npub;
+ return Node.string(n).trim();
+ })
+ .join(" ");
+ }
- return Node.string(n);
- })
- .join("\n");
- };
+ return Node.string(n);
+ })
+ .join("\n");
+ };
- const submit = async () => {
- try {
- setLoading(true);
+ const submit = async () => {
+ try {
+ setLoading(true);
- const event = new NDKEvent(ark.ndk);
- event.kind = NDKKind.Text;
- event.content = serialize(editor.children);
+ const event = new NDKEvent(ark.ndk);
+ event.kind = NDKKind.Text;
+ event.content = serialize(editor.children);
- const rootEvent = await ark.getEventById(eventId);
- event.tag(rootEvent, "root");
+ const rootEvent = await ark.getEventById(eventId);
+ event.tag(rootEvent, "root");
- const publish = await event.publish();
+ const publish = await event.publish();
- if (publish) {
- toast.success(
- `Event has been published successfully to ${publish.size} relays.`,
- );
+ if (publish) {
+ toast.success(
+ `Event has been published successfully to ${publish.size} relays.`,
+ );
- setLoading(false);
+ setLoading(false);
- return reset();
- }
- } catch (e) {
- setLoading(false);
- toast.error(String(e));
- }
- };
+ return reset();
+ }
+ } catch (e) {
+ setLoading(false);
+ toast.error(String(e));
+ }
+ };
- useEffect(() => {
- async function loadContacts() {
- const res = await storage.getAllCacheUsers();
- if (res) setContacts(res);
- }
+ useEffect(() => {
+ async function loadContacts() {
+ const res = await storage.getAllCacheUsers();
+ if (res) setContacts(res);
+ }
- loadContacts();
- }, []);
+ loadContacts();
+ }, []);
- useEffect(() => {
- if (target && filters.length > 0) {
- const el = ref.current;
- const domRange = ReactEditor.toDOMRange(editor, target);
- const rect = domRange.getBoundingClientRect();
- el.style.top = `${rect.top + window.pageYOffset + 24}px`;
- el.style.left = `${rect.left + window.pageXOffset}px`;
- }
- }, [filters.length, editor, index, search, target]);
+ useEffect(() => {
+ if (target && filters.length > 0) {
+ const el = ref.current;
+ const domRange = ReactEditor.toDOMRange(editor, target);
+ const rect = domRange.getBoundingClientRect();
+ el.style.top = `${rect.top + window.pageYOffset + 24}px`;
+ el.style.left = `${rect.left + window.pageXOffset}px`;
+ }
+ }, [filters.length, editor, index, search, target]);
- return (
-
-
-
-
-
-
-
-
{
- const { selection } = editor;
+ return (
+
+
+
+
+
+
+
+
{
+ const { selection } = editor;
- if (selection && Range.isCollapsed(selection)) {
- const [start] = Range.edges(selection);
- const wordBefore = Editor.before(editor, start, { unit: "word" });
- const before = wordBefore && Editor.before(editor, wordBefore);
- const beforeRange = before && Editor.range(editor, before, start);
- const beforeText =
- beforeRange && Editor.string(editor, beforeRange);
- const beforeMatch = beforeText?.match(/^@(\w+)$/);
- const after = Editor.after(editor, start);
- const afterRange = Editor.range(editor, start, after);
- const afterText = Editor.string(editor, afterRange);
- const afterMatch = afterText.match(/^(\s|$)/);
+ if (selection && Range.isCollapsed(selection)) {
+ const [start] = Range.edges(selection);
+ const wordBefore = Editor.before(editor, start, { unit: "word" });
+ const before = wordBefore && Editor.before(editor, wordBefore);
+ const beforeRange = before && Editor.range(editor, before, start);
+ const beforeText =
+ beforeRange && Editor.string(editor, beforeRange);
+ const beforeMatch = beforeText?.match(/^@(\w+)$/);
+ const after = Editor.after(editor, start);
+ const afterRange = Editor.range(editor, start, after);
+ const afterText = Editor.string(editor, afterRange);
+ const afterMatch = afterText.match(/^(\s|$)/);
- if (beforeMatch && afterMatch) {
- setTarget(beforeRange);
- setSearch(beforeMatch[1]);
- setIndex(0);
- return;
- }
- }
+ if (beforeMatch && afterMatch) {
+ setTarget(beforeRange);
+ setSearch(beforeMatch[1]);
+ setIndex(0);
+ return;
+ }
+ }
- setTarget(null);
- }}
- >
-
-
}
- placeholder={t("editor.replyPlaceholder")}
- className="focus:outline-none h-28"
- />
- {target && filters.length > 0 && (
-
-
- {filters.map((contact, i) => (
- // biome-ignore lint/a11y/useKeyWithClickEvents:
- {
- Transforms.select(editor, target);
- insertMention(editor, contact);
- setTarget(null);
- }}
- className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
- >
-
-
-
-
-
-
-
-
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ setTarget(null);
+ }}
+ >
+
+
}
+ placeholder={t("editor.replyPlaceholder")}
+ className="h-28 focus:outline-none"
+ />
+ {target && filters.length > 0 && (
+
+
+ {filters.map((contact, i) => (
+ // biome-ignore lint/a11y/useKeyWithClickEvents:
+ {
+ Transforms.select(editor, target);
+ insertMention(editor, contact);
+ setTarget(null);
+ }}
+ className="rounded-md px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
+ >
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index fefe25a3..5a137cf9 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -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";
diff --git a/packages/ui/src/layouts/app.tsx b/packages/ui/src/layouts/app.tsx
deleted file mode 100644
index 85765492..00000000
--- a/packages/ui/src/layouts/app.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/packages/ui/src/layouts/auth.tsx b/packages/ui/src/layouts/auth.tsx
deleted file mode 100644
index 6f29cfda..00000000
--- a/packages/ui/src/layouts/auth.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {canGoBack ? (
-
- ) : (
-
- )}
-
-
-
-
- );
-}
diff --git a/packages/ui/src/layouts/home.tsx b/packages/ui/src/layouts/home.tsx
deleted file mode 100644
index bdab9273..00000000
--- a/packages/ui/src/layouts/home.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Outlet } from "react-router-dom";
-import { OnboardingModal } from "../onboarding/modal";
-
-export function HomeLayout() {
- return (
- <>
-
-
-
-
- >
- );
-}
diff --git a/packages/ui/src/layouts/settings.tsx b/packages/ui/src/layouts/settings.tsx
deleted file mode 100644
index 2c72e3d5..00000000
--- a/packages/ui/src/layouts/settings.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- 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"
- : "",
- )
- }
- >
-
- {t("settings.general.title")}
-
-
- 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"
- : "",
- )
- }
- >
-
- {t("settings.user.title")}
-
-
- 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"
- : "",
- )
- }
- >
-
- {t("settings.zap.title")}
-
-
- 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"
- : "",
- )
- }
- >
-
- {t("settings.backup.title")}
-
-
- 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"
- : "",
- )
- }
- >
-
-
- {t("settings.advanced.title")}
-
-
-
- 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"
- : "",
- )
- }
- >
-
- {t("settings.about.title")}
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/navigation.tsx b/packages/ui/src/navigation.tsx
deleted file mode 100644
index 07d0cd4a..00000000
--- a/packages/ui/src/navigation.tsx
+++ /dev/null
@@ -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(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 (
-
-
-
-
-
-
-
-
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
-
- )}
-
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
-
-
- )}
-
-
-
-
- {update ? (
-
- ) : null}
-
-
- {({ isActive }) => (
-
- {isActive ? (
-
- ) : (
-
- )}
-
- )}
-
-
-
- );
-}
diff --git a/packages/ui/src/note/appHandler.tsx b/packages/ui/src/note/appHandler.tsx
new file mode 100644
index 00000000..eb6105fb
--- /dev/null
+++ b/packages/ui/src/note/appHandler.tsx
@@ -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) {
+ Loading...
;
+ }
+
+ if (isError || !data) {
+ return Error
;
+ }
+
+ return (
+
+

+
+
+ {data.name}
+
+
+ {data.about}
+
+
+
+ );
+}
diff --git a/packages/ark/src/components/note/buttons/pin.tsx b/packages/ui/src/note/buttons/pin.tsx
similarity index 100%
rename from packages/ark/src/components/note/buttons/pin.tsx
rename to packages/ui/src/note/buttons/pin.tsx
diff --git a/packages/ark/src/components/note/buttons/reaction.tsx b/packages/ui/src/note/buttons/reaction.tsx
similarity index 100%
rename from packages/ark/src/components/note/buttons/reaction.tsx
rename to packages/ui/src/note/buttons/reaction.tsx
diff --git a/packages/ark/src/components/note/buttons/reply.tsx b/packages/ui/src/note/buttons/reply.tsx
similarity index 100%
rename from packages/ark/src/components/note/buttons/reply.tsx
rename to packages/ui/src/note/buttons/reply.tsx
diff --git a/packages/ark/src/components/note/buttons/repost.tsx b/packages/ui/src/note/buttons/repost.tsx
similarity index 100%
rename from packages/ark/src/components/note/buttons/repost.tsx
rename to packages/ui/src/note/buttons/repost.tsx
diff --git a/packages/ui/src/note/buttons/zap.tsx b/packages/ui/src/note/buttons/zap.tsx
new file mode 100644
index 00000000..0398f3b4
--- /dev/null
+++ b/packages/ui/src/note/buttons/zap.tsx
@@ -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("21");
+ const [zapMessage, setZapMessage] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ const [isCompleted, setIsCompleted] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [invoice, setInvoice] = useState(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 (
+
+
+
+
+
+
+
+ {t("note.zap.tooltip")}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t("note.zap.tooltip")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("note.zap.modalTitle")}{" "}
+ {user?.name ||
+ user?.displayName ||
+ displayNpub(event.pubkey, 16)}
+
+
+ {!invoice ? (
+
+
+
+ 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"
+ />
+
+ sats
+
+
+
+
+
+
+
+
+
+
+
+
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"
+ />
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ {t("note.zap.invoiceButton")}
+
+
+ {t("note.zap.invoiceFooter")}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/note/child.tsx b/packages/ui/src/note/child.tsx
new file mode 100644
index 00000000..6882f4da
--- /dev/null
+++ b/packages/ui/src/note/child.tsx
@@ -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 ;
+ });
+ }
+ }
+
+ if (mentions.length) {
+ for (const mention of mentions) {
+ parsedContent = reactStringReplace(
+ parsedContent,
+ mention,
+ (match, i) => ,
+ );
+ }
+ }
+
+ parsedContent = reactStringReplace(
+ parsedContent,
+ /(https?:\/\/\S+)/g,
+ (match, i) => {
+ const url = new URL(match);
+ return (
+
+ {url.toString()}
+
+ );
+ },
+ );
+
+ return parsedContent;
+ } catch (e) {
+ console.log(e);
+ return parsedContent;
+ }
+ }, [data]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
+ {t("note.error")}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {isRoot ? t("note.posted") : t("note.replied")}:
+
+
+
+
+
+ );
+}
diff --git a/packages/ark/src/components/note/content.tsx b/packages/ui/src/note/content.tsx
similarity index 100%
rename from packages/ark/src/components/note/content.tsx
rename to packages/ui/src/note/content.tsx
diff --git a/packages/ark/src/components/note/index.ts b/packages/ui/src/note/index.ts
similarity index 100%
rename from packages/ark/src/components/note/index.ts
rename to packages/ui/src/note/index.ts
diff --git a/packages/ark/src/components/note/mentions/hashtag.tsx b/packages/ui/src/note/mentions/hashtag.tsx
similarity index 100%
rename from packages/ark/src/components/note/mentions/hashtag.tsx
rename to packages/ui/src/note/mentions/hashtag.tsx
diff --git a/packages/ark/src/components/note/mentions/invoice.tsx b/packages/ui/src/note/mentions/invoice.tsx
similarity index 100%
rename from packages/ark/src/components/note/mentions/invoice.tsx
rename to packages/ui/src/note/mentions/invoice.tsx
diff --git a/packages/ui/src/note/mentions/note.tsx b/packages/ui/src/note/mentions/note.tsx
new file mode 100644
index 00000000..637bdbcf
--- /dev/null
+++ b/packages/ui/src/note/mentions/note.tsx
@@ -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 ;
+ },
+ );
+ }
+ }
+
+ if (mentions.length) {
+ for (const mention of mentions) {
+ parsedContent = reactStringReplace(
+ parsedContent,
+ mention,
+ (match, i) => ,
+ );
+ }
+ }
+
+ parsedContent = reactStringReplace(
+ parsedContent,
+ /(https?:\/\/\S+)/g,
+ (match, i) => {
+ const url = new URL(match);
+ return (
+
+ {url.toString()}
+
+ );
+ },
+ );
+
+ return parsedContent;
+ } catch (e) {
+ console.log(e);
+ return parsedContent;
+ }
+ }, [data]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+ {t("note.error")}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ ·
+
+
+
+
+
+ {richContent}
+
+ {openable ? (
+
+
+ {t("note.showMore")}
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/note/mentions/user.tsx b/packages/ui/src/note/mentions/user.tsx
new file mode 100644
index 00000000..b426a65a
--- /dev/null
+++ b/packages/ui/src/note/mentions/user.tsx
@@ -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 (
+
+
+ {isLoading
+ ? "@anon"
+ : isError
+ ? pubkey
+ : `@${user?.name || user?.display_name || user?.name || "anon"}`}
+
+
+
+
+ {t("note.buttons.viewProfile")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/note/menu.tsx b/packages/ui/src/note/menu.tsx
new file mode 100644
index 00000000..91a181b2
--- /dev/null
+++ b/packages/ui/src/note/menu.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("note.menu.viewAuthor")}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/note/nip89.tsx b/packages/ui/src/note/nip89.tsx
new file mode 100644
index 00000000..ed4cd257
--- /dev/null
+++ b/packages/ui/src/note/nip89.tsx
@@ -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) {
+ Loading...
;
+ }
+
+ if (isError || !data) {
+ return Error
;
+ }
+
+ return (
+
+
+
+
+ {t("nip89.unsupported")}
+
+
+ {event.kind}
+
+
+
+
+ {t("nip89.openWith")}
+
+ {data.map((item) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/ark/src/components/note/preview/image.tsx b/packages/ui/src/note/preview/image.tsx
similarity index 100%
rename from packages/ark/src/components/note/preview/image.tsx
rename to packages/ui/src/note/preview/image.tsx
diff --git a/packages/ark/src/components/note/preview/link.tsx b/packages/ui/src/note/preview/link.tsx
similarity index 100%
rename from packages/ark/src/components/note/preview/link.tsx
rename to packages/ui/src/note/preview/link.tsx
diff --git a/packages/ark/src/components/note/preview/video.tsx b/packages/ui/src/note/preview/video.tsx
similarity index 100%
rename from packages/ark/src/components/note/preview/video.tsx
rename to packages/ui/src/note/preview/video.tsx
diff --git a/packages/ark/src/components/note/primitives/childReply.tsx b/packages/ui/src/note/primitives/childReply.tsx
similarity index 100%
rename from packages/ark/src/components/note/primitives/childReply.tsx
rename to packages/ui/src/note/primitives/childReply.tsx
diff --git a/packages/ark/src/components/note/primitives/reply.tsx b/packages/ui/src/note/primitives/reply.tsx
similarity index 100%
rename from packages/ark/src/components/note/primitives/reply.tsx
rename to packages/ui/src/note/primitives/reply.tsx
diff --git a/packages/ui/src/note/primitives/repost.tsx b/packages/ui/src/note/primitives/repost.tsx
new file mode 100644
index 00000000..821c6643
--- /dev/null
+++ b/packages/ui/src/note/primitives/repost.tsx
@@ -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 Loading...
;
+ }
+
+ if (isError || !repostEvent) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t("note.reposted")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t("note.reposted")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ark/src/components/note/primitives/skeleton.tsx b/packages/ui/src/note/primitives/skeleton.tsx
similarity index 100%
rename from packages/ark/src/components/note/primitives/skeleton.tsx
rename to packages/ui/src/note/primitives/skeleton.tsx
diff --git a/packages/ark/src/components/note/primitives/text.tsx b/packages/ui/src/note/primitives/text.tsx
similarity index 100%
rename from packages/ark/src/components/note/primitives/text.tsx
rename to packages/ui/src/note/primitives/text.tsx
diff --git a/packages/ui/src/note/primitives/thread.tsx b/packages/ui/src/note/primitives/thread.tsx
new file mode 100644
index 00000000..38c682ae
--- /dev/null
+++ b/packages/ui/src/note/primitives/thread.tsx
@@ -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 Loading...
;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ark/src/components/note/provider.tsx b/packages/ui/src/note/provider.tsx
similarity index 100%
rename from packages/ark/src/components/note/provider.tsx
rename to packages/ui/src/note/provider.tsx
diff --git a/packages/ark/src/components/note/root.tsx b/packages/ui/src/note/root.tsx
similarity index 100%
rename from packages/ark/src/components/note/root.tsx
rename to packages/ui/src/note/root.tsx
diff --git a/packages/ui/src/note/thread.tsx b/packages/ui/src/note/thread.tsx
new file mode 100644
index 00000000..e2f80fd6
--- /dev/null
+++ b/packages/ui/src/note/thread.tsx
@@ -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 (
+
+
+ {thread.rootEventId ? (
+
+ ) : null}
+ {thread.replyEventId ? (
+
+ ) : null}
+
+
+ {t("note.showThread")}
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/note/user.tsx b/packages/ui/src/note/user.tsx
new file mode 100644
index 00000000..e034dd4a
--- /dev/null
+++ b/packages/ui/src/note/user.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/replyList.tsx b/packages/ui/src/replyList.tsx
index 6c52fc16..4ae6b05e 100644
--- a/packages/ui/src/replyList.tsx
+++ b/packages/ui/src/replyList.tsx
@@ -1,83 +1,86 @@
-import { Reply, useArk } from "@lume/ark";
+import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
-import { NDKEventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ReplyForm } from "./editor/replyForm";
+import { Reply } from "./note/primitives/reply";
export function ReplyList({
- eventId,
- className,
-}: { eventId: string; className?: string }) {
- const ark = useArk();
+ eventId,
+ className,
+}: {
+ eventId: string;
+ className?: string;
+}) {
+ const ark = useArk();
- const [t] = useTranslation();
- const [data, setData] = useState(null);
+ const [t] = useTranslation();
+ const [data, setData] = useState(null);
- useEffect(() => {
- let sub: NDKSubscription = undefined;
- let isCancelled = false;
+ useEffect(() => {
+ let sub: NDKSubscription = undefined;
+ let isCancelled = false;
- async function fetchRepliesAndSub() {
- const id = ark.getCleanEventId(eventId);
- const events = await ark.getThreads(id);
+ async function fetchRepliesAndSub() {
+ const id = ark.getCleanEventId(eventId);
+ const events = await ark.getThreads(id);
- if (!isCancelled) {
- setData(events);
- }
+ if (!isCancelled) {
+ setData(events);
+ }
- if (!sub) {
- sub = ark.subscribe({
- filter: {
- "#e": [id],
- kinds: [NDKKind.Text],
- since: Math.floor(Date.now() / 1000),
- },
- closeOnEose: false,
- cb: (event: NDKEventWithReplies) =>
- setData((prev) => [event, ...prev]),
- });
- }
- }
+ if (!sub) {
+ sub = ark.subscribe({
+ filter: {
+ "#e": [id],
+ kinds: [NDKKind.Text],
+ since: Math.floor(Date.now() / 1000),
+ },
+ closeOnEose: false,
+ cb: (event: NDKEventWithReplies) =>
+ setData((prev) => [event, ...prev]),
+ });
+ }
+ }
- // subscribe for new replies
- fetchRepliesAndSub();
+ // subscribe for new replies
+ fetchRepliesAndSub();
- return () => {
- isCancelled = true;
- if (sub) sub.stop();
- };
- }, [eventId]);
+ return () => {
+ isCancelled = true;
+ if (sub) sub.stop();
+ };
+ }, [eventId]);
- return (
-
-
- {!data ? (
-
-
-
- ) : data.length === 0 ? (
-
-
-
👋
-
- {t("note.reply.empty")}
-
-
-
- ) : (
- data.map((event) =>
)
- )}
-
- );
+ return (
+
+
+ {!data ? (
+
+
+
+ ) : data.length === 0 ? (
+
+
+
👋
+
+ {t("note.reply.empty")}
+
+
+
+ ) : (
+ data.map((event) =>
)
+ )}
+
+ );
}
diff --git a/packages/ui/src/routes/event.tsx b/packages/ui/src/routes/event.tsx
index 9b3843e2..86a0293d 100644
--- a/packages/ui/src/routes/event.tsx
+++ b/packages/ui/src/routes/event.tsx
@@ -1,37 +1,37 @@
-import { ThreadNote } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "../replyList";
+import { ThreadNote } from "../note/primitives/thread";
export function EventRoute() {
- const { id } = useParams();
- const navigate = useNavigate();
+ const { id } = useParams();
+ const navigate = useNavigate();
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/packages/ui/src/routes/suggest.tsx b/packages/ui/src/routes/suggest.tsx
index 8dbe090f..0f230853 100644
--- a/packages/ui/src/routes/suggest.tsx
+++ b/packages/ui/src/routes/suggest.tsx
@@ -1,127 +1,127 @@
-import { User } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { WindowVirtualizer } from "virtua";
+import { User } from "../user";
const POPULAR_USERS = [
- "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
- "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
- "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
- "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
- "npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
- "npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
- "npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
- "npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
- "npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
- "npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
- "npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
- "npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
+ "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
+ "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
+ "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
+ "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
+ "npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
+ "npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
+ "npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
+ "npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
+ "npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
+ "npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
+ "npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
+ "npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
];
const LUME_USERS = [
- "npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
+ "npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
];
export function SuggestRoute({ queryKey }: { queryKey: string }) {
- const queryClient = useQueryClient();
- const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
- const { t } = useTranslation();
- const { isLoading, isError, data } = useQuery({
- queryKey: ["trending-users"],
- queryFn: async ({ signal }: { signal: AbortSignal }) => {
- const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
- signal,
- });
- if (!res.ok) {
- throw new Error("Failed to fetch trending users from nostr.band API.");
- }
- return res.json();
- },
- });
+ const { t } = useTranslation();
+ const { isLoading, isError, data } = useQuery({
+ queryKey: ["trending-users"],
+ queryFn: async ({ signal }: { signal: AbortSignal }) => {
+ const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
+ signal,
+ });
+ if (!res.ok) {
+ throw new Error("Failed to fetch trending users from nostr.band API.");
+ }
+ return res.json();
+ },
+ });
- const submit = async () => {
- try {
- await queryClient.refetchQueries({ queryKey: [queryKey] });
- return navigate("/", { replace: true });
- } catch (e) {
- toast.error(String(e));
- }
- };
+ const submit = async () => {
+ try {
+ await queryClient.refetchQueries({ queryKey: [queryKey] });
+ return navigate("/", { replace: true });
+ } catch (e) {
+ toast.error(String(e));
+ }
+ };
- return (
-
-
-
-
-
-
-
-
-
{t("suggestion.title")}
-
-
- {isLoading ? (
-
-
-
- ) : isError ? (
-
- {t("suggestion.error")}
-
- ) : (
- data?.profiles.map((item: { pubkey: string }) => (
-
- ))
- )}
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
{t("suggestion.title")}
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ {t("suggestion.error")}
+
+ ) : (
+ data?.profiles.map((item: { pubkey: string }) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
+ );
}
diff --git a/packages/ui/src/routes/user.tsx b/packages/ui/src/routes/user.tsx
index 2cdeb60a..822b894d 100644
--- a/packages/ui/src/routes/user.tsx
+++ b/packages/ui/src/routes/user.tsx
@@ -1,9 +1,9 @@
-import { RepostNote, TextNote, User, useArk } from "@lume/ark";
+import { useArk } from "@lume/ark";
import {
- ArrowLeftIcon,
- ArrowRightCircleIcon,
- ArrowRightIcon,
- LoaderIcon,
+ ArrowLeftIcon,
+ ArrowRightCircleIcon,
+ ArrowRightIcon,
+ LoaderIcon,
} from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
@@ -12,136 +12,137 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua";
+import { User } from "../user";
export function UserRoute() {
- const ark = useArk();
- const navigate = useNavigate();
+ const ark = useArk();
+ const navigate = useNavigate();
- const { id } = useParams();
- const { t } = useTranslation();
- const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
- useInfiniteQuery({
- queryKey: ["user-posts", id],
- initialPageParam: 0,
- queryFn: async ({
- signal,
- pageParam,
- }: {
- signal: AbortSignal;
- pageParam: number;
- }) => {
- const events = await ark.getInfiniteEvents({
- filter: {
- kinds: [NDKKind.Text, NDKKind.Repost],
- authors: [id],
- },
- limit: FETCH_LIMIT,
- pageParam,
- signal,
- });
+ const { id } = useParams();
+ const { t } = useTranslation();
+ const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+ useInfiniteQuery({
+ queryKey: ["user-posts", id],
+ initialPageParam: 0,
+ queryFn: async ({
+ signal,
+ pageParam,
+ }: {
+ signal: AbortSignal;
+ pageParam: number;
+ }) => {
+ const events = await ark.getInfiniteEvents({
+ filter: {
+ kinds: [NDKKind.Text, NDKKind.Repost],
+ authors: [id],
+ },
+ limit: FETCH_LIMIT,
+ pageParam,
+ signal,
+ });
- return events;
- },
- getNextPageParam: (lastPage) => {
- const lastEvent = lastPage.at(-1);
- if (!lastEvent) return;
- return lastEvent.created_at - 1;
- },
- refetchOnWindowFocus: false,
- });
+ return events;
+ },
+ getNextPageParam: (lastPage) => {
+ const lastEvent = lastPage.at(-1);
+ if (!lastEvent) return;
+ return lastEvent.created_at - 1;
+ },
+ refetchOnWindowFocus: false,
+ });
- const allEvents = useMemo(
- () => (data ? data.pages.flatMap((page) => page) : []),
- [data],
- );
+ const allEvents = useMemo(
+ () => (data ? data.pages.flatMap((page) => page) : []),
+ [data],
+ );
- const renderItem = (event: NDKEvent) => {
- switch (event.kind) {
- case NDKKind.Text:
- return ;
- case NDKKind.Repost:
- return ;
- default:
- return ;
- }
- };
+ const renderItem = (event: NDKEvent) => {
+ switch (event.kind) {
+ case NDKKind.Text:
+ return ;
+ case NDKKind.Repost:
+ return ;
+ default:
+ return ;
+ }
+ };
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("user.latestPosts")}
-
-
- {isLoading ? (
-
-
-
- ) : (
- allEvents.map((item) => renderItem(item))
- )}
-
- {hasNextPage ? (
-
- ) : null}
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("user.latestPosts")}
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ allEvents.map((item) => renderItem(item))
+ )}
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
}
diff --git a/packages/ui/src/titlebar/components/button.tsx b/packages/ui/src/titlebar/components/button.tsx
deleted file mode 100644
index 28fbcf8d..00000000
--- a/packages/ui/src/titlebar/components/button.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { cn } from "@lume/utils";
-import type { ButtonHTMLAttributes } from "react";
-
-export function WindowButton({
- className,
- children,
- ...props
-}: ButtonHTMLAttributes) {
- return (
-
- );
-}
diff --git a/packages/ui/src/titlebar/components/icons.tsx b/packages/ui/src/titlebar/components/icons.tsx
deleted file mode 100644
index 74425946..00000000
--- a/packages/ui/src/titlebar/components/icons.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import type { SVGProps } from 'react';
-
-export const WindowIcons = {
- minimizeWin: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- maximizeWin: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- maximizeRestoreWin: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- closeWin: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- closeMac: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- minMac: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- fullMac: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
- plusMac: (props: JSX.IntrinsicAttributes & SVGProps) => (
-
- ),
-};
diff --git a/packages/ui/src/titlebar/context.tsx b/packages/ui/src/titlebar/context.tsx
deleted file mode 100644
index 166374d6..00000000
--- a/packages/ui/src/titlebar/context.tsx
+++ /dev/null
@@ -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;
- maximizeWindow: () => Promise;
- fullscreenWindow: () => Promise;
- closeWindow: () => Promise;
-}
-
-export const AppWindowContext = createContext({
- 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 = ({
- children,
-}) => {
- const [appWindow, setAppWindow] = useState(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 (
-
- {children}
-
- );
-};
diff --git a/packages/ui/src/titlebar/controls/gnome.tsx b/packages/ui/src/titlebar/controls/gnome.tsx
deleted file mode 100644
index 95294a62..00000000
--- a/packages/ui/src/titlebar/controls/gnome.tsx
+++ /dev/null
@@ -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) {
- const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
- useContext(AppWindowContext);
-
- return (
-
-
-
-
-
- {!isWindowMaximized ? (
-
- ) : (
-
- )}
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/titlebar/controls/macos.tsx b/packages/ui/src/titlebar/controls/macos.tsx
deleted file mode 100644
index c1607df5..00000000
--- a/packages/ui/src/titlebar/controls/macos.tsx
+++ /dev/null
@@ -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) {
- const { minimizeWindow, maximizeWindow, fullscreenWindow, closeWindow } =
- useContext(AppWindowContext);
-
- const [isAltKeyPressed, setIsAltKeyPressed] = useState(false);
- const [isHovering, setIsHovering] = useState(false);
-
- const last = isAltKeyPressed ? (
-
- ) : (
-
- );
- 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 (
-
-
- {isHovering && }
-
-
- {isHovering && }
-
-
- {isHovering && last}
-
-
- );
-}
diff --git a/packages/ui/src/titlebar/controls/windows.tsx b/packages/ui/src/titlebar/controls/windows.tsx
deleted file mode 100644
index 19372fa1..00000000
--- a/packages/ui/src/titlebar/controls/windows.tsx
+++ /dev/null
@@ -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) {
- const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
- useContext(AppWindowContext);
-
- return (
-
-
-
-
-
- {!isWindowMaximized ? (
-
- ) : (
-
- )}
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/titlebar/index.ts b/packages/ui/src/titlebar/index.ts
deleted file mode 100644
index eaa6880b..00000000
--- a/packages/ui/src/titlebar/index.ts
+++ /dev/null
@@ -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';
diff --git a/packages/ui/src/titlebar/titleBar.tsx b/packages/ui/src/titlebar/titleBar.tsx
deleted file mode 100644
index 882dedbe..00000000
--- a/packages/ui/src/titlebar/titleBar.tsx
+++ /dev/null
@@ -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 ;
- case "macos":
- return ;
- case "linux":
- return ;
- default:
- return ;
- }
- };
-
- return (
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/user.tsx b/packages/ui/src/user.tsx
deleted file mode 100644
index 83a1596c..00000000
--- a/packages/ui/src/user.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- {fallbackName}
-
- ·
-
- {createdAt}
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {user?.name ||
- user?.display_name ||
- user?.displayName ||
- fallbackName}
-
- ·
-
- {createdAt}
-
-
-
- );
- }
-
- if (variant === "notify2") {
- if (isLoading) {
- return (
-
-
-
-
-
- {fallbackName}
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {user?.name ||
- user?.display_name ||
- user?.displayName ||
- fallbackName}
-
-
{subtext}
-
-
- );
- }
-
- if (variant === "notify") {
- if (isLoading) {
- return (
-
-
-
-
-
- {fallbackName}
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- {user?.name || user?.display_name || fallbackName}
-
-
- );
- }
-
- if (variant === "large") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
- {user?.banner ? (
-

- ) : null}
-
-
-
-
-
-
-
-
-
-
- {user?.name || user?.display_name}
-
-
- {user?.about || "No bio"}
-
-
-
-
- );
- }
-
- if (variant === "simple") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {user?.name || user?.display_name || user?.displayName}
-
-
- {user?.nip05 || user?.username || fallbackName}
-
-
-
- );
- }
-
- if (variant === "avatar") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- );
- }
-
- if (variant === "miniavatar") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- );
- }
-
- if (variant === "childnote") {
- if (isLoading) {
- return (
- <>
-
-
-
-
-
{fallbackName}
-
- {subtext}:
-
-
- >
- );
- }
-
- return (
- <>
-
-
-
-
-
-
-
-
- {user?.display_name ||
- user?.name ||
- user?.displayName ||
- fallbackName}{" "}
-
-
- {subtext}:
-
-
- >
- );
- }
-
- if (variant === "stacked") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- );
- }
-
- if (variant === "ministacked") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- );
- }
-
- if (variant === "repost") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {user?.name ||
- user?.display_name ||
- user?.displayName ||
- fallbackName}
-
- reposted
-
-
-
- );
- }
-
- if (variant === "thread") {
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {user?.name || user?.display_name || user?.displayName || "Anon"}
-
-
- {createdAt}
- ·
- {fallbackName}
-
-
-
- );
- }
-
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {user?.name ||
- user?.display_name ||
- user?.displayName ||
- fallbackName}
-
-
-
-
- );
-});
diff --git a/packages/ark/src/components/user/about.tsx b/packages/ui/src/user/about.tsx
similarity index 100%
rename from packages/ark/src/components/user/about.tsx
rename to packages/ui/src/user/about.tsx
diff --git a/packages/ark/src/components/user/avatar.tsx b/packages/ui/src/user/avatar.tsx
similarity index 100%
rename from packages/ark/src/components/user/avatar.tsx
rename to packages/ui/src/user/avatar.tsx
diff --git a/packages/ark/src/components/user/cover.tsx b/packages/ui/src/user/cover.tsx
similarity index 100%
rename from packages/ark/src/components/user/cover.tsx
rename to packages/ui/src/user/cover.tsx
diff --git a/packages/ui/src/user/followButton.tsx b/packages/ui/src/user/followButton.tsx
new file mode 100644
index 00000000..e6a89557
--- /dev/null
+++ b/packages/ui/src/user/followButton.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/ark/src/components/user/index.ts b/packages/ui/src/user/index.ts
similarity index 100%
rename from packages/ark/src/components/user/index.ts
rename to packages/ui/src/user/index.ts
diff --git a/packages/ark/src/components/user/name.tsx b/packages/ui/src/user/name.tsx
similarity index 100%
rename from packages/ark/src/components/user/name.tsx
rename to packages/ui/src/user/name.tsx
diff --git a/packages/ark/src/components/user/nip05.tsx b/packages/ui/src/user/nip05.tsx
similarity index 100%
rename from packages/ark/src/components/user/nip05.tsx
rename to packages/ui/src/user/nip05.tsx
diff --git a/packages/ui/src/user/provider.tsx b/packages/ui/src/user/provider.tsx
new file mode 100644
index 00000000..205ab5f9
--- /dev/null
+++ b/packages/ui/src/user/provider.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function useUserContext() {
+ const context = useContext(UserContext);
+ return context;
+}
diff --git a/packages/ark/src/components/user/root.tsx b/packages/ui/src/user/root.tsx
similarity index 100%
rename from packages/ark/src/components/user/root.tsx
rename to packages/ui/src/user/root.tsx
diff --git a/packages/ark/src/components/user/time.tsx b/packages/ui/src/user/time.tsx
similarity index 100%
rename from packages/ark/src/components/user/time.tsx
rename to packages/ui/src/user/time.tsx
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c11b723..702ab6a5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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