refactor(ark): rename widget to column
This commit is contained in:
@@ -1,2 +0,0 @@
|
|||||||
export * from './newsfeed';
|
|
||||||
export * from './notification';
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NotificationColumn } from "@columns/notification";
|
import { NotificationColumn } from "@columns/notification";
|
||||||
import { TimelineColumn } from "@columns/timeline";
|
import { Timeline } from "@columns/timeline";
|
||||||
import { useStorage } from "@lume/ark";
|
import { useStorage } from "@lume/ark";
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import { WidgetProps } from "@lume/types";
|
import { WidgetProps } from "@lume/types";
|
||||||
@@ -37,12 +37,10 @@ export function HomeScreen() {
|
|||||||
|
|
||||||
const renderItem = (widget: WidgetProps) => {
|
const renderItem = (widget: WidgetProps) => {
|
||||||
switch (widget.kind) {
|
switch (widget.kind) {
|
||||||
case WIDGET_KIND.notification:
|
|
||||||
return <NotificationColumn key={widget.id} />;
|
|
||||||
case WIDGET_KIND.newsfeed:
|
case WIDGET_KIND.newsfeed:
|
||||||
return <TimelineColumn key={widget.id} />;
|
return <Timeline key={widget.id} />;
|
||||||
default:
|
default:
|
||||||
return <TimelineColumn key={widget.id} />;
|
return <Timeline key={widget.id} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import sharedConfig from "@lume/tailwindcss";
|
|||||||
const config = {
|
const config = {
|
||||||
content: [
|
content: [
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
|
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
|
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
"index.html",
|
"index.html",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"noNonNullAssertion": "warn"
|
"noNonNullAssertion": "warn"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useExhaustiveDependencies": "warn"
|
"useExhaustiveDependencies": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"noSvgWithoutTitle": "off"
|
"noSvgWithoutTitle": "off"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@columns/timeline",
|
"name": "@columns/timeline",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.tsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
"@lume/ark": "workspace:^",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||||
"@tanstack/react-query": "^5.14.2",
|
"@tanstack/react-query": "^5.14.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
"virtua": "^0.18.0"
|
"virtua": "^0.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
28
packages/@columns/timeline/src/event.tsx
Normal file
28
packages/@columns/timeline/src/event.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
packages/@columns/timeline/src/home.tsx
Normal file
119
packages/@columns/timeline/src/home.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./timeline";
|
|
||||||
23
packages/@columns/timeline/src/index.tsx
Normal file
23
packages/@columns/timeline/src/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
7
packages/@columns/timeline/src/user.tsx
Normal file
7
packages/@columns/timeline/src/user.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export function UserRoute() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return <div>{id}</div>;
|
||||||
|
}
|
||||||
6
packages/ark/src/components/column/content.tsx
Normal file
6
packages/ark/src/components/column/content.tsx
Normal 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>;
|
||||||
|
}
|
||||||
112
packages/ark/src/components/column/header.tsx
Normal file
112
packages/ark/src/components/column/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
packages/ark/src/components/column/index.ts
Normal file
13
packages/ark/src/components/column/index.ts
Normal 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,
|
||||||
|
};
|
||||||
42
packages/ark/src/components/column/live.tsx
Normal file
42
packages/ark/src/components/column/live.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
packages/ark/src/components/column/root.tsx
Normal file
37
packages/ark/src/components/column/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,10 +23,10 @@ export function Reply({
|
|||||||
className="h-14 px-3"
|
className="h-14 px-3"
|
||||||
/>
|
/>
|
||||||
<Note.TextContent content={event.content} className="min-w-0 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 ? (
|
{event.replies?.length > 0 ? (
|
||||||
<Collapsible.Trigger asChild>
|
<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
|
<NavArrowDownIcon
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"h-3 w-3",
|
"h-3 w-3",
|
||||||
@@ -54,17 +54,14 @@ export function Reply({
|
|||||||
<Note.User pubkey={event.pubkey} time={event.created_at} />
|
<Note.User pubkey={event.pubkey} time={event.created_at} />
|
||||||
<Note.TextContent
|
<Note.TextContent
|
||||||
content={event.content}
|
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">
|
<div className="-ml-1 flex h-14 items-center gap-10">
|
||||||
<Note.Pin eventId={event.id} />
|
|
||||||
<div className="inline-flex items-center gap-10">
|
|
||||||
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
||||||
<Note.Reaction event={event} />
|
<Note.Reaction event={event} />
|
||||||
<Note.Repost event={event} />
|
<Note.Repost event={event} />
|
||||||
<Note.Zap event={event} />
|
<Note.Zap event={event} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
))}
|
))}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
|||||||
import { Note } from "..";
|
import { Note } from "..";
|
||||||
import { useArk } from "../../../provider";
|
import { useArk } from "../../../provider";
|
||||||
|
|
||||||
export function TextNote({ event }: { event: NDKEvent }) {
|
export function TextNote({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: { event: NDKEvent; className?: string }) {
|
||||||
const ark = useArk();
|
const ark = useArk();
|
||||||
const thread = ark.getEventThread({ tags: event.tags });
|
const thread = ark.getEventThread({ tags: event.tags });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root>
|
<Note.Root className={className}>
|
||||||
<Note.User
|
<Note.User
|
||||||
pubkey={event.pubkey}
|
pubkey={event.pubkey}
|
||||||
time={event.created_at}
|
time={event.created_at}
|
||||||
|
|||||||
55
packages/ark/src/components/note/builds/thread.tsx
Normal file
55
packages/ark/src/components/note/builds/thread.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { NoteArticleContent } from "./kinds/article";
|
|||||||
import { NoteMediaContent } from "./kinds/media";
|
import { NoteMediaContent } from "./kinds/media";
|
||||||
import { NoteTextContent } from "./kinds/text";
|
import { NoteTextContent } from "./kinds/text";
|
||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteReplies } from "./reply";
|
import { NoteReplyList } from "./reply";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
import { NoteThread } from "./thread";
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
@@ -27,12 +27,13 @@ export const Note = {
|
|||||||
TextContent: NoteTextContent,
|
TextContent: NoteTextContent,
|
||||||
MediaContent: NoteMediaContent,
|
MediaContent: NoteMediaContent,
|
||||||
ArticleContent: NoteArticleContent,
|
ArticleContent: NoteArticleContent,
|
||||||
Replies: NoteReplies,
|
ReplyList: NoteReplyList,
|
||||||
};
|
};
|
||||||
|
|
||||||
export * from "./builds/text";
|
export * from "./builds/text";
|
||||||
export * from "./builds/repost";
|
export * from "./builds/repost";
|
||||||
export * from "./builds/skeleton";
|
export * from "./builds/skeleton";
|
||||||
|
export * from "./builds/thread";
|
||||||
export * from "./preview/image";
|
export * from "./preview/image";
|
||||||
export * from "./preview/link";
|
export * from "./preview/link";
|
||||||
export * from "./preview/video";
|
export * from "./preview/video";
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { WIDGET_KIND } from "@lume/utils";
|
|
||||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Note } from "..";
|
import { Note } from "..";
|
||||||
import { useEvent } from "../../../hooks/useEvent";
|
import { useEvent } from "../../../hooks/useEvent";
|
||||||
import { useWidget } from "../../../hooks/useWidget";
|
|
||||||
|
|
||||||
export const MentionNote = memo(function MentionNote({
|
export const MentionNote = memo(function MentionNote({
|
||||||
eventId,
|
eventId,
|
||||||
}: { eventId: string }) {
|
}: { eventId: string }) {
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
const { addWidget } = useWidget();
|
|
||||||
|
|
||||||
const renderKind = (event: NDKEvent) => {
|
const renderKind = (event: NDKEvent) => {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
@@ -51,19 +49,12 @@ export const MentionNote = memo(function MentionNote({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 px-3 pb-3">
|
<div className="mt-1 px-3 pb-3">
|
||||||
{renderKind(data)}
|
{renderKind(data)}
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to={`/events/${data.id}`}
|
||||||
onClick={() =>
|
|
||||||
addWidget.mutate({
|
|
||||||
kind: WIDGET_KIND.thread,
|
|
||||||
title: "Thread",
|
|
||||||
content: data.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-2 text-blue-500 hover:text-blue-600"
|
className="mt-2 text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
Show more
|
Show more
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { LoaderIcon } from '@lume/icons';
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import { NDKEventWithReplies } from '@lume/types';
|
import { NDKEventWithReplies } from "@lume/types";
|
||||||
import { NDKSubscription } from '@nostr-dev-kit/ndk';
|
import { NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { useArk } from '../../provider';
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Reply } from './builds/reply';
|
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 ark = useArk();
|
||||||
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
||||||
|
|
||||||
@@ -21,7 +26,7 @@ export function NoteReplies({ eventId }: { eventId: string }) {
|
|||||||
// subscribe for new replies
|
// subscribe for new replies
|
||||||
sub = ark.subscribe({
|
sub = ark.subscribe({
|
||||||
filter: {
|
filter: {
|
||||||
'#e': [eventId],
|
"#e": [eventId],
|
||||||
since: Math.floor(Date.now() / 1000),
|
since: Math.floor(Date.now() / 1000),
|
||||||
},
|
},
|
||||||
closeOnEose: false,
|
closeOnEose: false,
|
||||||
@@ -37,21 +42,15 @@ export function NoteReplies({ eventId }: { eventId: string }) {
|
|||||||
};
|
};
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
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">
|
<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" />
|
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : data.length === 0 ? (
|
||||||
);
|
<div className="flex w-full items-center justify-center">
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||||
<h3 className="text-3xl">👋</h3>
|
<h3 className="text-3xl">👋</h3>
|
||||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -60,7 +59,9 @@ export function NoteReplies({ eventId }: { eventId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from "react";
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function NoteRoot({
|
export function NoteRoot({
|
||||||
children,
|
children,
|
||||||
@@ -11,8 +11,8 @@ export function NoteRoot({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
|
"flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { WIDGET_KIND } from '@lume/utils';
|
import { Link } from "react-router-dom";
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Note } from '.';
|
import { Note } from ".";
|
||||||
import { useWidget } from '../../hooks/useWidget';
|
|
||||||
|
|
||||||
export function NoteThread({
|
export function NoteThread({
|
||||||
thread,
|
thread,
|
||||||
@@ -10,28 +9,23 @@ export function NoteThread({
|
|||||||
thread: { rootEventId: string; replyEventId: string };
|
thread: { rootEventId: string; replyEventId: string };
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { addWidget } = useWidget();
|
|
||||||
|
|
||||||
if (!thread) return null;
|
if (!thread) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<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.rootEventId ? (
|
||||||
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
|
<Note.Child eventId={thread.rootEventId} isRoot />
|
||||||
<button
|
) : null}
|
||||||
type="button"
|
{thread.replyEventId ? (
|
||||||
onClick={() =>
|
<Note.Child eventId={thread.replyEventId} />
|
||||||
addWidget.mutate({
|
) : null}
|
||||||
kind: WIDGET_KIND.thread,
|
<Link
|
||||||
title: 'Thread',
|
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
|
||||||
content: thread.rootEventId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="self-start text-blue-500 hover:text-blue-600"
|
className="self-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
Show full thread
|
Show full thread
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,35 @@
|
|||||||
import { RepostIcon } from '@lume/icons';
|
import { RepostIcon } from "@lume/icons";
|
||||||
import { displayNpub, formatCreatedAt } from '@lume/utils';
|
import { displayNpub, formatCreatedAt } from "@lume/utils";
|
||||||
import * as Avatar from '@radix-ui/react-avatar';
|
import * as Avatar from "@radix-ui/react-avatar";
|
||||||
import { minidenticon } from 'minidenticons';
|
import { minidenticon } from "minidenticons";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useProfile } from '../../hooks/useProfile';
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
|
|
||||||
export function NoteUser({
|
export function NoteUser({
|
||||||
pubkey,
|
pubkey,
|
||||||
time,
|
time,
|
||||||
variant = 'text',
|
variant = "text",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
time: number;
|
time: number;
|
||||||
variant?: 'text' | 'repost' | 'mention';
|
variant?: "text" | "repost" | "mention" | "thread";
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||||
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
||||||
const fallbackAvatar = useMemo(
|
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);
|
const { isLoading, user } = useProfile(pubkey);
|
||||||
|
|
||||||
if (variant === 'mention') {
|
if (variant === "mention") {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -42,7 +45,9 @@ export function NoteUser({
|
|||||||
{fallbackName}
|
{fallbackName}
|
||||||
</h5>
|
</h5>
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -68,19 +73,24 @@ export function NoteUser({
|
|||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div className="flex flex-1 items-baseline gap-2">
|
<div className="flex flex-1 items-baseline gap-2">
|
||||||
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
<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>
|
</h5>
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'repost') {
|
if (variant === "repost") {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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">
|
<div className="inline-flex w-10 items-center justify-center">
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +103,7 @@ export function NoteUser({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="inline-flex w-10 items-center justify-center">
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +126,10 @@ export function NoteUser({
|
|||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div className="inline-flex items-baseline gap-1">
|
<div className="inline-flex items-baseline gap-1">
|
||||||
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
|
<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>
|
</h5>
|
||||||
<span className="text-blue-500">reposted</span>
|
<span className="text-blue-500">reposted</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,9 +138,54 @@ export function NoteUser({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (variant === "thread") {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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.Root className="h-9 w-9 shrink-0">
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={fallbackAvatar}
|
src={fallbackAvatar}
|
||||||
@@ -145,7 +203,7 @@ export function NoteUser({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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.Root className="h-9 w-9 shrink-0">
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={user?.picture || user?.image}
|
src={user?.picture || user?.image}
|
||||||
@@ -164,9 +222,14 @@ export function NoteUser({
|
|||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div className="flex h-6 flex-1 items-start justify-between gap-2">
|
<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">
|
<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>
|
||||||
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export function WidgetContent({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="h-full w-full">{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from "./ark";
|
export * from "./ark";
|
||||||
export * from "./provider";
|
export * from "./provider";
|
||||||
export * from "./components/widget";
|
export * from "./components/column";
|
||||||
export * from "./components/note";
|
export * from "./components/note";
|
||||||
export * from "./hooks/useWidget";
|
export * from "./hooks/useWidget";
|
||||||
export * from "./hooks/useRichContent";
|
export * from "./hooks/useRichContent";
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -383,6 +383,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 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:
|
virtua:
|
||||||
specifier: ^0.18.0
|
specifier: ^0.18.0
|
||||||
version: 0.18.0(react-dom@18.2.0)(react@18.2.0)
|
version: 0.18.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user