refactor(ark): rename widget to column

This commit is contained in:
reya 2023-12-26 13:44:38 +07:00
parent 227c2ddefa
commit e1db873bd5
34 changed files with 800 additions and 892 deletions

View File

@ -1,2 +0,0 @@
export * from './newsfeed';
export * from './notification';

View File

@ -1,104 +0,0 @@
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { VList, VListHandle } from "virtua";
export function NewsfeedWidget() {
const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["newsfeed"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !storage.account.contacts.length
? [storage.account.pubkey]
: storage.account.contacts,
},
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 allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Widget.Root>
<Widget.Header
id="9999"
queryKey={["newsfeed"]}
title="Timeline"
icon={<TimelineIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList ref={ref} overscan={2} className="flex-1">
{isLoading ? (
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<LoaderIcon className="size-5" />
Loading
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-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-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,174 +0,0 @@
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from "@lume/ark";
import {
AnnouncementIcon,
ArrowRightCircleIcon,
LoaderIcon,
} from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind, NDKSubscription } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { sendNativeNotification } from "apps/desktop/src/utils/notification";
import { useEffect, useMemo } from "react";
import { VList } from "virtua";
export function NotificationWidget() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["notification"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [
NDKKind.Text,
NDKKind.Repost,
NDKKind.Reaction,
NDKKind.Zap,
],
"#p": [storage.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderEvent = (event: NDKEvent) => {
if (event.pubkey === storage.account.pubkey) return null;
return <TextNote key={event.id} event={event} />;
};
useEffect(() => {
let sub: NDKSubscription = undefined;
if (status === "success" && storage.account) {
const filter = {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
"#p": [storage.account.pubkey],
since: Math.floor(Date.now() / 1000),
};
sub = ark.subscribe({
filter,
closeOnEose: false,
cb: async (event) => {
queryClient.setQueryData(
["notification"],
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
...prev,
pages: [[event], ...prev.pages],
}),
);
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has replied to your note`,
);
case NDKKind.Repost:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has reposted to your note`,
);
case NDKKind.Reaction:
return await sendNativeNotification(
`${profile.displayName || profile.name} has reacted ${
event.content
} to your note`,
);
case NDKKind.Zap:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has zapped to your note`,
);
default:
break;
}
},
});
}
return () => {
if (sub) sub.stop();
};
}, [status]);
return (
<Widget.Root>
<Widget.Header
id="9998"
queryKey={["notification"]}
title="Notification"
icon={<AnnouncementIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList className="flex-1" overscan={2}>
{status === "pending" ? (
<NoteSkeleton />
) : allEvents.length < 1 ? (
<div className="my-3 flex w-full items-center justify-center gap-2">
<div>🎉</div>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<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>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,5 +1,5 @@
import { NotificationColumn } from "@columns/notification";
import { TimelineColumn } from "@columns/timeline";
import { Timeline } from "@columns/timeline";
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { WidgetProps } from "@lume/types";
@ -37,12 +37,10 @@ export function HomeScreen() {
const renderItem = (widget: WidgetProps) => {
switch (widget.kind) {
case WIDGET_KIND.notification:
return <NotificationColumn key={widget.id} />;
case WIDGET_KIND.newsfeed:
return <TimelineColumn key={widget.id} />;
return <Timeline key={widget.id} />;
default:
return <TimelineColumn key={widget.id} />;
return <Timeline key={widget.id} />;
}
};

View File

@ -3,6 +3,7 @@ import sharedConfig from "@lume/tailwindcss";
const config = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
"index.html",

View File

@ -11,7 +11,7 @@
"noNonNullAssertion": "warn"
},
"correctness": {
"useExhaustiveDependencies": "warn"
"useExhaustiveDependencies": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"

View File

@ -2,7 +2,7 @@
"name": "@columns/timeline",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"main": "./src/index.tsx",
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
@ -10,6 +10,7 @@
"@nostr-dev-kit/ndk": "^2.3.1",
"@tanstack/react-query": "^5.14.2",
"react": "^18.2.0",
"react-router-dom": "^6.21.0",
"virtua": "^0.18.0"
},
"devDependencies": {

View File

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

View File

@ -0,0 +1,119 @@
import { RepostNote, TextNote, useArk, useStorage } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { CacheSnapshot, VList, VListHandle } from "virtua";
export function HomeRoute() {
const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>();
const cacheKey = "newsfeed-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: ["newsfeed"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !storage.account.contacts.length
? [storage.account.pubkey]
: storage.account.contacts,
},
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 allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} className="mt-3" />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
useEffect(() => {
if (!ref.current) return;
const handle = ref.current;
if (offset) {
handle.scrollTo(offset);
}
return () => {
sessionStorage.setItem(
cacheKey,
JSON.stringify([handle.scrollOffset, handle.cache]),
);
};
}, []);
return (
<div className="w-full h-full">
<VList ref={ref} cache={cache} overscan={2} className="flex-1 px-3">
{isLoading ? (
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<LoaderIcon className="size-5" />
Loading
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-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-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</div>
);
}

View File

@ -1 +0,0 @@
export * from "./timeline";

View File

@ -0,0 +1,23 @@
import { Column } from "@lume/ark";
import { TimelineIcon } from "@lume/icons";
import { EventRoute } from "./event";
import { HomeRoute } from "./home";
import { UserRoute } from "./user";
export function Timeline() {
return (
<Column.Root>
<Column.Header
id="9999"
queryKey={["newsfeed"]}
title="Timeline"
icon={<TimelineIcon className="size-4" />}
/>
<Column.Content>
<Column.Route path="/" element={<HomeRoute />} />
<Column.Route path="/events/:id" element={<EventRoute />} />
<Column.Route path="/users/:id" element={<UserRoute />} />
</Column.Content>
</Column.Root>
);
}

View File

@ -1,104 +0,0 @@
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { VList, VListHandle } from "virtua";
export function TimelineColumn() {
const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["newsfeed"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !storage.account.contacts.length
? [storage.account.pubkey]
: storage.account.contacts,
},
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 allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Widget.Root>
<Widget.Header
id="9999"
queryKey={["newsfeed"]}
title="Timeline"
icon={<TimelineIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList ref={ref} overscan={2} className="flex-1">
{isLoading ? (
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<LoaderIcon className="size-5" />
Loading
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-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-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -0,0 +1,7 @@
import { useParams } from "react-router-dom";
export function UserRoute() {
const { id } = useParams();
return <div>{id}</div>;
}

View File

@ -0,0 +1,6 @@
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
export function ColumnContent({ children }: { children: ReactNode }) {
return <Routes>{children}</Routes>;
}

View File

@ -0,0 +1,112 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { ReactNode } from "react";
import { useWidget } from "../../hooks/useWidget";
export function ColumnHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 shrink-0 rounded-full bg-blue-500" />
<div className="text-neutral-800 dark:text-neutral-200 inline-flex items-center gap-2 flex-1">
{icon ? icon : <ThreadIcon className="size-4" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="size-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="size-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="size-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="size-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnRoot } from "./root";
export const Column = {
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,
Content: ColumnContent,
Route: Route,
};

View File

@ -0,0 +1,42 @@
import { ChevronUpIcon } from "@lume/icons";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { useArk } from "../../provider";
export function ColumnLiveWidget({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? "event" : "events"}
</button>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { MemoryRouter, UNSAFE_LocationContext } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<UNSAFE_LocationContext.Provider value={null}>
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
<MemoryRouter future={{ v7_startTransition: true }}>
{children}
</MemoryRouter>
</Resizable>
</UNSAFE_LocationContext.Provider>
);
}

View File

@ -23,10 +23,10 @@ export function Reply({
className="h-14 px-3"
/>
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex items-center justify-between">
<div className="-ml-1 flex items-center justify-between h-14 px-3">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<div className="ml-1 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge(
"h-3 w-3",
@ -54,17 +54,14 @@ export function Reply({
<Note.User pubkey={event.pubkey} time={event.created_at} />
<Note.TextContent
content={event.content}
className="min-w-0 px-3"
className="min-w-0"
/>
<div className="-ml-1 flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<div className="-ml-1 flex h-14 items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
))}
</Collapsible.Content>

View File

@ -2,12 +2,15 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useArk } from "../../../provider";
export function TextNote({ event }: { event: NDKEvent }) {
export function TextNote({
event,
className,
}: { event: NDKEvent; className?: string }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<Note.Root>
<Note.Root className={className}>
<Note.User
pubkey={event.pubkey}
time={event.created_at}

View File

@ -0,0 +1,55 @@
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useArk } from "../../../provider";
export function ThreadNote({ eventId }: { eventId: string }) {
const ark = useArk();
const { isLoading, data } = useEvent(eventId);
const renderEventKind = (event: NDKEvent) => {
const thread = ark.getEventThread({ tags: data.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={data.content} className="min-w-0 px-3" />
</>
);
case NDKKind.Article:
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
case 1063:
return <Note.MediaContent tags={event.tags} />;
default:
return (
<Note.TextContent content={data.content} className="min-w-0 px-3" />
);
}
};
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Note.Root>
<Note.User
pubkey={data.pubkey}
time={data.created_at}
variant="thread"
className="h-16 px-3"
/>
{renderEventKind(data)}
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={data.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={data.id} />
<Note.Reaction event={data} />
<Note.Repost event={data} />
<Note.Zap event={data} />
</div>
</div>
</Note.Root>
);
}

View File

@ -8,7 +8,7 @@ import { NoteArticleContent } from "./kinds/article";
import { NoteMediaContent } from "./kinds/media";
import { NoteTextContent } from "./kinds/text";
import { NoteMenu } from "./menu";
import { NoteReplies } from "./reply";
import { NoteReplyList } from "./reply";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
@ -27,12 +27,13 @@ export const Note = {
TextContent: NoteTextContent,
MediaContent: NoteMediaContent,
ArticleContent: NoteArticleContent,
Replies: NoteReplies,
ReplyList: NoteReplyList,
};
export * from "./builds/text";
export * from "./builds/repost";
export * from "./builds/skeleton";
export * from "./builds/thread";
export * from "./preview/image";
export * from "./preview/link";
export * from "./preview/video";

View File

@ -1,15 +1,13 @@
import { WIDGET_KIND } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { memo } from "react";
import { Link } from "react-router-dom";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useWidget } from "../../../hooks/useWidget";
export const MentionNote = memo(function MentionNote({
eventId,
}: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
@ -51,19 +49,12 @@ export const MentionNote = memo(function MentionNote({
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: data.id,
})
}
<Link
to={`/events/${data.id}`}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</Link>
</div>
</Note.Root>
);

View File

@ -1,11 +1,16 @@
import { LoaderIcon } from '@lume/icons';
import { NDKEventWithReplies } from '@lume/types';
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
import { Reply } from './builds/reply';
import { LoaderIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import { NDKSubscription } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useArk } from "../../provider";
import { Reply } from "./builds/reply";
export function NoteReplies({ eventId }: { eventId: string }) {
export function NoteReplyList({
eventId,
title,
className,
}: { eventId: string; title?: string; className?: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
@ -21,7 +26,7 @@ export function NoteReplies({ eventId }: { eventId: string }) {
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
"#e": [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
@ -37,21 +42,15 @@ export function NoteReplies({ eventId }: { eventId: string }) {
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className={twMerge("flex flex-col gap-5", className)}>
<h3 className="font-semibold">{title}</h3>
{!data ? (
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
@ -60,7 +59,9 @@ export function NoteReplies({ eventId }: { eventId: string }) {
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
data.map((event) => (
<Reply key={event.id} event={event} rootEvent={eventId} />
))
)}
</div>
);

View File

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export function NoteRoot({
children,
@ -11,8 +11,8 @@ export function NoteRoot({
return (
<div
className={twMerge(
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
className
"flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
{children}

View File

@ -1,7 +1,6 @@
import { WIDGET_KIND } from '@lume/utils';
import { twMerge } from 'tailwind-merge';
import { Note } from '.';
import { useWidget } from '../../hooks/useWidget';
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Note } from ".";
export function NoteThread({
thread,
@ -10,28 +9,23 @@ export function NoteThread({
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<div className={twMerge("w-full px-3", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</Link>
</div>
</div>
);

View File

@ -1,32 +1,35 @@
import { RepostIcon } from '@lume/icons';
import { displayNpub, formatCreatedAt } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useProfile } from '../../hooks/useProfile';
import { RepostIcon } from "@lume/icons";
import { displayNpub, formatCreatedAt } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { twMerge } from "tailwind-merge";
import { useProfile } from "../../hooks/useProfile";
export function NoteUser({
pubkey,
time,
variant = 'text',
variant = "text",
className,
}: {
pubkey: string;
time: number;
variant?: 'text' | 'repost' | 'mention';
variant?: "text" | "repost" | "mention" | "thread";
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
const { isLoading, user } = useProfile(pubkey);
if (variant === 'mention') {
if (variant === "mention") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
@ -42,7 +45,9 @@ export function NoteUser({
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
@ -68,19 +73,24 @@ export function NoteUser({
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
if (variant === 'repost') {
if (variant === "repost") {
if (isLoading) {
return (
<div className={twMerge('flex gap-3', className)}>
<div className={twMerge("flex gap-3", className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
@ -93,7 +103,7 @@ export function NoteUser({
}
return (
<div className={twMerge('flex gap-2', className)}>
<div className={twMerge("flex gap-2", className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
@ -116,7 +126,10 @@ export function NoteUser({
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
@ -125,9 +138,54 @@ export function NoteUser({
);
}
if (variant === "thread") {
if (isLoading) {
return (
<div className={twMerge('flex items-center gap-3', className)}>
<div className="flex h-16 items-center gap-3 px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex h-16 items-center gap-3 px-3">
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 flex-col">
<h5 className="max-w-[15rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || "Anon"}
</h5>
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{createdAt}</span>
<span>·</span>
<span>{fallbackName}</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge("flex items-center gap-3", className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
@ -145,7 +203,7 @@ export function NoteUser({
}
return (
<div className={twMerge('flex items-center gap-3', className)}>
<div className={twMerge("flex items-center gap-3", className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
@ -164,9 +222,14 @@ export function NoteUser({
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
);

View File

@ -1,5 +0,0 @@
import { ReactNode } from 'react';
export function WidgetContent({ children }: { children: ReactNode }) {
return <div className="h-full w-full">{children}</div>;
}

View File

@ -1,112 +0,0 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from '@lume/icons';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useWidget } from '../../hooks/useWidget';
export function WidgetHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 rounded-full bg-blue-500" />
<div className="inline-flex items-center gap-2">
{icon ? icon : <ThreadIcon className="h-5 w-5" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="h-5 w-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="h-5 w-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="h-5 w-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@ -1,11 +0,0 @@
import { WidgetContent } from "./content";
import { WidgetHeader } from "./header";
import { WidgetLive } from "./live";
import { WidgetRoot } from "./root";
export const Widget = {
Root: WidgetRoot,
Live: WidgetLive,
Header: WidgetHeader,
Content: WidgetContent,
};

View File

@ -1,42 +0,0 @@
import { ChevronUpIcon } from '@lume/icons';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
export function WidgetLive({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? 'event' : 'events'}
</button>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { twMerge } from "tailwind-merge";
export function WidgetRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
{children}
</Resizable>
);
}

View File

@ -1,6 +1,6 @@
export * from "./ark";
export * from "./provider";
export * from "./components/widget";
export * from "./components/column";
export * from "./components/note";
export * from "./hooks/useWidget";
export * from "./hooks/useRichContent";

View File

@ -383,6 +383,9 @@ 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)
virtua:
specifier: ^0.18.0
version: 0.18.0(react-dom@18.2.0)(react@18.2.0)