From 6171b9bed10e0bb4621eaa8ed0f7d68a3faaa176 Mon Sep 17 00:00:00 2001 From: reya Date: Wed, 14 Feb 2024 15:51:06 +0700 Subject: [PATCH] wip: tired... --- apps/desktop2/package.json | 3 +- apps/desktop2/src/routes/app.tsx | 115 ++-- apps/desktop2/src/routes/app/home.lazy.tsx | 105 ++- .../ark/src/components/note/appHandler.tsx | 52 -- .../ark/src/components/note/buttons/zap.tsx | 260 ------- packages/ark/src/components/note/child.tsx | 122 ---- .../ark/src/components/note/mentions/note.tsx | 144 ---- .../ark/src/components/note/mentions/user.tsx | 40 -- packages/ark/src/components/note/menu.tsx | 112 --- packages/ark/src/components/note/nip89.tsx | 55 -- .../src/components/note/primitives/repost.tsx | 110 --- .../src/components/note/primitives/thread.tsx | 43 -- packages/ark/src/components/note/thread.tsx | 51 -- packages/ark/src/components/note/user.tsx | 56 -- .../ark/src/components/user/followButton.tsx | 59 -- packages/ark/src/components/user/provider.tsx | 44 -- packages/ark/src/index.ts | 15 - packages/lume-column-thread/src/home.tsx | 20 +- packages/ui/package.json | 11 + packages/ui/src/account/active.tsx | 68 +- .../account/{logout.tsx => logoutDialog.tsx} | 2 +- .../components => ui/src}/column/content.tsx | 0 .../components => ui/src}/column/header.tsx | 0 .../src/components => ui/src}/column/index.ts | 0 .../src/components => ui/src}/column/live.tsx | 0 .../components => ui/src}/column/provider.tsx | 0 .../src/components => ui/src}/column/root.tsx | 0 packages/ui/src/editor/form.tsx | 595 ++++++++-------- packages/ui/src/editor/replyForm.tsx | 645 +++++++++--------- packages/ui/src/index.ts | 22 +- packages/ui/src/layouts/app.tsx | 20 - packages/ui/src/layouts/auth.tsx | 31 - packages/ui/src/layouts/home.tsx | 13 - packages/ui/src/layouts/settings.tsx | 115 ---- packages/ui/src/navigation.tsx | 181 ----- packages/ui/src/note/appHandler.tsx | 53 ++ .../src}/note/buttons/pin.tsx | 0 .../src}/note/buttons/reaction.tsx | 0 .../src}/note/buttons/reply.tsx | 0 .../src}/note/buttons/repost.tsx | 0 packages/ui/src/note/buttons/zap.tsx | 260 +++++++ packages/ui/src/note/child.tsx | 125 ++++ .../components => ui/src}/note/content.tsx | 0 .../src/components => ui/src}/note/index.ts | 0 .../src}/note/mentions/hashtag.tsx | 0 .../src}/note/mentions/invoice.tsx | 0 packages/ui/src/note/mentions/note.tsx | 147 ++++ packages/ui/src/note/mentions/user.tsx | 39 ++ packages/ui/src/note/menu.tsx | 112 +++ packages/ui/src/note/nip89.tsx | 55 ++ .../src}/note/preview/image.tsx | 0 .../src}/note/preview/link.tsx | 0 .../src}/note/preview/video.tsx | 0 .../src}/note/primitives/childReply.tsx | 0 .../src}/note/primitives/reply.tsx | 0 packages/ui/src/note/primitives/repost.tsx | 113 +++ .../src}/note/primitives/skeleton.tsx | 0 .../src}/note/primitives/text.tsx | 0 packages/ui/src/note/primitives/thread.tsx | 43 ++ .../components => ui/src}/note/provider.tsx | 0 .../src/components => ui/src}/note/root.tsx | 0 packages/ui/src/note/thread.tsx | 47 ++ packages/ui/src/note/user.tsx | 51 ++ packages/ui/src/replyList.tsx | 135 ++-- packages/ui/src/routes/event.tsx | 58 +- packages/ui/src/routes/suggest.tsx | 218 +++--- packages/ui/src/routes/user.tsx | 261 +++---- .../ui/src/titlebar/components/button.tsx | 21 - packages/ui/src/titlebar/components/icons.tsx | 140 ---- packages/ui/src/titlebar/context.tsx | 115 ---- packages/ui/src/titlebar/controls/gnome.tsx | 40 -- packages/ui/src/titlebar/controls/macos.tsx | 79 --- packages/ui/src/titlebar/controls/windows.tsx | 41 -- packages/ui/src/titlebar/index.ts | 7 - packages/ui/src/titlebar/titleBar.tsx | 31 - packages/ui/src/user.tsx | 604 ---------------- .../src/components => ui/src}/user/about.tsx | 0 .../src/components => ui/src}/user/avatar.tsx | 0 .../src/components => ui/src}/user/cover.tsx | 0 packages/ui/src/user/followButton.tsx | 62 ++ .../src/components => ui/src}/user/index.ts | 0 .../src/components => ui/src}/user/name.tsx | 0 .../src/components => ui/src}/user/nip05.tsx | 0 packages/ui/src/user/provider.tsx | 45 ++ .../src/components => ui/src}/user/root.tsx | 0 .../src/components => ui/src}/user/time.tsx | 0 pnpm-lock.yaml | 36 + 87 files changed, 2380 insertions(+), 3667 deletions(-) delete mode 100644 packages/ark/src/components/note/appHandler.tsx delete mode 100644 packages/ark/src/components/note/buttons/zap.tsx delete mode 100644 packages/ark/src/components/note/child.tsx delete mode 100644 packages/ark/src/components/note/mentions/note.tsx delete mode 100644 packages/ark/src/components/note/mentions/user.tsx delete mode 100644 packages/ark/src/components/note/menu.tsx delete mode 100644 packages/ark/src/components/note/nip89.tsx delete mode 100644 packages/ark/src/components/note/primitives/repost.tsx delete mode 100644 packages/ark/src/components/note/primitives/thread.tsx delete mode 100644 packages/ark/src/components/note/thread.tsx delete mode 100644 packages/ark/src/components/note/user.tsx delete mode 100644 packages/ark/src/components/user/followButton.tsx delete mode 100644 packages/ark/src/components/user/provider.tsx rename packages/ui/src/account/{logout.tsx => logoutDialog.tsx} (98%) rename packages/{ark/src/components => ui/src}/column/content.tsx (100%) rename packages/{ark/src/components => ui/src}/column/header.tsx (100%) rename packages/{ark/src/components => ui/src}/column/index.ts (100%) rename packages/{ark/src/components => ui/src}/column/live.tsx (100%) rename packages/{ark/src/components => ui/src}/column/provider.tsx (100%) rename packages/{ark/src/components => ui/src}/column/root.tsx (100%) delete mode 100644 packages/ui/src/layouts/app.tsx delete mode 100644 packages/ui/src/layouts/auth.tsx delete mode 100644 packages/ui/src/layouts/home.tsx delete mode 100644 packages/ui/src/layouts/settings.tsx delete mode 100644 packages/ui/src/navigation.tsx create mode 100644 packages/ui/src/note/appHandler.tsx rename packages/{ark/src/components => ui/src}/note/buttons/pin.tsx (100%) rename packages/{ark/src/components => ui/src}/note/buttons/reaction.tsx (100%) rename packages/{ark/src/components => ui/src}/note/buttons/reply.tsx (100%) rename packages/{ark/src/components => ui/src}/note/buttons/repost.tsx (100%) create mode 100644 packages/ui/src/note/buttons/zap.tsx create mode 100644 packages/ui/src/note/child.tsx rename packages/{ark/src/components => ui/src}/note/content.tsx (100%) rename packages/{ark/src/components => ui/src}/note/index.ts (100%) rename packages/{ark/src/components => ui/src}/note/mentions/hashtag.tsx (100%) rename packages/{ark/src/components => ui/src}/note/mentions/invoice.tsx (100%) create mode 100644 packages/ui/src/note/mentions/note.tsx create mode 100644 packages/ui/src/note/mentions/user.tsx create mode 100644 packages/ui/src/note/menu.tsx create mode 100644 packages/ui/src/note/nip89.tsx rename packages/{ark/src/components => ui/src}/note/preview/image.tsx (100%) rename packages/{ark/src/components => ui/src}/note/preview/link.tsx (100%) rename packages/{ark/src/components => ui/src}/note/preview/video.tsx (100%) rename packages/{ark/src/components => ui/src}/note/primitives/childReply.tsx (100%) rename packages/{ark/src/components => ui/src}/note/primitives/reply.tsx (100%) create mode 100644 packages/ui/src/note/primitives/repost.tsx rename packages/{ark/src/components => ui/src}/note/primitives/skeleton.tsx (100%) rename packages/{ark/src/components => ui/src}/note/primitives/text.tsx (100%) create mode 100644 packages/ui/src/note/primitives/thread.tsx rename packages/{ark/src/components => ui/src}/note/provider.tsx (100%) rename packages/{ark/src/components => ui/src}/note/root.tsx (100%) create mode 100644 packages/ui/src/note/thread.tsx create mode 100644 packages/ui/src/note/user.tsx delete mode 100644 packages/ui/src/titlebar/components/button.tsx delete mode 100644 packages/ui/src/titlebar/components/icons.tsx delete mode 100644 packages/ui/src/titlebar/context.tsx delete mode 100644 packages/ui/src/titlebar/controls/gnome.tsx delete mode 100644 packages/ui/src/titlebar/controls/macos.tsx delete mode 100644 packages/ui/src/titlebar/controls/windows.tsx delete mode 100644 packages/ui/src/titlebar/index.ts delete mode 100644 packages/ui/src/titlebar/titleBar.tsx delete mode 100644 packages/ui/src/user.tsx rename packages/{ark/src/components => ui/src}/user/about.tsx (100%) rename packages/{ark/src/components => ui/src}/user/avatar.tsx (100%) rename packages/{ark/src/components => ui/src}/user/cover.tsx (100%) create mode 100644 packages/ui/src/user/followButton.tsx rename packages/{ark/src/components => ui/src}/user/index.ts (100%) rename packages/{ark/src/components => ui/src}/user/name.tsx (100%) rename packages/{ark/src/components => ui/src}/user/nip05.tsx (100%) create mode 100644 packages/ui/src/user/provider.tsx rename packages/{ark/src/components => ui/src}/user/root.tsx (100%) rename packages/{ark/src/components => ui/src}/user/time.tsx (100%) 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.pubkey} -
-
- {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")} - - - - - - - - - -
-
- -
- Esc -
-
-
-
-
- - {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 ( -
-
-
-
- {richContent} -
-
- - - -
- -
- {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 ( -
-

Loading...

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

Failed to get event

-
-
-
- ); - } - - 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 ( -
- - - - {ark.account.npub} - - - + -
+ + {ark.account.npub} + +
- {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} -
- {element.url} - -
-
- ); + return ( +
+ {children} +
+ {element.url} + +
+
+ ); }; 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} -
- {element.url} - -
-
- ); + return ( +
+ {children} +
+ {element.url} + +
+
+ ); }; 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.pubkey} +
+
+ {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")} + + + + + + + + + +
+
+ +
+ Esc +
+
+
+
+
+ + {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 ( +
+
+
+
+ {richContent} +
+
+ + + +
+ +
+ {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 ( +
+

Loading...

+
+ ); + } + + 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")} +
+
+
+
+
+
+

Failed to get event

+
+
+
+ ); + } + + 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 ( + + + + + + +
+ + +
+
+ + +
+ +
+
+ + +
+ + + View profile + +
+
+ +
+
+
+
+ ); +} 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 ( -
- - - - {pubkey} - - -
-
- {user?.name || - user?.display_name || - user?.displayName || - fallbackName} -
- · - - {createdAt} - -
-
- ); - } - - if (variant === "notify2") { - if (isLoading) { - return ( -
- - - -
- {fallbackName} -
-
- ); - } - - return ( -
- - - - {pubkey} - - -
-
- {user?.name || - user?.display_name || - user?.displayName || - fallbackName} -
-

{subtext}

-
-
- ); - } - - if (variant === "notify") { - if (isLoading) { - return ( -
- - - -
- {fallbackName} -
-
- ); - } - - return ( -
- - - - {pubkey} - - -
- {user?.name || user?.display_name || fallbackName} -
-
- ); - } - - if (variant === "large") { - if (isLoading) { - return ( -
-
-
-
-
-
-
- ); - } - - return ( -
-
- {user?.banner ? ( - banner - ) : null} -
-
- - - - {pubkey} - - -
-

- {user?.name || user?.display_name} -

-

- {user?.about || "No bio"} -

-
-
-
- ); - } - - if (variant === "simple") { - if (isLoading) { - return ( -
-
-
-
-
-
-
- ); - } - - return ( -
- - - - {pubkey} - - -
-

- {user?.name || user?.display_name || user?.displayName} -

-

- {user?.nip05 || user?.username || fallbackName} -

-
-
- ); - } - - if (variant === "avatar") { - if (isLoading) { - return ( -
- ); - } - - return ( - - - - {pubkey} - - - ); - } - - if (variant === "miniavatar") { - if (isLoading) { - return ( -
- ); - } - - return ( - - - - {pubkey} - - - ); - } - - if (variant === "childnote") { - if (isLoading) { - return ( - <> - - - -
-
{fallbackName}
-
- {subtext}: -
-
- - ); - } - - return ( - <> - - - - {pubkey} - - -
-
- {user?.display_name || - user?.name || - user?.displayName || - fallbackName}{" "} -
-
- {subtext}: -
-
- - ); - } - - if (variant === "stacked") { - if (isLoading) { - return ( -
- ); - } - - return ( - - - - {pubkey} - - - ); - } - - if (variant === "ministacked") { - if (isLoading) { - return ( -
- ); - } - - return ( - - - - {pubkey} - - - ); - } - - if (variant === "repost") { - if (isLoading) { - return ( -
-
- -
-
-
-
-
-
- ); - } - - return ( -
-
- -
-
- - - - {pubkey} - - -
-
- {user?.name || - user?.display_name || - user?.displayName || - fallbackName} -
- reposted -
-
-
- ); - } - - if (variant === "thread") { - if (isLoading) { - return ( -
-
-
-
-
-
-
- ); - } - - return ( -
- - - - {pubkey} - - -
-
- {user?.name || user?.display_name || user?.displayName || "Anon"} -
-
- {createdAt} - · - {fallbackName} -
-
-
- ); - } - - if (isLoading) { - return ( -
- - - -
-
- {fallbackName} -
-
-
- ); - } - - return ( -
- - - - {pubkey} - - -
-
- {user?.name || - user?.display_name || - user?.displayName || - fallbackName} -
-
-
- {createdAt} -
-
-
-
- ); -}); 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