diff --git a/src/app/auth/onboarding/hashtag.tsx b/src/app/auth/onboarding/hashtag.tsx index 1175ecf4..5a4b8de3 100644 --- a/src/app/auth/onboarding/hashtag.tsx +++ b/src/app/auth/onboarding/hashtag.tsx @@ -6,8 +6,8 @@ import { useStorage } from '@libs/storage/provider'; import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons'; +import { WidgetKinds } from '@stores/constants'; import { useOnboarding } from '@stores/onboarding'; -import { WidgetKinds } from '@stores/widgets'; const data = [ { hashtag: '#bitcoin' }, diff --git a/src/app/space/components/toggle.tsx b/src/app/space/components/toggle.tsx index 931de796..9fe73b43 100644 --- a/src/app/space/components/toggle.tsx +++ b/src/app/space/components/toggle.tsx @@ -1,23 +1,20 @@ -import { useStorage } from '@libs/storage/provider'; +import { PlusIcon } from '@shared/icons'; +import { WidgetWrapper } from '@shared/widgets'; -import { HandArrowDownIcon, PlusIcon } from '@shared/icons'; +import { WidgetKinds } from '@stores/constants'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { useWidget } from '@utils/hooks/useWidget'; export function ToggleWidgetList() { - const { db } = useStorage(); - const setWidget = useWidgets((state) => state.setWidget); + const { addWidget } = useWidget(); return ( -
-
-
- -
+ +
-
+ ); } diff --git a/src/app/space/components/widgetList.tsx b/src/app/space/components/widgetList.tsx index 4229f931..0ace2c0a 100644 --- a/src/app/space/components/widgetList.tsx +++ b/src/app/space/components/widgetList.tsx @@ -1,7 +1,5 @@ import { useCallback } from 'react'; -import { useStorage } from '@libs/storage/provider'; - import { ArticleIcon, BellIcon, @@ -13,22 +11,15 @@ import { TrendingIcon, } from '@shared/icons'; import { TitleBar } from '@shared/titleBar'; +import { WidgetWrapper } from '@shared/widgets'; -import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets'; +import { DefaultWidgets, WidgetKinds } from '@stores/constants'; -import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types'; +import { useWidget } from '@utils/hooks/useWidget'; +import { Widget, WidgetGroup } from '@utils/types'; export function WidgetList({ params }: { params: Widget }) { - const { db } = useStorage(); - const [setWidget, removeWidget] = useWidgets((state) => [ - state.setWidget, - state.removeWidget, - ]); - - const openWidget = (widget: WidgetGroupItem) => { - setWidget(db, { kind: widget.kind, title: widget.title, content: '' }); - removeWidget(db, params.id); - }; + const { addWidget } = useWidget(); const renderIcon = useCallback( (kind: number) => { @@ -71,52 +62,51 @@ export function WidgetList({ params }: { params: Widget }) { [DefaultWidgets] ); - const renderItem = useCallback( - (row: WidgetGroup, index: number) => { - return ( -
-

- {row.title} -

-
- {row.data.map((item, index) => ( - - ))} -
+ ) : ( +
+ {renderIcon(item.kind)} +
+ )} +
+
+ {item.title} +
+

+ {item.description} +

+
+ + ))}
- ); - }, - [DefaultWidgets] - ); +
+ ); + }, []); return ( -
+
@@ -139,6 +129,6 @@ export function WidgetList({ params }: { params: Widget }) {
- + ); } diff --git a/src/app/space/index.tsx b/src/app/space/index.tsx index 5ffee4d6..dd63dc9b 100644 --- a/src/app/space/index.tsx +++ b/src/app/space/index.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useRef, useState } from 'react'; import { VList, VListHandle } from 'virtua'; import { ToggleWidgetList } from '@app/space/components/toggle'; @@ -11,99 +12,134 @@ import { GlobalArticlesWidget, GlobalFilesWidget, GlobalHashtagWidget, - LearnNostrWidget, LocalArticlesWidget, LocalFeedsWidget, LocalFilesWidget, - LocalFollowsWidget, - LocalNotificationWidget, LocalThreadWidget, LocalUserWidget, NewsfeedWidget, + NotificationWidget, TrendingAccountsWidget, TrendingNotesWidget, XfeedsWidget, XhashtagWidget, } from '@shared/widgets'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; import { Widget } from '@utils/types'; export function SpaceScreen() { + const ref = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(-1); + const { db } = useStorage(); - const vlistRef = useRef(null); + const { status, data } = useQuery({ + queryKey: ['widgets'], + queryFn: async () => { + const dbWidgets = await db.getWidgets(); + const defaultWidgets = [ + { + id: '9998', + title: 'Notification', + content: '', + kind: WidgetKinds.local.notification, + }, + { + id: '9999', + title: 'Newsfeed', + content: '', + kind: WidgetKinds.local.network, + }, + ]; - const [widgets, fetchWidgets] = useWidgets((state) => [ - state.widgets, - state.fetchWidgets, - ]); - - const renderItem = useCallback( - (widget: Widget) => { - if (!widget) return; - switch (widget.kind) { - case WidgetKinds.local.network: - return ; - case WidgetKinds.local.follows: - return ; - case WidgetKinds.local.feeds: - return ; - case WidgetKinds.local.files: - return ; - case WidgetKinds.local.articles: - return ; - case WidgetKinds.local.user: - return ; - case WidgetKinds.local.thread: - return ; - case WidgetKinds.global.hashtag: - return ; - case WidgetKinds.global.articles: - return ; - case WidgetKinds.global.files: - return ; - case WidgetKinds.nostrBand.trendingAccounts: - return ; - case WidgetKinds.nostrBand.trendingNotes: - return ; - case WidgetKinds.tmp.xfeed: - return ; - case WidgetKinds.tmp.xhashtag: - return ; - case WidgetKinds.tmp.list: - return ; - case WidgetKinds.other.learnNostr: - return ; - case WidgetKinds.local.notification: - return ; - default: - return null; - } + return [...defaultWidgets, ...dbWidgets]; }, - [widgets] - ); + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: Infinity, + }); - useEffect(() => { - fetchWidgets(db); + const renderItem = useCallback((widget: Widget) => { + switch (widget.kind) { + case WidgetKinds.local.feeds: + return ; + case WidgetKinds.local.files: + return ; + case WidgetKinds.local.articles: + return ; + case WidgetKinds.local.user: + return ; + case WidgetKinds.local.thread: + return ; + case WidgetKinds.global.hashtag: + return ; + case WidgetKinds.global.articles: + return ; + case WidgetKinds.global.files: + return ; + case WidgetKinds.nostrBand.trendingAccounts: + return ; + case WidgetKinds.nostrBand.trendingNotes: + return ; + case WidgetKinds.tmp.xfeed: + return ; + case WidgetKinds.tmp.xhashtag: + return ; + case WidgetKinds.tmp.list: + return ; + case WidgetKinds.local.notification: + return ; + case WidgetKinds.local.network: + return ; + default: + return null; + } }, []); + if (status === 'pending') { + return ( +
+ +
+ ); + } + return ( { + if (!ref.current) return; + switch (e.code) { + case 'ArrowLeft': { + e.preventDefault(); + const prevIndex = Math.max(selectedIndex - 1, 0); + setSelectedIndex(prevIndex); + ref.current.scrollToIndex(prevIndex, { + align: 'center', + smooth: true, + }); + break; + } + case 'ArrowRight': { + e.preventDefault(); + const nextIndex = Math.min(selectedIndex + 1, data.length - 1); + setSelectedIndex(nextIndex); + ref.current.scrollToIndex(nextIndex, { + align: 'center', + smooth: true, + }); + break; + } + } + }} > - {!widgets ? ( -
- -
- ) : ( - widgets.map((widget) => renderItem(widget)) - )} + {data.map((widget) => renderItem(widget))}
); diff --git a/src/libs/storage/instance.ts b/src/libs/storage/instance.ts index d973222f..3e7a0250 100644 --- a/src/libs/storage/instance.ts +++ b/src/libs/storage/instance.ts @@ -254,7 +254,8 @@ export class LumeStorage { } public async removeWidget(id: string) { - return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]); + const res = await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]); + if (res) return id; } public async createEvent(event: NDKEvent) { diff --git a/src/shared/accounts/active.tsx b/src/shared/accounts/active.tsx index cd6c5984..85dd7dc7 100644 --- a/src/shared/accounts/active.tsx +++ b/src/shared/accounts/active.tsx @@ -1,99 +1,22 @@ -import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'; import * as Avatar from '@radix-ui/react-avatar'; import { minidenticon } from 'minidenticons'; -import { useEffect } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { Link } from 'react-router-dom'; -import { useNDK } from '@libs/ndk/provider'; import { useStorage } from '@libs/storage/provider'; import { AccountMoreActions } from '@shared/accounts/more'; import { NetworkStatusIndicator } from '@shared/networkStatusIndicator'; -import { useActivities } from '@stores/activities'; - -import { useNostr } from '@utils/hooks/useNostr'; import { useProfile } from '@utils/hooks/useProfile'; -import { sendNativeNotification } from '@utils/notification'; export function ActiveAccount() { const { db } = useStorage(); - const { ndk } = useNDK(); const { status, user } = useProfile(db.account.pubkey); - const { sub } = useNostr(); - - const location = useLocation(); - const addActivity = useActivities((state) => state.addActivity); - const addNewMessage = useActivities((state) => state.addNewMessage); const svgURI = 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(db.account.pubkey, 90, 50)); - useEffect(() => { - const filter: NDKFilter = { - kinds: [ - NDKKind.Text, - NDKKind.EncryptedDirectMessage, - NDKKind.Repost, - NDKKind.Reaction, - NDKKind.Zap, - ], - since: Math.floor(Date.now() / 1000), - '#p': [db.account.pubkey], - }; - - sub( - filter, - async (event) => { - console.log('receive event: ', event.id); - - if (event.kind !== NDKKind.EncryptedDirectMessage) { - addActivity(event); - } - - const user = ndk.getUser({ hexpubkey: event.pubkey }); - await user.fetchProfile(); - - switch (event.kind) { - case NDKKind.Text: - return await sendNativeNotification( - `${user.profile.displayName || user.profile.name} has replied to your note` - ); - case NDKKind.EncryptedDirectMessage: { - if (location.pathname !== '/chats') { - addNewMessage(); - return await sendNativeNotification( - `${ - user.profile.displayName || user.profile.name - } has send you a encrypted message` - ); - } else { - break; - } - } - case NDKKind.Repost: - return await sendNativeNotification( - `${user.profile.displayName || user.profile.name} has reposted to your note` - ); - case NDKKind.Reaction: - return await sendNativeNotification( - `${user.profile.displayName || user.profile.name} has reacted ${ - event.content - } to your note` - ); - case NDKKind.Zap: - return await sendNativeNotification( - `${user.profile.displayName || user.profile.name} has zapped to your note` - ); - default: - break; - } - }, - false - ); - }, []); - if (status === 'pending') { return (
diff --git a/src/shared/navigation.tsx b/src/shared/navigation.tsx index 1a1108a6..f814441c 100644 --- a/src/shared/navigation.tsx +++ b/src/shared/navigation.tsx @@ -11,12 +11,10 @@ import { RelayIcon, } from '@shared/icons'; -import { useActivities } from '@stores/activities'; - import { compactNumber } from '@utils/number'; export function Navigation() { - const newMessages = useActivities((state) => state.newMessages); + const newMessages = 0; return (
diff --git a/src/shared/notes/actions.tsx b/src/shared/notes/actions.tsx index b99b58e4..2c399257 100644 --- a/src/shared/notes/actions.tsx +++ b/src/shared/notes/actions.tsx @@ -1,14 +1,14 @@ import * as Tooltip from '@radix-ui/react-tooltip'; -import { useStorage } from '@libs/storage/provider'; - import { FocusIcon } from '@shared/icons'; import { NoteReaction } from '@shared/notes/actions/reaction'; import { NoteReply } from '@shared/notes/actions/reply'; import { NoteRepost } from '@shared/notes/actions/repost'; import { NoteZap } from '@shared/notes/actions/zap'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; + +import { useWidget } from '@utils/hooks/useWidget'; export function NoteActions({ id, @@ -21,8 +21,7 @@ export function NoteActions({ extraButtons?: boolean; root?: string; }) { - const { db } = useStorage(); - const setWidget = useWidgets((state) => state.setWidget); + const { addWidget } = useWidget(); return ( @@ -40,7 +39,7 @@ export function NoteActions({ ); } diff --git a/src/shared/notes/mentions/invoice.tsx b/src/shared/notes/mentions/invoice.tsx index f8133d46..d9c11f0c 100644 --- a/src/shared/notes/mentions/invoice.tsx +++ b/src/shared/notes/mentions/invoice.tsx @@ -3,8 +3,8 @@ import { memo } from 'react'; export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) { return ( - +
- +
); }); diff --git a/src/shared/notes/mentions/note.tsx b/src/shared/notes/mentions/note.tsx index 1e463446..e5de7205 100644 --- a/src/shared/notes/mentions/note.tsx +++ b/src/shared/notes/mentions/note.tsx @@ -2,8 +2,6 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { nip19 } from 'nostr-tools'; import { memo } from 'react'; -import { useStorage } from '@libs/storage/provider'; - import { ArticleNote, FileNote, @@ -14,20 +12,23 @@ import { } from '@shared/notes'; import { User } from '@shared/user'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; import { useEvent } from '@utils/hooks/useEvent'; +import { useWidget } from '@utils/hooks/useWidget'; export const MentionNote = memo(function MentionNote({ id }: { id: string }) { - const { db } = useStorage(); const { status, data } = useEvent(id); - - const setWidget = useWidgets((state) => state.setWidget); + const { addWidget } = useWidget(); const openThread = (event, thread: string) => { const selection = window.getSelection(); if (selection.toString().length === 0) { - setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread }); + addWidget.mutate({ + kind: WidgetKinds.local.thread, + title: 'Thread', + content: thread, + }); } else { event.stopPropagation(); } @@ -74,15 +75,13 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) { } return ( -
openThread(e, id)} - onKeyDown={(e) => openThread(e, id)} - role="button" - tabIndex={0} className="mt-3 cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800" > -
{renderKind(data)}
-
+
{renderKind(data)}
+ ); }); diff --git a/src/shared/notes/mentions/user.tsx b/src/shared/notes/mentions/user.tsx index 80a9679f..b659ab8b 100644 --- a/src/shared/notes/mentions/user.tsx +++ b/src/shared/notes/mentions/user.tsx @@ -1,30 +1,26 @@ import { memo } from 'react'; -import { useStorage } from '@libs/storage/provider'; - -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; import { useProfile } from '@utils/hooks/useProfile'; +import { useWidget } from '@utils/hooks/useWidget'; export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) { - const { db } = useStorage(); const { user } = useProfile(pubkey); - - const setWidget = useWidgets((state) => state.setWidget); + const { addWidget } = useWidget(); return ( - - setWidget(db, { + addWidget.mutate({ kind: WidgetKinds.local.user, title: user?.name || user?.display_name || user?.displayName, content: pubkey, }) } onKeyDown={() => - setWidget(db, { + addWidget.mutate({ kind: WidgetKinds.local.user, title: user?.name || user?.display_name || user?.displayName, content: pubkey, @@ -38,6 +34,6 @@ export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: strin user?.displayName || user?.username || 'unknown')} - + ); }); diff --git a/src/shared/notes/metadata.tsx b/src/shared/notes/metadata.tsx index 8d6de8c5..035ef8eb 100644 --- a/src/shared/notes/metadata.tsx +++ b/src/shared/notes/metadata.tsx @@ -3,19 +3,13 @@ import { useQuery } from '@tanstack/react-query'; import { decode } from 'light-bolt11-decoder'; import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; import { LoaderIcon } from '@shared/icons'; import { User } from '@shared/user'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; - import { compactNumber } from '@utils/number'; export function NoteMetadata({ id }: { id: string }) { - const setWidget = useWidgets((state) => state.setWidget); - - const { db } = useStorage(); const { ndk } = useNDK(); const { status, data } = useQuery({ queryKey: ['note-metadata', id], @@ -89,17 +83,7 @@ export function NoteMetadata({ id }: { id: string }) {
- ยท diff --git a/src/shared/notes/preview/link.tsx b/src/shared/notes/preview/link.tsx index eeecbc70..2a488fd4 100644 --- a/src/shared/notes/preview/link.tsx +++ b/src/shared/notes/preview/link.tsx @@ -2,6 +2,10 @@ import { Link } from 'react-router-dom'; import { useOpenGraph } from '@utils/hooks/useOpenGraph'; +function isImage(url: string) { + return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url); +} + export function LinkPreview({ urls }: { urls: string[] }) { const { status, data, error } = useOpenGraph(urls[0]); const domain = new URL(urls[0]); @@ -37,25 +41,25 @@ export function LinkPreview({ urls }: { urls: string[] }) {
) : ( <> - {data.image && ( + {isImage(data.image) ? ( {urls[0]} - )} -
-
+ ) : null} +
+
{data.title && (
{data.title}
)} - {data.description && ( + {data.description ? (

{data.description}

- )} + ) : null}
{domain.hostname} diff --git a/src/shared/notification/notifyNote.tsx b/src/shared/notification/notifyNote.tsx index 44466e75..f6ebcd6c 100644 --- a/src/shared/notification/notifyNote.tsx +++ b/src/shared/notification/notifyNote.tsx @@ -1,7 +1,5 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; -import { useStorage } from '@libs/storage/provider'; - import { ArticleNote, FileNote, @@ -11,33 +9,37 @@ import { } from '@shared/notes'; import { User } from '@shared/user'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; import { formatCreatedAt } from '@utils/createdAt'; import { useEvent } from '@utils/hooks/useEvent'; +import { useWidget } from '@utils/hooks/useWidget'; export function NotifyNote({ event }: { event: NDKEvent }) { const createdAt = formatCreatedAt(event.created_at, false); const rootEventId = event.tags.find((el) => el[0] === 'e')?.[1]; - const { db } = useStorage(); const { status, data } = useEvent(rootEventId); - - const setWidget = useWidgets((state) => state.setWidget); + const { addWidget } = useWidget(); const openThread = (event, thread: string) => { const selection = window.getSelection(); if (selection.toString().length === 0) { - setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread }); + addWidget.mutate({ + kind: WidgetKinds.local.thread, + title: 'Thread', + content: thread, + }); } else { event.stopPropagation(); } }; const renderKind = (event: NDKEvent) => { + if (!event) return null; switch (event.kind) { case NDKKind.Text: - return ; + return ; case NDKKind.Article: return ; case 1063: @@ -88,13 +90,13 @@ export function NotifyNote({ event }: { event: NDKEvent }) { {event.kind === 1 ? : null}
openThread(e, data.id)} - onKeyDown={(e) => openThread(e, data.id)} + onClick={(e) => openThread(e, data?.id)} + onKeyDown={(e) => openThread(e, data?.id)} role="button" tabIndex={0} className="cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800" > - +
{renderKind(data)}
diff --git a/src/shared/titleBar.tsx b/src/shared/titleBar.tsx index fe3fbb92..7bb8d3ba 100644 --- a/src/shared/titleBar.tsx +++ b/src/shared/titleBar.tsx @@ -3,11 +3,11 @@ import { useStorage } from '@libs/storage/provider'; import { CancelIcon } from '@shared/icons'; import { User } from '@shared/user'; -import { useWidgets } from '@stores/widgets'; +import { useWidget } from '@utils/hooks/useWidget'; export function TitleBar({ id, title }: { id?: string; title?: string }) { const { db } = useStorage(); - const remove = useWidgets((state) => state.removeWidget); + const { removeWidget } = useWidget(); return (
@@ -33,7 +33,7 @@ export function TitleBar({ id, title }: { id?: string; title?: string }) { {id !== '9999' ? ( + ) : null} +
+ ); } diff --git a/src/shared/widgets/global/files.tsx b/src/shared/widgets/global/files.tsx index d6628b17..eefc7a6f 100644 --- a/src/shared/widgets/global/files.tsx +++ b/src/shared/widgets/global/files.tsx @@ -1,66 +1,76 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { useQuery } from '@tanstack/react-query'; -import { useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { VList } from 'virtua'; import { useNDK } from '@libs/ndk/provider'; -import { LoaderIcon } from '@shared/icons'; +import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { FileNote, NoteWrapper } from '@shared/notes'; import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { nHoursAgo } from '@utils/date'; +import { FETCH_LIMIT } from '@stores/constants'; + import { Widget } from '@utils/types'; export function GlobalFilesWidget({ params }: { params: Widget }) { - const { ndk } = useNDK(); - const { status, data } = useQuery({ - queryKey: ['global-file-sharing'], - queryFn: async () => { - const events = await ndk.fetchEvents({ - // @ts-expect-error, NDK not support file metadata yet - kinds: [1063], - since: nHoursAgo(24), - }); - const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at); - return sortedEvents; - }, - refetchOnWindowFocus: false, - }); + const { ndk, relayUrls, fetcher } = useNDK(); + const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: ['global-files'], + initialPageParam: 0, + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [1063], + }, + FETCH_LIMIT, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); - // render event match event kind - const renderItem = useCallback( - (event: NDKEvent) => { - return ( - - - - ); - }, + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); + + return ndkEvents.sort((a, b) => b.created_at - a.created_at); + }, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), [data] ); return ( -
+ {status === 'pending' ? ( -
-
- -

- Loading file sharing event... -

-
+
+
- ) : data.length === 0 ? ( + ) : allEvents.length === 0 ? (
empty feeds

- Oops, it looks like there are no file sharing events. + Oops, it looks like there are no files.

You can close this widget @@ -69,12 +79,32 @@ export function GlobalFilesWidget({ params }: { params: Widget }) {

) : ( - - {data.map((item) => renderItem(item))} -
- + allEvents.map((item) => ( + + + + )) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/global/hashtag.tsx b/src/shared/widgets/global/hashtag.tsx index 99c2b67f..f1b88abe 100644 --- a/src/shared/widgets/global/hashtag.tsx +++ b/src/shared/widgets/global/hashtag.tsx @@ -1,11 +1,11 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; -import { useQuery } from '@tanstack/react-query'; -import { useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; import { VList } from 'virtua'; import { useNDK } from '@libs/ndk/provider'; -import { LoaderIcon } from '@shared/icons'; +import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArticleNote, FileNote, @@ -17,74 +17,94 @@ import { import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { nHoursAgo } from '@utils/date'; +import { FETCH_LIMIT } from '@stores/constants'; + import { Widget } from '@utils/types'; export function GlobalHashtagWidget({ params }: { params: Widget }) { - const { ndk } = useNDK(); - const { status, data } = useQuery({ - queryKey: ['hashtag-' + params.title], - queryFn: async () => { - const events = await ndk.fetchEvents({ - kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Article], - '#t': [params.content], - since: nHoursAgo(24), - }); - const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at); - return sortedEvents; - }, - refetchOnWindowFocus: false, - }); + const { ndk, relayUrls, fetcher } = useNDK(); + const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: ['hashtag-' + params.title], + initialPageParam: 0, + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], + '#t': [params.content], + }, + FETCH_LIMIT, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); - // render event match event kind - const renderItem = useCallback( - (event: NDKEvent) => { - switch (event.kind) { - case NDKKind.Text: - return ( - - - - ); - case NDKKind.Repost: - return ; - case 1063: - return ( - - - - ); - case NDKKind.Article: - return ( - - - - ); - default: - return ( - - - - ); - } - }, + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); + + return ndkEvents.sort((a, b) => b.created_at - a.created_at); + }, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), [data] ); + // render event match event kind + const renderItem = useCallback((event: NDKEvent) => { + switch (event.kind) { + case NDKKind.Text: + return ( + + + + ); + case NDKKind.Repost: + return ; + case 1063: + return ( + + + + ); + case NDKKind.Article: + return ( + + + + ); + default: + return ( + + + + ); + } + }, []); + return ( -
+ {status === 'pending' ? (
-
- -

- Loading event related to the hashtag {params.title}... -

-
+
- ) : data.length === 0 ? ( + ) : allEvents.length === 0 ? (
empty feeds @@ -93,18 +113,34 @@ export function GlobalHashtagWidget({ params }: { params: Widget }) { Oops, it looks like there are no events related to {params.title}.

- You can close this widget or try with other hashtag + You can close this widget

) : ( - - {data.map((item) => renderItem(item))} -
- + allEvents.map((item) => renderItem(item)) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/index.ts b/src/shared/widgets/index.ts index 61bb53fb..f23db79a 100644 --- a/src/shared/widgets/index.ts +++ b/src/shared/widgets/index.ts @@ -1,11 +1,9 @@ export * from './wrapper'; export * from './local/feeds'; -export * from './local/network'; export * from './local/user'; export * from './local/thread'; export * from './local/files'; export * from './local/articles'; -export * from './local/follows'; export * from './global/articles'; export * from './global/files'; export * from './global/hashtag'; @@ -13,7 +11,5 @@ export * from './nostrBand/trendingNotes'; export * from './nostrBand/trendingAccounts'; export * from './tmp/feeds'; export * from './tmp/hashtag'; -export * from './other/learnNostr'; -export * from './eventLoader'; export * from './newsfeed'; export * from './notification'; diff --git a/src/shared/widgets/local/articles.tsx b/src/shared/widgets/local/articles.tsx index 78a0ced6..6bc44dc6 100644 --- a/src/shared/widgets/local/articles.tsx +++ b/src/shared/widgets/local/articles.tsx @@ -1,8 +1,9 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { VList } from 'virtua'; +import { useNDK } from '@libs/ndk/provider'; import { useStorage } from '@libs/storage/provider'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; @@ -10,52 +11,63 @@ import { ArticleNote, NoteWrapper } from '@shared/notes'; import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { DBEvent, Widget } from '@utils/types'; +import { FETCH_LIMIT } from '@stores/constants'; + +import { Widget } from '@utils/types'; export function LocalArticlesWidget({ params }: { params: Widget }) { const { db } = useStorage(); + const { ndk, relayUrls, fetcher } = useNDK(); const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: ['local-articles'], initialPageParam: 0, - queryFn: async ({ pageParam = 0 }) => { - return await db.getAllEventsByKinds([NDKKind.Article], 20, pageParam); + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [NDKKind.Article], + authors: db.account.circles, + }, + FETCH_LIMIT, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); + + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); + + return ndkEvents.sort((a, b) => b.created_at - a.created_at); }, - getNextPageParam: (lastPage) => lastPage.nextCursor, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }); - const dbEvents = useMemo( - () => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []), - [data] - ); - - // render event match event kind - const renderItem = useCallback( - (dbEvent: DBEvent) => { - const event: NDKEvent = JSON.parse(dbEvent.event as string); - return ( - - - - ); - }, + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), [data] ); return ( -
+ {status === 'pending' ? ( -
-
- -

- Loading article... -

-
+
+
- ) : dbEvents.length === 0 ? ( + ) : allEvents.length === 0 ? (
empty feeds @@ -70,38 +82,32 @@ export function LocalArticlesWidget({ params }: { params: Widget }) {
) : ( - - {dbEvents.map((item) => renderItem(item))} -
- {dbEvents.length > 0 ? ( - - ) : null} -
-
- + allEvents.map((item) => ( + + + + )) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/local/feeds.tsx b/src/shared/widgets/local/feeds.tsx index c69fa1fe..29979aa9 100644 --- a/src/shared/widgets/local/feeds.tsx +++ b/src/shared/widgets/local/feeds.tsx @@ -3,141 +3,132 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { VList } from 'virtua'; -import { useStorage } from '@libs/storage/provider'; +import { useNDK } from '@libs/ndk/provider'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { - ArticleNote, - FileNote, + MemoizedArticleNote, + MemoizedFileNote, + MemoizedRepost, + MemoizedTextNote, + NoteSkeleton, NoteWrapper, - Repost, - TextNote, UnknownNote, } from '@shared/notes'; import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { DBEvent, Widget } from '@utils/types'; +import { FETCH_LIMIT } from '@stores/constants'; + +import { Widget } from '@utils/types'; export function LocalFeedsWidget({ params }: { params: Widget }) { - const { db } = useStorage(); + const { relayUrls, ndk, fetcher } = useNDK(); const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: ['group-feeds-' + params.id], initialPageParam: 0, - queryFn: async ({ pageParam = 0 }) => { - const authors = JSON.parse(params.content); - return await db.getAllEventsByAuthors(authors, 20, pageParam); + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], + authors: JSON.parse(params.content), + }, + FETCH_LIMIT, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); + + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); + + return ndkEvents.sort((a, b) => b.created_at - a.created_at); }, - getNextPageParam: (lastPage) => lastPage.nextCursor, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }); - const dbEvents = useMemo( - () => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []), + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), [data] ); - // render event match event kind - const renderItem = useCallback( - (dbEvent: DBEvent) => { - const event: NDKEvent = JSON.parse(dbEvent.event as string); - switch (event.kind) { - case NDKKind.Text: - return ( - - - - ); - case NDKKind.Repost: - return ; - case 1063: - return ( - - - - ); - case NDKKind.Article: - return ( - - - - ); - default: - return ( - - - - ); - } - }, - [dbEvents] - ); + const renderItem = useCallback((event: NDKEvent) => { + switch (event.kind) { + case NDKKind.Text: + return ( + + + + ); + case NDKKind.Repost: + return ; + case 1063: + return ( + + + + ); + case NDKKind.Article: + return ( + + + + ); + default: + return ( + + + + ); + } + }, []); return ( -
+ {status === 'pending' ? ( -
-
- -

- Loading newsfeed... -

-
-
- ) : dbEvents.length === 0 ? ( -
-
- empty feeds -
-

- Oops, it looks like there are no posts. -

-

- You can close this widget -

-
+
+
+
) : ( - - {dbEvents.map((item) => renderItem(item))} -
- {dbEvents.length > 0 ? ( - - ) : null} -
-
- + allEvents.map((item) => renderItem(item)) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/local/files.tsx b/src/shared/widgets/local/files.tsx index 541a1c05..e36dc59a 100644 --- a/src/shared/widgets/local/files.tsx +++ b/src/shared/widgets/local/files.tsx @@ -1,8 +1,9 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { VList } from 'virtua'; +import { useNDK } from '@libs/ndk/provider'; import { useStorage } from '@libs/storage/provider'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; @@ -10,58 +11,69 @@ import { FileNote, NoteWrapper } from '@shared/notes'; import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { DBEvent, Widget } from '@utils/types'; +import { FETCH_LIMIT } from '@stores/constants'; + +import { Widget } from '@utils/types'; export function LocalFilesWidget({ params }: { params: Widget }) { const { db } = useStorage(); + const { ndk, relayUrls, fetcher } = useNDK(); const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({ - queryKey: ['local-file-sharing'], + queryKey: ['local-files'], initialPageParam: 0, - queryFn: async ({ pageParam = 0 }) => { - return await db.getAllEventsByKinds([1063], 20, pageParam); + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [1063], + authors: db.account.circles, + }, + FETCH_LIMIT, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); + + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); + + return ndkEvents.sort((a, b) => b.created_at - a.created_at); }, - getNextPageParam: (lastPage) => lastPage.nextCursor, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }); - const dbEvents = useMemo( - () => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []), - [data] - ); - - // render event match event kind - const renderItem = useCallback( - (dbEvent: DBEvent) => { - const event: NDKEvent = JSON.parse(dbEvent.event as string); - return ( - - - - ); - }, + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), [data] ); return ( -
+ {status === 'pending' ? ( -
-
- -

- Loading file sharing event... -

-
+
+
- ) : dbEvents.length === 0 ? ( + ) : allEvents.length === 0 ? (
empty feeds

- Oops, it looks like there are no file sharing events. + Oops, it looks like there are no files.

You can close this widget @@ -70,38 +82,32 @@ export function LocalFilesWidget({ params }: { params: Widget }) {

) : ( - - {dbEvents.map((item) => renderItem(item))} -
- {dbEvents.length > 0 ? ( - - ) : null} -
-
- + allEvents.map((item) => ( + + + + )) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/local/follows.tsx b/src/shared/widgets/local/follows.tsx deleted file mode 100644 index cb2836fb..00000000 --- a/src/shared/widgets/local/follows.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; -import { VList } from 'virtua'; - -import { useStorage } from '@libs/storage/provider'; - -import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; -import { - MemoizedArticleNote, - MemoizedFileNote, - MemoizedRepost, - MemoizedTextNote, - NoteWrapper, - UnknownNote, -} from '@shared/notes'; -import { TitleBar } from '@shared/titleBar'; -import { WidgetWrapper } from '@shared/widgets'; - -import { DBEvent, Widget } from '@utils/types'; - -export function LocalFollowsWidget({ params }: { params: Widget }) { - const { db } = useStorage(); - const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = - useInfiniteQuery({ - queryKey: ['follows-' + params.title], - initialPageParam: 0, - queryFn: async ({ pageParam = 0 }) => { - return await db.getAllEventsByAuthors(db.account.follows, 20, pageParam); - }, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }); - - const dbEvents = useMemo( - () => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []), - [data] - ); - - // render event match event kind - const renderItem = useCallback( - (dbEvent: DBEvent) => { - const event: NDKEvent = JSON.parse(dbEvent.event as string); - switch (event.kind) { - case NDKKind.Text: - return ( - - - - ); - case NDKKind.Repost: - return ; - case 1063: - return ( - - - - ); - case NDKKind.Article: - return ( - - - - ); - default: - return ( - - - - ); - } - }, - [dbEvents] - ); - - return ( - - -
- {status === 'pending' ? ( -
-
- -

- Loading post... -

-
-
- ) : dbEvents.length === 0 ? ( -
-
- empty feeds -
-

- Oops, it looks like there are no posts. -

-

- You can close this widget -

-
-
-
- ) : ( - - {dbEvents.map((item) => renderItem(item))} -
- {dbEvents.length > 0 ? ( - - ) : null} -
-
- - )} -
- - ); -} diff --git a/src/shared/widgets/local/user.tsx b/src/shared/widgets/local/user.tsx index c436ebc6..4f6311f7 100644 --- a/src/shared/widgets/local/user.tsx +++ b/src/shared/widgets/local/user.tsx @@ -26,14 +26,30 @@ export function LocalUserWidget({ params }: { params: Widget }) { const { status, data } = useQuery({ queryKey: ['user-posts', params.content], queryFn: async () => { + const rootIds = new Set(); + const dedupQueue = new Set(); + const events = await ndk.fetchEvents({ // @ts-expect-error, NDK not support file metadata yet kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], authors: [params.content], since: nHoursAgo(24), }); - const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at); - return sortedEvents; + + const ndkEvents = [...events]; + + ndkEvents.forEach((event) => { + const tags = event.tags.filter((el) => el[0] === 'e'); + if (tags && tags.length > 0) { + const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1]; + if (rootIds.has(rootId)) return dedupQueue.add(event.id); + rootIds.add(rootId); + } + }); + + return ndkEvents + .filter((event) => !dedupQueue.has(event.id)) + .sort((a, b) => b.created_at - a.created_at); }, staleTime: Infinity, refetchOnMount: false, diff --git a/src/shared/widgets/newsfeed.tsx b/src/shared/widgets/newsfeed.tsx index fa6a7f74..ab080eae 100644 --- a/src/shared/widgets/newsfeed.tsx +++ b/src/shared/widgets/newsfeed.tsx @@ -1,6 +1,6 @@ import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect } from 'react'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; import { VList } from 'virtua'; import { useNDK } from '@libs/ndk/provider'; @@ -19,92 +19,66 @@ import { import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { nHoursAgo } from '@utils/date'; import { useNostr } from '@utils/hooks/useNostr'; export function NewsfeedWidget() { + const queryClient = useQueryClient(); + const { db } = useStorage(); const { sub } = useNostr(); const { relayUrls, ndk, fetcher } = useNDK(); - const { status, data } = useQuery({ - queryKey: ['newsfeed'], - queryFn: async ({ signal }: { signal: AbortSignal }) => { - const rootIds = new Set(); - const dedupQueue = new Set(); + const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: ['newsfeed'], + initialPageParam: 0, + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const rootIds = new Set(); + const dedupQueue = new Set(); - const events = await fetcher.fetchAllEvents( - relayUrls, - { - kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], - authors: db.account.circles, - }, - { - since: db.account.last_login_at === 0 ? nHoursAgo(4) : db.account.last_login_at, - }, - { abortSignal: signal } - ); + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], + authors: db.account.circles, + }, + 50, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); - const ndkEvents = events.map((event) => { - return new NDKEvent(ndk, event); - }); + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); - ndkEvents.forEach((event) => { - const tags = event.tags.filter((el) => el[0] === 'e'); - if (tags && tags.length > 0) { - const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1]; - if (rootIds.has(rootId)) return dedupQueue.add(event.id); - rootIds.add(rootId); - } - }); + ndkEvents.forEach((event) => { + const tags = event.tags.filter((el) => el[0] === 'e'); + if (tags && tags.length > 0) { + const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1]; + if (rootIds.has(rootId)) return dedupQueue.add(event.id); + rootIds.add(rootId); + } + }); - return ndkEvents - .filter((event) => !dedupQueue.has(event.id)) - .sort((a, b) => b.created_at - a.created_at); - }, - }); + return ndkEvents + .filter((event) => !dedupQueue.has(event.id)) + .sort((a, b) => b.created_at - a.created_at); + }, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + }); - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: async () => { - const currentLastEvent = data.at(-1); - const lastCreatedAt = currentLastEvent.created_at - 1; - - const rootIds = new Set(); - const dedupQueue = new Set(); - - const events = await fetcher.fetchLatestEvents( - relayUrls, - { - kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], - authors: db.account.circles, - }, - 100, - { - asOf: lastCreatedAt, - } - ); - - const ndkEvents = events.map((event) => { - return new NDKEvent(ndk, event); - }); - - ndkEvents.forEach((event) => { - const tags = event.tags.filter((el) => el[0] === 'e'); - if (tags && tags.length > 0) { - const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1]; - if (rootIds.has(rootId)) return dedupQueue.add(event.id); - rootIds.add(rootId); - } - }); - - return ndkEvents - .filter((event) => !dedupQueue.has(event.id)) - .sort((a, b) => b.created_at - a.created_at); - }, - onSuccess: async (data) => { - queryClient.setQueryData(['newsfeed'], (old: NDKEvent[]) => [...old, ...data]); - }, - }); + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), + [data] + ); const renderItem = useCallback((event: NDKEvent) => { switch (event.kind) { @@ -138,46 +112,55 @@ export function NewsfeedWidget() { }, []); useEffect(() => { - if (db.account && db.account.circles.length > 0) { + if (status === 'success' && db.account && db.account.circles.length > 0) { + queryClient.fetchQuery({ queryKey: ['notification'] }); + const filter: NDKFilter = { kinds: [NDKKind.Text, NDKKind.Repost], authors: db.account.circles, since: Math.floor(Date.now() / 1000), }; - sub(filter, async (event) => { - queryClient.setQueryData(['newsfeed'], (old: NDKEvent[]) => [event, ...old]); - }); + sub( + filter, + async (event) => { + queryClient.setQueryData(['newsfeed'], (old: NDKEvent[]) => [event, ...old]); + }, + false, + 'newsfeed' + ); } - }, []); + }, [status]); return ( {status === 'pending' ? ( -
-
-
- -
-
-
- +
+
+
) : ( - data.map((item) => renderItem(item)) + allEvents.map((item) => renderItem(item)) )}
- {data ? ( + {hasNextPage ? ( ) : null}
diff --git a/src/shared/widgets/notification.tsx b/src/shared/widgets/notification.tsx index b0134c56..104226d5 100644 --- a/src/shared/widgets/notification.tsx +++ b/src/shared/widgets/notification.tsx @@ -1,59 +1,145 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { useCallback, useEffect } from 'react'; +import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; import { VList } from 'virtua'; +import { useNDK } from '@libs/ndk/provider'; import { useStorage } from '@libs/storage/provider'; -import { LoaderIcon } from '@shared/icons'; +import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; +import { NoteSkeleton } from '@shared/notes'; import { NotifyNote } from '@shared/notification/notifyNote'; import { TitleBar } from '@shared/titleBar'; import { WidgetWrapper } from '@shared/widgets'; -import { useActivities } from '@stores/activities'; - import { useNostr } from '@utils/hooks/useNostr'; -import { Widget } from '@utils/types'; +import { sendNativeNotification } from '@utils/notification'; + +export function NotificationWidget() { + const queryClient = useQueryClient(); -export function LocalNotificationWidget({ params }: { params: Widget }) { const { db } = useStorage(); - const { getAllActivities } = useNostr(); + const { sub } = useNostr(); + const { ndk, relayUrls, fetcher } = useNDK(); + const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: ['notification'], + initialPageParam: 0, + queryFn: async ({ + signal, + pageParam, + }: { + signal: AbortSignal; + pageParam: number; + }) => { + const events = await fetcher.fetchLatestEvents( + relayUrls, + { + kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap], + '#p': [db.account.pubkey], + }, + 50, + { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } + ); - const [activities, setActivities] = useActivities((state) => [ - state.activities, - state.setActivities, - ]); + const ndkEvents = events.map((event) => { + return new NDKEvent(ndk, event); + }); - const renderEvent = useCallback( - (event: NDKEvent) => { - if (event.pubkey === db.account.pubkey) return null; - return ; - }, - [activities] + return ndkEvents.sort((a, b) => b.created_at - a.created_at); + }, + getNextPageParam: (lastPage) => { + const lastEvent = lastPage.at(-1); + if (!lastEvent) return; + return lastEvent.created_at - 1; + }, + enabled: false, + }); + + const allEvents = useMemo( + () => (data ? data.pages.flatMap((page) => page) : []), + [data] ); - useEffect(() => { - async function getActivities() { - const events = await getAllActivities(48); - setActivities(events); - } - - getActivities(); + const renderEvent = useCallback((event: NDKEvent) => { + if (event.pubkey === db.account.pubkey) return null; + return ; }, []); + useEffect(() => { + if (status === 'success' && db.account) { + const filter = { + kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap], + '#p': [db.account.pubkey], + since: Math.floor(Date.now() / 1000), + }; + + sub( + filter, + async (event) => { + queryClient.setQueryData(['notification'], (old: NDKEvent[]) => [ + event, + ...old, + ]); + + const user = ndk.getUser({ hexpubkey: event.pubkey }); + await user.fetchProfile(); + + switch (event.kind) { + case NDKKind.Text: + return await sendNativeNotification( + `${ + user.profile.displayName || user.profile.name + } has replied to your note` + ); + case NDKKind.EncryptedDirectMessage: { + if (location.pathname !== '/chats') { + return await sendNativeNotification( + `${ + user.profile.displayName || user.profile.name + } has send you a encrypted message` + ); + } else { + break; + } + } + case NDKKind.Repost: + return await sendNativeNotification( + `${ + user.profile.displayName || user.profile.name + } has reposted to your note` + ); + case NDKKind.Reaction: + return await sendNativeNotification( + `${user.profile.displayName || user.profile.name} has reacted ${ + event.content + } to your note` + ); + case NDKKind.Zap: + return await sendNativeNotification( + `${user.profile.displayName || user.profile.name} has zapped to your note` + ); + default: + break; + } + }, + false, + 'notification' + ); + } + }, [status]); + return ( - -
- {!activities ? ( -
-
- -

- Loading... -

+ + + {status === 'pending' ? ( +
+
+
- ) : activities.length < 1 ? ( + ) : allEvents.length < 1 ? (

๐ŸŽ‰

@@ -61,12 +147,28 @@ export function LocalNotificationWidget({ params }: { params: Widget }) {

) : ( - - {activities.map((event) => renderEvent(event))} -
- + allEvents.map((event) => renderEvent(event)) )} -
+
+ {hasNextPage ? ( + + ) : null} +
+
); } diff --git a/src/shared/widgets/other/learnNostr.tsx b/src/shared/widgets/other/learnNostr.tsx deleted file mode 100644 index 0888a746..00000000 --- a/src/shared/widgets/other/learnNostr.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useNavigate } from 'react-router-dom'; - -import { ArrowRightIcon } from '@shared/icons'; -import { TitleBar } from '@shared/titleBar'; -import { WidgetWrapper } from '@shared/widgets'; - -import { useResources } from '@stores/resources'; - -import { Widget } from '@utils/types'; - -export function LearnNostrWidget({ params }: { params: Widget }) { - const navigate = useNavigate(); - const openResource = useResources((state) => state.openResource); - const resources = useResources((state) => state.resources); - const seens = useResources((state) => state.seens); - - const open = (naddr: string) => { - // add resource to seen list - openResource(naddr); - // redirect - navigate(`/notes/article/${naddr}`); - }; - - return ( - - -
- {resources.map((resource, index) => ( -
-

- {resource.title} -

-
- {resource.data.length ? ( - resource.data.map((item, index) => ( - - )) - ) : ( -
-

- More resources are coming, stay tuned. -

-
- )} -
-
- ))} -
-
- ); -} diff --git a/src/shared/widgets/tmp/feeds.tsx b/src/shared/widgets/tmp/feeds.tsx index d046c159..2178a404 100644 --- a/src/shared/widgets/tmp/feeds.tsx +++ b/src/shared/widgets/tmp/feeds.tsx @@ -5,17 +5,15 @@ import { useStorage } from '@libs/storage/provider'; import { ArrowRightCircleIcon, CheckCircleIcon } from '@shared/icons'; import { User } from '@shared/user'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; +import { useWidget } from '@utils/hooks/useWidget'; import { Widget } from '@utils/types'; export function XfeedsWidget({ params }: { params: Widget }) { const { db } = useStorage(); + const { addWidget, removeWidget } = useWidget(); - const [setWidget, removeWidget] = useWidgets((state) => [ - state.setWidget, - state.removeWidget, - ]); const [title, setTitle] = useState(''); const [groups, setGroups] = useState>([]); @@ -28,17 +26,17 @@ export function XfeedsWidget({ params }: { params: Widget }) { }; const cancel = () => { - removeWidget(db, params.id); + removeWidget.mutate(params.id); }; const submit = async () => { - setWidget(db, { + addWidget.mutate({ kind: WidgetKinds.local.feeds, title: title || 'Group', content: JSON.stringify(groups), }); // remove temp widget - removeWidget(db, params.id); + removeWidget.mutate(params.id); }; return ( diff --git a/src/shared/widgets/tmp/hashtag.tsx b/src/shared/widgets/tmp/hashtag.tsx index 973dd9a7..278abd4a 100644 --- a/src/shared/widgets/tmp/hashtag.tsx +++ b/src/shared/widgets/tmp/hashtag.tsx @@ -1,11 +1,10 @@ import { Resolver, useForm } from 'react-hook-form'; -import { useStorage } from '@libs/storage/provider'; - import { ArrowRightCircleIcon } from '@shared/icons'; -import { WidgetKinds, useWidgets } from '@stores/widgets'; +import { WidgetKinds } from '@stores/constants'; +import { useWidget } from '@utils/hooks/useWidget'; import { Widget } from '@utils/types'; type FormValues = { @@ -27,12 +26,7 @@ const resolver: Resolver = async (values) => { }; export function XhashtagWidget({ params }: { params: Widget }) { - const [setWidget, removeWidget] = useWidgets((state) => [ - state.setWidget, - state.removeWidget, - ]); - - const { db } = useStorage(); + const { addWidget, removeWidget } = useWidget(); const { register, setError, @@ -41,18 +35,18 @@ export function XhashtagWidget({ params }: { params: Widget }) { } = useForm({ resolver }); const cancel = () => { - removeWidget(db, params.id); + removeWidget.mutate(params.id); }; const onSubmit = async (data: FormValues) => { try { - setWidget(db, { + addWidget.mutate({ kind: WidgetKinds.global.hashtag, title: data.hashtag, content: data.hashtag.replace('#', ''), }); // remove temp widget - removeWidget(db, params.id); + removeWidget.mutate(params.id); } catch (e) { setError('hashtag', { type: 'custom', diff --git a/src/stores/activities.ts b/src/stores/activities.ts deleted file mode 100644 index 0265a9e5..00000000 --- a/src/stores/activities.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { create } from 'zustand'; - -interface ActivitiesState { - activities: Array; - newMessages: number; - setActivities: (events: NDKEvent[]) => void; - addActivity: (event: NDKEvent) => void; - addNewMessage: () => void; - clearNewMessage: () => void; -} - -export const useActivities = create((set) => ({ - activities: null, - newMessages: 0, - setActivities: (events: NDKEvent[]) => { - set(() => ({ - activities: events, - })); - }, - addActivity: (event: NDKEvent) => { - set((state) => ({ - activities: state.activities ? [event, ...state.activities] : [event], - })); - }, - addNewMessage: () => { - set((state) => ({ newMessages: state.newMessages + 1 })); - }, - clearNewMessage: () => { - set(() => ({ newMessages: 0 })); - }, -})); diff --git a/src/stores/constants.ts b/src/stores/constants.ts index 5a76cdfc..46bc3f2c 100644 --- a/src/stores/constants.ts +++ b/src/stores/constants.ts @@ -1,3 +1,5 @@ +import { WidgetGroup } from '@utils/types'; + export const FULL_RELAYS = [ 'wss://relay.damus.io', 'wss://relay.primal.net', @@ -5,3 +7,104 @@ export const FULL_RELAYS = [ 'wss://relay.nostr.band/all', 'wss://nostr.mutinywallet.com', ]; + +export const FETCH_LIMIT = 50; + +export const WidgetKinds = { + local: { + network: 100, + feeds: 101, + files: 102, + articles: 103, + user: 104, + thread: 105, + follows: 106, + notification: 107, + }, + global: { + feeds: 1000, + files: 1001, + articles: 1002, + hashtag: 1003, + }, + nostrBand: { + trendingAccounts: 1, + trendingNotes: 2, + }, + other: { + learnNostr: 90000, + }, + tmp: { + list: 10000, + xfeed: 10001, + xhashtag: 10002, + }, +}; + +export const DefaultWidgets: Array = [ + { + title: 'Circles / Follows', + data: [ + { + kind: WidgetKinds.tmp.xfeed, + title: 'Group feeds', + description: 'All posts from specific people you want to keep up with', + }, + { + kind: WidgetKinds.local.files, + title: 'Files', + description: 'All files shared by people in your circle', + }, + { + kind: WidgetKinds.local.articles, + title: 'Articles', + description: 'All articles shared by people in your circle', + }, + ], + }, + { + title: 'Global', + data: [ + { + kind: WidgetKinds.tmp.xhashtag, + title: 'Hashtag', + description: 'All posts have a specific hashtag', + }, + { + kind: WidgetKinds.global.files, + title: 'Files', + description: 'All files shared by people in your current relay set', + }, + { + kind: WidgetKinds.global.articles, + title: 'Articles', + description: 'All articles shared by people in your current relay set', + }, + ], + }, + { + title: 'nostr.band', + data: [ + { + kind: WidgetKinds.nostrBand.trendingAccounts, + title: 'Accounts', + description: 'Trending accounts from the last 24 hours', + }, + { + kind: WidgetKinds.nostrBand.trendingNotes, + title: 'Notes', + description: 'Trending notes from the last 24 hours', + }, + ], + }, + { + title: 'Other', + data: [ + { + kind: WidgetKinds.local.notification, + title: 'Notification', + description: 'Everything happens around you', + }, + ], + }, +]; diff --git a/src/stores/widgets.ts b/src/stores/widgets.ts deleted file mode 100644 index d572b053..00000000 --- a/src/stores/widgets.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { LumeStorage } from '@libs/storage/instance'; - -import { Widget, WidgetGroup } from '@utils/types'; - -interface WidgetState { - widgets: null | Array; - isFetched: boolean; - fetchWidgets: (db: LumeStorage) => void; - setWidget: (db: LumeStorage, { kind, title, content }: Widget) => void; - removeWidget: (db: LumeStorage, id: string) => void; - reorderWidget: (id: string, position: number) => void; - setIsFetched: () => void; -} - -export const WidgetKinds = { - local: { - network: 100, - feeds: 101, - files: 102, - articles: 103, - user: 104, - thread: 105, - follows: 106, - notification: 107, - }, - global: { - feeds: 1000, - files: 1001, - articles: 1002, - hashtag: 1003, - }, - nostrBand: { - trendingAccounts: 1, - trendingNotes: 2, - }, - other: { - learnNostr: 90000, - }, - tmp: { - list: 10000, - xfeed: 10001, - xhashtag: 10002, - }, -}; - -export const DefaultWidgets: Array = [ - { - title: 'Circles / Follows', - data: [ - { - kind: WidgetKinds.tmp.xfeed, - title: 'Group feeds', - description: 'All posts from specific people you want to keep up with', - }, - { - kind: WidgetKinds.local.files, - title: 'Files', - description: 'All files shared by people in your circle', - }, - { - kind: WidgetKinds.local.articles, - title: 'Articles', - description: 'All articles shared by people in your circle', - }, - { - kind: WidgetKinds.local.follows, - title: 'Follows', - description: 'All posts from people you are following', - }, - ], - }, - { - title: 'Global', - data: [ - { - kind: WidgetKinds.tmp.xhashtag, - title: 'Hashtag', - description: 'All posts have a specific hashtag', - }, - { - kind: WidgetKinds.global.files, - title: 'Files', - description: 'All files shared by people in your current relay set', - }, - { - kind: WidgetKinds.global.articles, - title: 'Articles', - description: 'All articles shared by people in your current relay set', - }, - ], - }, - { - title: 'nostr.band', - data: [ - { - kind: WidgetKinds.nostrBand.trendingAccounts, - title: 'Accounts', - description: 'Trending accounts from the last 24 hours', - }, - { - kind: WidgetKinds.nostrBand.trendingNotes, - title: 'Notes', - description: 'Trending notes from the last 24 hours', - }, - ], - }, - { - title: 'Other', - data: [ - { - kind: WidgetKinds.local.notification, - title: 'Notification', - description: 'Everything happens around you', - }, - { - kind: WidgetKinds.other.learnNostr, - title: 'Learn Nostr', - description: 'All things you need to know about Nostr', - }, - ], - }, -]; - -export const useWidgets = create()( - persist( - (set) => ({ - widgets: null, - isFetched: false, - fetchWidgets: async (db: LumeStorage) => { - const dbWidgets = await db.getWidgets(); - - /* - dbWidgets.unshift({ - id: '9998', - title: 'Notification', - content: '', - kind: WidgetKinds.local.notification, - }); - */ - - dbWidgets.unshift({ - id: '9999', - title: '', - content: '', - kind: WidgetKinds.local.network, - }); - - set({ widgets: dbWidgets }); - }, - setWidget: async (db: LumeStorage, { kind, title, content }: Widget) => { - const widget: Widget = await db.createWidget(kind, title, content); - set((state) => ({ widgets: [...state.widgets, widget] })); - }, - removeWidget: async (db: LumeStorage, id: string) => { - await db.removeWidget(id); - set((state) => ({ widgets: state.widgets.filter((widget) => widget.id !== id) })); - }, - reorderWidget: (id: string, position: number) => { - set((state) => { - const widgets = [...state.widgets]; - const widget = widgets.find((widget) => widget.id === id); - if (!widget) return { widgets }; - - const idx = widgets.indexOf(widget); - widgets.splice(idx, 1); - widgets.splice(position, 0, widget); - - return { widgets }; - }); - }, - setIsFetched: () => { - set({ isFetched: true }); - }, - }), - { - name: 'widgets', - storage: createJSONStorage(() => sessionStorage), - } - ) -); diff --git a/src/utils/hooks/useNostr.ts b/src/utils/hooks/useNostr.ts index ed44e08e..00145054 100644 --- a/src/utils/hooks/useNostr.ts +++ b/src/utils/hooks/useNostr.ts @@ -29,11 +29,12 @@ export function useNostr() { const sub = async ( filter: NDKFilter, callback: (event: NDKEvent) => void, - groupable?: boolean + groupable?: boolean, + subKey?: string ) => { if (!ndk) throw new Error('NDK instance not found'); - const key = JSON.stringify(filter); + const key = subKey ?? JSON.stringify(filter); if (!subManager.get(key)) { const subEvent = ndk.subscribe(filter, { closeOnEose: false, diff --git a/src/utils/hooks/useWidget.ts b/src/utils/hooks/useWidget.ts new file mode 100644 index 00000000..b2203162 --- /dev/null +++ b/src/utils/hooks/useWidget.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useStorage } from '@libs/storage/provider'; + +import { Widget } from '@utils/types'; + +export function useWidget() { + const { db } = useStorage(); + const queryClient = useQueryClient(); + + const addWidget = useMutation({ + mutationFn: async (widget: Widget) => { + return await db.createWidget(widget.kind, widget.title, widget.content); + }, + onSuccess: (data) => { + queryClient.setQueryData(['widgets'], (old: Widget[]) => [...old, data]); + }, + }); + + const removeWidget = useMutation({ + mutationFn: async (id: string) => { + return await db.removeWidget(id); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['widgets'] }); + }, + }); + + return { addWidget, removeWidget }; +}