From 4fc3cc8a80a7cd3489eb876f07fb82738d79b1d6 Mon Sep 17 00:00:00 2001 From: reya <reya@lume.nu> Date: Thu, 28 Dec 2023 11:31:47 +0700 Subject: [PATCH] feat(column): add thread and user columns --- apps/desktop/package.json | 2 + apps/desktop/src/routes/home/index.tsx | 7 +- packages/@columns/thread/package.json | 26 +++ packages/@columns/thread/src/event.tsx | 28 +++ packages/@columns/thread/src/home.tsx | 13 ++ packages/@columns/thread/src/index.tsx | 18 ++ packages/@columns/thread/src/user.tsx | 213 ++++++++++++++++++ packages/@columns/thread/tailwind.config.js | 8 + packages/@columns/thread/tsconfig.json | 8 + packages/@columns/user/package.json | 26 +++ packages/@columns/user/src/event.tsx | 28 +++ packages/@columns/user/src/home.tsx | 202 +++++++++++++++++ packages/@columns/user/src/index.tsx | 23 ++ packages/@columns/user/src/user.tsx | 213 ++++++++++++++++++ packages/@columns/user/tailwind.config.js | 8 + packages/@columns/user/tsconfig.json | 8 + .../ark/src/components/note/preview/image.tsx | 2 +- .../ark/src/components/note/preview/link.tsx | 4 +- .../ark/src/components/note/preview/video.tsx | 30 +-- packages/ark/src/components/note/provider.tsx | 2 +- pnpm-lock.yaml | 74 +++++- 21 files changed, 921 insertions(+), 22 deletions(-) create mode 100644 packages/@columns/thread/package.json create mode 100644 packages/@columns/thread/src/event.tsx create mode 100644 packages/@columns/thread/src/home.tsx create mode 100644 packages/@columns/thread/src/index.tsx create mode 100644 packages/@columns/thread/src/user.tsx create mode 100644 packages/@columns/thread/tailwind.config.js create mode 100644 packages/@columns/thread/tsconfig.json create mode 100644 packages/@columns/user/package.json create mode 100644 packages/@columns/user/src/event.tsx create mode 100644 packages/@columns/user/src/home.tsx create mode 100644 packages/@columns/user/src/index.tsx create mode 100644 packages/@columns/user/src/user.tsx create mode 100644 packages/@columns/user/tailwind.config.js create mode 100644 packages/@columns/user/tsconfig.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 50b74f54..2e526e49 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,9 @@ }, "dependencies": { "@columns/notification": "workspace:^", + "@columns/thread": "workspace:^", "@columns/timeline": "workspace:^", + "@columns/user": "workspace:^", "@getalby/sdk": "^3.2.1", "@lume/ark": "workspace:^", "@lume/icons": "workspace:^", diff --git a/apps/desktop/src/routes/home/index.tsx b/apps/desktop/src/routes/home/index.tsx index 9b49aa91..81ae7732 100644 --- a/apps/desktop/src/routes/home/index.tsx +++ b/apps/desktop/src/routes/home/index.tsx @@ -1,5 +1,6 @@ -import { NotificationColumn } from "@columns/notification"; +import { Thread } from "@columns/thread"; import { Timeline } from "@columns/timeline"; +import { User } from "@columns/user"; import { useStorage } from "@lume/ark"; import { LoaderIcon } from "@lume/icons"; import { WidgetProps } from "@lume/types"; @@ -39,6 +40,10 @@ export function HomeScreen() { switch (widget.kind) { case WIDGET_KIND.newsfeed: return <Timeline key={widget.id} />; + case WIDGET_KIND.thread: + return <Thread key={widget.id} thread={widget} />; + case WIDGET_KIND.user: + return <User key={widget.id} user={widget} />; default: return <Timeline key={widget.id} />; } diff --git a/packages/@columns/thread/package.json b/packages/@columns/thread/package.json new file mode 100644 index 00000000..44b26cf3 --- /dev/null +++ b/packages/@columns/thread/package.json @@ -0,0 +1,26 @@ +{ + "name": "@columns/thread", + "version": "0.0.0", + "private": true, + "main": "./src/index.tsx", + "dependencies": { + "@lume/ark": "workspace:^", + "@lume/icons": "workspace:^", + "@lume/ui": "workspace:^", + "@lume/utils": "workspace:^", + "@nostr-dev-kit/ndk": "^2.3.1", + "@tanstack/react-query": "^5.14.2", + "react": "^18.2.0", + "react-router-dom": "^6.21.0", + "sonner": "^1.2.4", + "virtua": "^0.18.0" + }, + "devDependencies": { + "@lume/tailwindcss": "workspace:^", + "@lume/tsconfig": "workspace:^", + "@lume/types": "workspace:^", + "@types/react": "^18.2.45", + "tailwind": "^4.0.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/@columns/thread/src/event.tsx b/packages/@columns/thread/src/event.tsx new file mode 100644 index 00000000..2aa10da8 --- /dev/null +++ b/packages/@columns/thread/src/event.tsx @@ -0,0 +1,28 @@ +import { Note, ThreadNote } from "@lume/ark"; +import { ArrowLeftIcon } from "@lume/icons"; +import { useNavigate, useParams } from "react-router-dom"; +import { WVList } from "virtua"; + +export function EventRoute() { + const { id } = useParams(); + const navigate = useNavigate(); + + return ( + <WVList className="pb-5 overflow-y-auto"> + <div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3"> + <button + type="button" + className="inline-flex items-center gap-2.5 text-sm font-medium" + onClick={() => navigate(-1)} + > + <ArrowLeftIcon className="size-4" /> + Back + </button> + </div> + <div className="px-3"> + <ThreadNote eventId={id} /> + <Note.ReplyList eventId={id} title="All replies" className="mt-5" /> + </div> + </WVList> + ); +} diff --git a/packages/@columns/thread/src/home.tsx b/packages/@columns/thread/src/home.tsx new file mode 100644 index 00000000..47f381d3 --- /dev/null +++ b/packages/@columns/thread/src/home.tsx @@ -0,0 +1,13 @@ +import { Note, ThreadNote } from "@lume/ark"; +import { WVList } from "virtua"; + +export function HomeRoute({ id }: { id: string }) { + return ( + <WVList className="pb-5 overflow-y-auto"> + <div className="px-3"> + <ThreadNote eventId={id} /> + <Note.ReplyList eventId={id} title="All replies" className="mt-5" /> + </div> + </WVList> + ); +} diff --git a/packages/@columns/thread/src/index.tsx b/packages/@columns/thread/src/index.tsx new file mode 100644 index 00000000..a7e0b415 --- /dev/null +++ b/packages/@columns/thread/src/index.tsx @@ -0,0 +1,18 @@ +import { Column } from "@lume/ark"; +import { WidgetProps } from "@lume/types"; +import { EventRoute } from "./event"; +import { HomeRoute } from "./home"; +import { UserRoute } from "./user"; + +export function Thread({ thread }: { thread: WidgetProps }) { + return ( + <Column.Root> + <Column.Header id={thread.id} title={thread.title} /> + <Column.Content> + <Column.Route path="/" element={<HomeRoute id={thread.content} />} /> + <Column.Route path="/events/:id" element={<EventRoute />} /> + <Column.Route path="/users/:id" element={<UserRoute />} /> + </Column.Content> + </Column.Root> + ); +} diff --git a/packages/@columns/thread/src/user.tsx b/packages/@columns/thread/src/user.tsx new file mode 100644 index 00000000..8d46f957 --- /dev/null +++ b/packages/@columns/thread/src/user.tsx @@ -0,0 +1,213 @@ +import { + RepostNote, + TextNote, + useArk, + useProfile, + useStorage, +} from "@lume/ark"; +import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons"; +import { NIP05 } from "@lume/ui"; +import { FETCH_LIMIT, displayNpub } from "@lume/utils"; +import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { WVList } from "virtua"; + +export function UserRoute() { + const ark = useArk(); + const storage = useStorage(); + const navigate = useNavigate(); + + const { id } = useParams(); + const { user } = useProfile(id); + 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, + }); + + const [followed, setFollowed] = useState(false); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), + [data], + ); + + const follow = async (pubkey: string) => { + try { + const add = await ark.createContact({ pubkey }); + if (add) { + setFollowed(true); + } else { + toast.success("You already follow this user"); + } + } catch (error) { + console.log(error); + } + }; + + const unfollow = async (pubkey: string) => { + try { + const remove = await ark.deleteContact({ pubkey }); + if (remove) { + setFollowed(false); + } + } catch (error) { + console.log(error); + } + }; + + const renderItem = (event: NDKEvent) => { + switch (event.kind) { + case NDKKind.Text: + return <TextNote key={event.id} event={event} className="mt-3" />; + case NDKKind.Repost: + return <RepostNote key={event.id} event={event} className="mt-3" />; + default: + return <TextNote key={event.id} event={event} className="mt-3" />; + } + }; + + useEffect(() => { + if (storage.account.contacts.includes(id)) { + setFollowed(true); + } + }, []); + + return ( + <WVList className="pb-5 overflow-y-auto"> + <div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3"> + <button + type="button" + className="inline-flex items-center gap-2.5 text-sm font-medium" + onClick={() => navigate(-1)} + > + <ArrowLeftIcon className="size-4" /> + Back + </button> + </div> + <div className="px-3"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <img + src={user?.picture || user?.image} + alt={id} + className="h-12 w-12 shrink-0 rounded-lg object-cover" + loading="lazy" + decoding="async" + /> + <div className="inline-flex items-center gap-2"> + {followed ? ( + <button + type="button" + onClick={() => unfollow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Unfollow + </button> + ) : ( + <button + type="button" + onClick={() => follow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Follow + </button> + )} + <Link + to={`/chats/${id}`} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Message + </Link> + </div> + </div> + <div className="flex flex-1 flex-col gap-1.5"> + <div className="flex flex-col"> + <h5 className="text-lg font-semibold"> + {user?.name || + user?.display_name || + user?.displayName || + "Anon"} + </h5> + {user?.nip05 ? ( + <NIP05 + pubkey={id} + nip05={user?.nip05} + className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400" + /> + ) : ( + <span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"> + {displayNpub(id, 16)} + </span> + )} + </div> + <div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100"> + {user?.about} + </div> + </div> + </div> + <div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900"> + <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> + Latest posts + </h3> + <div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10"> + {isLoading ? ( + <div className="flex items-center justify-center"> + <LoaderIcon className="h-4 w-4 animate-spin" /> + </div> + ) : ( + allEvents.map((item) => renderItem(item)) + )} + <div className="flex h-16 items-center justify-center px-3 pb-3"> + {hasNextPage ? ( + <button + type="button" + onClick={() => fetchNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none" + > + {isFetchingNextPage ? ( + <LoaderIcon className="h-4 w-4 animate-spin" /> + ) : ( + <> + <ArrowRightCircleIcon className="h-5 w-5" /> + Load more + </> + )} + </button> + ) : null} + </div> + </div> + </div> + </div> + </WVList> + ); +} diff --git a/packages/@columns/thread/tailwind.config.js b/packages/@columns/thread/tailwind.config.js new file mode 100644 index 00000000..49c48c7a --- /dev/null +++ b/packages/@columns/thread/tailwind.config.js @@ -0,0 +1,8 @@ +import sharedConfig from "@lume/tailwindcss"; + +const config = { + content: ["./src/**/*.{js,ts,jsx,tsx}"], + presets: [sharedConfig], +}; + +export default config; diff --git a/packages/@columns/thread/tsconfig.json b/packages/@columns/thread/tsconfig.json new file mode 100644 index 00000000..34a32891 --- /dev/null +++ b/packages/@columns/thread/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@lume/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/@columns/user/package.json b/packages/@columns/user/package.json new file mode 100644 index 00000000..bb5d61fc --- /dev/null +++ b/packages/@columns/user/package.json @@ -0,0 +1,26 @@ +{ + "name": "@columns/user", + "version": "0.0.0", + "private": true, + "main": "./src/index.tsx", + "dependencies": { + "@lume/ark": "workspace:^", + "@lume/icons": "workspace:^", + "@lume/ui": "workspace:^", + "@lume/utils": "workspace:^", + "@nostr-dev-kit/ndk": "^2.3.1", + "@tanstack/react-query": "^5.14.2", + "react": "^18.2.0", + "react-router-dom": "^6.21.0", + "sonner": "^1.2.4", + "virtua": "^0.18.0" + }, + "devDependencies": { + "@lume/tailwindcss": "workspace:^", + "@lume/tsconfig": "workspace:^", + "@lume/types": "workspace:^", + "@types/react": "^18.2.45", + "tailwind": "^4.0.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/@columns/user/src/event.tsx b/packages/@columns/user/src/event.tsx new file mode 100644 index 00000000..2aa10da8 --- /dev/null +++ b/packages/@columns/user/src/event.tsx @@ -0,0 +1,28 @@ +import { Note, ThreadNote } from "@lume/ark"; +import { ArrowLeftIcon } from "@lume/icons"; +import { useNavigate, useParams } from "react-router-dom"; +import { WVList } from "virtua"; + +export function EventRoute() { + const { id } = useParams(); + const navigate = useNavigate(); + + return ( + <WVList className="pb-5 overflow-y-auto"> + <div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3"> + <button + type="button" + className="inline-flex items-center gap-2.5 text-sm font-medium" + onClick={() => navigate(-1)} + > + <ArrowLeftIcon className="size-4" /> + Back + </button> + </div> + <div className="px-3"> + <ThreadNote eventId={id} /> + <Note.ReplyList eventId={id} title="All replies" className="mt-5" /> + </div> + </WVList> + ); +} diff --git a/packages/@columns/user/src/home.tsx b/packages/@columns/user/src/home.tsx new file mode 100644 index 00000000..30f960e9 --- /dev/null +++ b/packages/@columns/user/src/home.tsx @@ -0,0 +1,202 @@ +import { + RepostNote, + TextNote, + useArk, + useProfile, + useStorage, +} from "@lume/ark"; +import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons"; +import { NIP05 } from "@lume/ui"; +import { FETCH_LIMIT, displayNpub } from "@lume/utils"; +import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { WVList } from "virtua"; + +export function HomeRoute({ id }: { id: string }) { + const ark = useArk(); + const storage = useStorage(); + const navigate = useNavigate(); + + const { user } = useProfile(id); + 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, + }); + + const [followed, setFollowed] = useState(false); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), + [data], + ); + + const follow = async (pubkey: string) => { + try { + const add = await ark.createContact({ pubkey }); + if (add) { + setFollowed(true); + } else { + toast.success("You already follow this user"); + } + } catch (error) { + console.log(error); + } + }; + + const unfollow = async (pubkey: string) => { + try { + const remove = await ark.deleteContact({ pubkey }); + if (remove) { + setFollowed(false); + } + } catch (error) { + console.log(error); + } + }; + + const renderItem = (event: NDKEvent) => { + switch (event.kind) { + case NDKKind.Text: + return <TextNote key={event.id} event={event} className="mt-3" />; + case NDKKind.Repost: + return <RepostNote key={event.id} event={event} className="mt-3" />; + default: + return <TextNote key={event.id} event={event} className="mt-3" />; + } + }; + + useEffect(() => { + if (storage.account.contacts.includes(id)) { + setFollowed(true); + } + }, []); + + return ( + <WVList className="py-5 overflow-y-auto"> + <div className="px-3"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <img + src={user?.picture || user?.image} + alt={id} + className="h-12 w-12 shrink-0 rounded-lg object-cover" + loading="lazy" + decoding="async" + /> + <div className="inline-flex items-center gap-2"> + {followed ? ( + <button + type="button" + onClick={() => unfollow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Unfollow + </button> + ) : ( + <button + type="button" + onClick={() => follow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Follow + </button> + )} + <Link + to={`/chats/${id}`} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Message + </Link> + </div> + </div> + <div className="flex flex-1 flex-col gap-1.5"> + <div className="flex flex-col"> + <h5 className="text-lg font-semibold"> + {user?.name || + user?.display_name || + user?.displayName || + "Anon"} + </h5> + {user?.nip05 ? ( + <NIP05 + pubkey={id} + nip05={user?.nip05} + className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400" + /> + ) : ( + <span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"> + {displayNpub(id, 16)} + </span> + )} + </div> + <div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100"> + {user?.about} + </div> + </div> + </div> + <div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900"> + <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> + Latest posts + </h3> + <div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10"> + {isLoading ? ( + <div className="flex items-center justify-center"> + <LoaderIcon className="h-4 w-4 animate-spin" /> + </div> + ) : ( + allEvents.map((item) => renderItem(item)) + )} + <div className="flex h-16 items-center justify-center px-3 pb-3"> + {hasNextPage ? ( + <button + type="button" + onClick={() => fetchNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none" + > + {isFetchingNextPage ? ( + <LoaderIcon className="h-4 w-4 animate-spin" /> + ) : ( + <> + <ArrowRightCircleIcon className="h-5 w-5" /> + Load more + </> + )} + </button> + ) : null} + </div> + </div> + </div> + </div> + </WVList> + ); +} diff --git a/packages/@columns/user/src/index.tsx b/packages/@columns/user/src/index.tsx new file mode 100644 index 00000000..e34c21cd --- /dev/null +++ b/packages/@columns/user/src/index.tsx @@ -0,0 +1,23 @@ +import { Column } from "@lume/ark"; +import { UserIcon } from "@lume/icons"; +import { WidgetProps } from "@lume/types"; +import { EventRoute } from "./event"; +import { HomeRoute } from "./home"; +import { UserRoute } from "./user"; + +export function User({ user }: { user: WidgetProps }) { + return ( + <Column.Root> + <Column.Header + id={user.id} + title={user.title} + icon={<UserIcon className="size-4" />} + /> + <Column.Content> + <Column.Route path="/" element={<HomeRoute id={user.content} />} /> + <Column.Route path="/events/:id" element={<EventRoute />} /> + <Column.Route path="/users/:id" element={<UserRoute />} /> + </Column.Content> + </Column.Root> + ); +} diff --git a/packages/@columns/user/src/user.tsx b/packages/@columns/user/src/user.tsx new file mode 100644 index 00000000..8d46f957 --- /dev/null +++ b/packages/@columns/user/src/user.tsx @@ -0,0 +1,213 @@ +import { + RepostNote, + TextNote, + useArk, + useProfile, + useStorage, +} from "@lume/ark"; +import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons"; +import { NIP05 } from "@lume/ui"; +import { FETCH_LIMIT, displayNpub } from "@lume/utils"; +import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { WVList } from "virtua"; + +export function UserRoute() { + const ark = useArk(); + const storage = useStorage(); + const navigate = useNavigate(); + + const { id } = useParams(); + const { user } = useProfile(id); + 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, + }); + + const [followed, setFollowed] = useState(false); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), + [data], + ); + + const follow = async (pubkey: string) => { + try { + const add = await ark.createContact({ pubkey }); + if (add) { + setFollowed(true); + } else { + toast.success("You already follow this user"); + } + } catch (error) { + console.log(error); + } + }; + + const unfollow = async (pubkey: string) => { + try { + const remove = await ark.deleteContact({ pubkey }); + if (remove) { + setFollowed(false); + } + } catch (error) { + console.log(error); + } + }; + + const renderItem = (event: NDKEvent) => { + switch (event.kind) { + case NDKKind.Text: + return <TextNote key={event.id} event={event} className="mt-3" />; + case NDKKind.Repost: + return <RepostNote key={event.id} event={event} className="mt-3" />; + default: + return <TextNote key={event.id} event={event} className="mt-3" />; + } + }; + + useEffect(() => { + if (storage.account.contacts.includes(id)) { + setFollowed(true); + } + }, []); + + return ( + <WVList className="pb-5 overflow-y-auto"> + <div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3"> + <button + type="button" + className="inline-flex items-center gap-2.5 text-sm font-medium" + onClick={() => navigate(-1)} + > + <ArrowLeftIcon className="size-4" /> + Back + </button> + </div> + <div className="px-3"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <img + src={user?.picture || user?.image} + alt={id} + className="h-12 w-12 shrink-0 rounded-lg object-cover" + loading="lazy" + decoding="async" + /> + <div className="inline-flex items-center gap-2"> + {followed ? ( + <button + type="button" + onClick={() => unfollow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Unfollow + </button> + ) : ( + <button + type="button" + onClick={() => follow(id)} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Follow + </button> + )} + <Link + to={`/chats/${id}`} + className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800" + > + Message + </Link> + </div> + </div> + <div className="flex flex-1 flex-col gap-1.5"> + <div className="flex flex-col"> + <h5 className="text-lg font-semibold"> + {user?.name || + user?.display_name || + user?.displayName || + "Anon"} + </h5> + {user?.nip05 ? ( + <NIP05 + pubkey={id} + nip05={user?.nip05} + className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400" + /> + ) : ( + <span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"> + {displayNpub(id, 16)} + </span> + )} + </div> + <div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100"> + {user?.about} + </div> + </div> + </div> + <div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900"> + <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> + Latest posts + </h3> + <div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10"> + {isLoading ? ( + <div className="flex items-center justify-center"> + <LoaderIcon className="h-4 w-4 animate-spin" /> + </div> + ) : ( + allEvents.map((item) => renderItem(item)) + )} + <div className="flex h-16 items-center justify-center px-3 pb-3"> + {hasNextPage ? ( + <button + type="button" + onClick={() => fetchNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none" + > + {isFetchingNextPage ? ( + <LoaderIcon className="h-4 w-4 animate-spin" /> + ) : ( + <> + <ArrowRightCircleIcon className="h-5 w-5" /> + Load more + </> + )} + </button> + ) : null} + </div> + </div> + </div> + </div> + </WVList> + ); +} diff --git a/packages/@columns/user/tailwind.config.js b/packages/@columns/user/tailwind.config.js new file mode 100644 index 00000000..49c48c7a --- /dev/null +++ b/packages/@columns/user/tailwind.config.js @@ -0,0 +1,8 @@ +import sharedConfig from "@lume/tailwindcss"; + +const config = { + content: ["./src/**/*.{js,ts,jsx,tsx}"], + presets: [sharedConfig], +}; + +export default config; diff --git a/packages/@columns/user/tsconfig.json b/packages/@columns/user/tsconfig.json new file mode 100644 index 00000000..34a32891 --- /dev/null +++ b/packages/@columns/user/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@lume/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ark/src/components/note/preview/image.tsx b/packages/ark/src/components/note/preview/image.tsx index bf90af2e..2743b7ff 100644 --- a/packages/ark/src/components/note/preview/image.tsx +++ b/packages/ark/src/components/note/preview/image.tsx @@ -31,7 +31,7 @@ export function ImagePreview({ url }: { url: string }) { return ( // biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> - <div onClick={open} className="group relative my-2"> + <div onClick={open} className="group relative"> <img src={url} alt={url} diff --git a/packages/ark/src/components/note/preview/link.tsx b/packages/ark/src/components/note/preview/link.tsx index 4c737d7f..bb448a40 100644 --- a/packages/ark/src/components/note/preview/link.tsx +++ b/packages/ark/src/components/note/preview/link.tsx @@ -11,7 +11,7 @@ export function LinkPreview({ url }: { url: string }) { if (status === "pending") { return ( - <div className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"> + <div className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"> <div className="h-48 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" /> <div className="flex flex-col gap-2 px-3 py-3"> <div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" /> @@ -42,7 +42,7 @@ export function LinkPreview({ url }: { url: string }) { to={url} target="_blank" rel="noreferrer" - className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900" + className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900" > {isImage(data.image) ? ( <img diff --git a/packages/ark/src/components/note/preview/video.tsx b/packages/ark/src/components/note/preview/video.tsx index a21ab4ae..5c3b757c 100644 --- a/packages/ark/src/components/note/preview/video.tsx +++ b/packages/ark/src/components/note/preview/video.tsx @@ -1,19 +1,19 @@ -import { MediaPlayer, MediaProvider } from '@vidstack/react'; +import { MediaPlayer, MediaProvider } from "@vidstack/react"; import { - DefaultVideoLayout, - defaultLayoutIcons, -} from '@vidstack/react/player/layouts/default'; + DefaultVideoLayout, + defaultLayoutIcons, +} from "@vidstack/react/player/layouts/default"; export function VideoPreview({ url }: { url: string }) { - return ( - <MediaPlayer - src={url} - className="my-2 w-full overflow-hidden rounded-lg" - aspectRatio="16/9" - load="visible" - > - <MediaProvider /> - <DefaultVideoLayout icons={defaultLayoutIcons} /> - </MediaPlayer> - ); + return ( + <MediaPlayer + src={url} + className="w-full overflow-hidden rounded-lg" + aspectRatio="16/9" + load="visible" + > + <MediaProvider /> + <DefaultVideoLayout icons={defaultLayoutIcons} /> + </MediaPlayer> + ); } diff --git a/packages/ark/src/components/note/provider.tsx b/packages/ark/src/components/note/provider.tsx index d5ee6920..c2cf4d45 100644 --- a/packages/ark/src/components/note/provider.tsx +++ b/packages/ark/src/components/note/provider.tsx @@ -14,7 +14,7 @@ export function NoteProvider({ export function useNoteContext() { const context = useContext(EventContext); - if (context === undefined) { + if (!context) { throw new Error("Please import Note Provider to use useNoteContext() hook"); } return context; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2413c3f..d42f5031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,15 @@ importers: '@columns/notification': specifier: workspace:^ version: link:../../packages/@columns/notification + '@columns/thread': + specifier: workspace:^ + version: link:../../packages/@columns/thread '@columns/timeline': specifier: workspace:^ version: link:../../packages/@columns/timeline + '@columns/user': + specifier: workspace:^ + version: link:../../packages/@columns/user '@getalby/sdk': specifier: ^3.2.1 version: 3.2.1(typescript@5.3.3) @@ -283,7 +289,7 @@ importers: specifier: ^4.2.2 version: 4.2.2(typescript@5.3.3)(vite@4.5.1) - packages/@columns/newsfeed: + packages/@columns/notification: dependencies: '@lume/ark': specifier: workspace:^ @@ -323,7 +329,7 @@ importers: specifier: ^5.3.3 version: 5.3.3 - packages/@columns/notification: + packages/@columns/thread: dependencies: '@lume/ark': specifier: workspace:^ @@ -331,6 +337,9 @@ importers: '@lume/icons': specifier: workspace:^ version: link:../../icons + '@lume/ui': + specifier: workspace:^ + version: link:../../ui '@lume/utils': specifier: workspace:^ version: link:../../utils @@ -343,6 +352,12 @@ importers: react: specifier: ^18.2.0 version: 18.2.0 + react-router-dom: + specifier: ^6.21.0 + version: 6.21.0(react-dom@18.2.0)(react@18.2.0) + sonner: + specifier: ^1.2.4 + version: 1.2.4(react-dom@18.2.0)(react@18.2.0) virtua: specifier: ^0.18.0 version: 0.18.0(react-dom@18.2.0)(react@18.2.0) @@ -353,6 +368,9 @@ importers: '@lume/tsconfig': specifier: workspace:^ version: link:../../tsconfig + '@lume/types': + specifier: workspace:^ + version: link:../../types '@types/react': specifier: ^18.2.45 version: 18.2.45 @@ -412,6 +430,58 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/@columns/user: + dependencies: + '@lume/ark': + specifier: workspace:^ + version: link:../../ark + '@lume/icons': + specifier: workspace:^ + version: link:../../icons + '@lume/ui': + specifier: workspace:^ + version: link:../../ui + '@lume/utils': + specifier: workspace:^ + version: link:../../utils + '@nostr-dev-kit/ndk': + specifier: ^2.3.1 + version: 2.3.1(typescript@5.3.3) + '@tanstack/react-query': + specifier: ^5.14.2 + version: 5.14.2(react@18.2.0) + react: + specifier: ^18.2.0 + version: 18.2.0 + react-router-dom: + specifier: ^6.21.0 + version: 6.21.0(react-dom@18.2.0)(react@18.2.0) + sonner: + specifier: ^1.2.4 + version: 1.2.4(react-dom@18.2.0)(react@18.2.0) + virtua: + specifier: ^0.18.0 + version: 0.18.0(react-dom@18.2.0)(react@18.2.0) + devDependencies: + '@lume/tailwindcss': + specifier: workspace:^ + version: link:../../tailwindcss + '@lume/tsconfig': + specifier: workspace:^ + version: link:../../tsconfig + '@lume/types': + specifier: workspace:^ + version: link:../../types + '@types/react': + specifier: ^18.2.45 + version: 18.2.45 + tailwind: + specifier: ^4.0.0 + version: 4.0.0 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages/ark: dependencies: '@getalby/sdk':