diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index ae30e972..27f8d4fe 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; interface ImageGalleryProps { images: string[]; @@ -62,32 +63,15 @@ export function ImageGallery({ )} > {visibleImages.map((url, i) => ( - + url={url} + index={i} + visibleCount={visibleImages.length} + maxGridHeight={maxGridHeight} + overflow={i === visibleImages.length - 1 ? overflow : 0} + onOpen={(e) => openLightbox(i, e)} + /> ))} @@ -105,6 +89,83 @@ export function ImageGallery({ ); } +/** Single image tile with a skeleton shown until the image loads. */ +function GridImage({ + url, + index, + visibleCount, + maxGridHeight, + overflow, + onOpen, +}: { + url: string; + index: number; + visibleCount: number; + maxGridHeight: string; + overflow: number; + onOpen: (e: React.MouseEvent) => void; +}) { + const [loaded, setLoaded] = useState(false); + const imgRef = useRef(null); + + // If the image is already cached by the browser, onLoad may have + // fired before the ref was attached. Check on mount. + useEffect(() => { + if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) { + setLoaded(true); + } + }, []); + + const isSingle = visibleCount === 1; + const heightStyle = isSingle + ? 'auto' + : visibleCount === 3 && index === 0 + ? maxGridHeight + : `calc(${maxGridHeight} / 2)`; + + return ( + + ); +} + export interface LightboxProps { images: string[]; currentIndex: number; diff --git a/src/hooks/useHasUnreadNotifications.ts b/src/hooks/useHasUnreadNotifications.ts index 9cf7c630..a1c5ba73 100644 --- a/src/hooks/useHasUnreadNotifications.ts +++ b/src/hooks/useHasUnreadNotifications.ts @@ -1,4 +1,3 @@ -import { useState, useEffect } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import { Capacitor } from '@capacitor/core'; @@ -19,16 +18,6 @@ export function useHasUnreadNotifications(): boolean { const { user } = useCurrentUser(); const { settings } = useEncryptedSettings(); - // Delay query by 3 seconds to avoid competing with feed load (same as useNotifications) - const [queryEnabled, setQueryEnabled] = useState(false); - - useEffect(() => { - if (user) { - const timer = setTimeout(() => setQueryEnabled(true), 3000); - return () => clearTimeout(timer); - } - }, [user]); - // Only use cursor if settings have actually loaded, otherwise null const notificationsCursor = settings !== undefined && settings !== null ? (settings.notificationsCursor ?? 0) @@ -52,7 +41,7 @@ export function useHasUnreadNotifications(): boolean { // Check that the returned event isn't from the user themselves return events.some((e) => e.pubkey !== user.pubkey); }, - enabled: queryEnabled && !!user && notificationsCursor !== null, + enabled: !!user && notificationsCursor !== null, refetchInterval: Capacitor.isNativePlatform() ? false : 60_000, placeholderData: (prev) => prev, }); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 07f1e0e7..46bcf8df 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,95 +1,203 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useMemo } from 'react'; import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { Capacitor } from '@capacitor/core'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { useEncryptedSettings } from './useEncryptedSettings'; +const PAGE_SIZE = 20; + +export interface NotificationItem { + /** The notification event (kind 1, 6, 7, or 9735). */ + event: NostrEvent; + /** The referenced event (the post that was liked/reposted/zapped), if available. */ + referencedEvent?: NostrEvent; +} + +interface NotificationPage { + items: NotificationItem[]; + /** Oldest event timestamp in this page, used for cursor-based pagination. */ + oldestTimestamp: number; +} + export interface NotificationData { - /** All notifications */ - notifications: NostrEvent[]; - /** Notifications newer than cursor (unread) */ - newNotifications: NostrEvent[]; - /** Whether there are any unread notifications */ + /** All notification items (paginated, flattened). */ + items: NotificationItem[]; + /** IDs of notifications newer than cursor (unread). */ + newNotificationIds: Set; + /** Whether there are any unread notifications. */ hasUnread: boolean; - /** Mark all current notifications as read */ + /** Mark all current notifications as read. */ markAsRead: () => Promise; - /** Loading state */ + /** Loading state for the first page. */ isLoading: boolean; - /** Whether the query has completed at least once */ + /** Whether the query has completed at least once. */ hasFetched: boolean; + /** Whether more pages are available. */ + hasNextPage: boolean; + /** Whether a next page is currently being fetched. */ + isFetchingNextPage: boolean; + /** Fetch the next page. */ + fetchNextPage: () => void; +} + +/** Get the referenced event ID from an event's tags. */ +function getReferencedEventId(event: NostrEvent): string | undefined { + const eTag = event.tags.find(([name]) => name === 'e'); + return eTag?.[1]; } /** - * Hook to query notifications and track unread status - * Uses encrypted settings to persist the last-viewed timestamp + * Hook to query notifications with infinite scroll pagination and track unread status. + * Uses encrypted settings to persist the last-viewed timestamp. */ export function useNotifications(): NotificationData { const { nostr } = useNostr(); + const queryClient = useQueryClient(); const { user } = useCurrentUser(); const { settings, updateSettings } = useEncryptedSettings(); - // Delay notifications query by 3 seconds to avoid competing with feed load - const [queryEnabled, setQueryEnabled] = useState(false); - - useEffect(() => { - if (user) { - const timer = setTimeout(() => setQueryEnabled(true), 3000); - return () => clearTimeout(timer); - } - }, [user]); - - const { data: notifications = [], isLoading, isFetched } = useQuery({ + const infiniteQuery = useInfiniteQuery({ queryKey: ['notifications', user?.pubkey ?? ''], - queryFn: async ({ signal }) => { - if (!user) return []; + queryFn: async ({ pageParam, signal }) => { + if (!user) return { items: [], oldestTimestamp: Math.floor(Date.now() / 1000) }; + + const filter: Record = { + kinds: [1, 6, 7, 9735], + '#p': [user.pubkey], + limit: PAGE_SIZE, + }; + if (pageParam) { + filter.until = pageParam; + } + const events = await nostr.query( - [{ kinds: [1, 6, 7, 9735], '#p': [user.pubkey], limit: 15 }], + [filter as { kinds: number[]; '#p': string[]; limit: number; until?: number }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); - return events + + const rawEvents = Array.isArray(events) ? events : []; + + // Filter out own events and sort + const filtered = rawEvents .filter((e) => e.pubkey !== user.pubkey) .sort((a, b) => b.created_at - a.created_at); + + // Track oldest timestamp from the raw query for pagination + const oldestTimestamp = filtered.length > 0 + ? Math.min(...filtered.map((e) => e.created_at)) + : Math.floor(Date.now() / 1000); + + // Collect referenced event IDs for batch fetching + const referencedIds: string[] = []; + for (const ev of filtered) { + // kind 1 (mention) IS the notification content; no referenced event needed + if (ev.kind !== 1) { + const refId = getReferencedEventId(ev); + if (refId) referencedIds.push(refId); + } + } + + // Batch-fetch referenced events in a single query + const referencedMap = new Map(); + if (referencedIds.length > 0) { + // Check query cache first, only fetch IDs we don't have + const uncachedIds: string[] = []; + for (const id of referencedIds) { + const cached = queryClient.getQueryData(['event', id]); + if (cached) { + referencedMap.set(id, cached); + } else { + uncachedIds.push(id); + } + } + + if (uncachedIds.length > 0) { + try { + const refEvents = await nostr.query( + [{ ids: uncachedIds, limit: uncachedIds.length }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + for (const refEv of refEvents) { + referencedMap.set(refEv.id, refEv); + // Seed the event cache so NoteCard sub-queries resolve instantly + if (!queryClient.getQueryData(['event', refEv.id])) { + queryClient.setQueryData(['event', refEv.id], refEv); + } + } + } catch { + // Timeout — referenced events will load individually via useEvent + } + } + } + + // Build notification items + const items: NotificationItem[] = filtered.map((ev) => { + const refId = ev.kind !== 1 ? getReferencedEventId(ev) : undefined; + return { + event: ev, + referencedEvent: refId ? referencedMap.get(refId) : undefined, + }; + }); + + return { items, oldestTimestamp }; }, - enabled: queryEnabled && !!user, - // On native platforms, skip automatic background refetch since the native - // relay service maintains a persistent WebSocket subscription. The in-app - // feed still fetches on mount and can be manually refetched. + getNextPageParam: (lastPage) => { + if (!lastPage || lastPage.items.length === 0) return undefined; + return lastPage.oldestTimestamp - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!user, + staleTime: 30_000, refetchInterval: Capacitor.isNativePlatform() ? false : 60_000, - placeholderData: (prev) => prev, // Keep previous data during refetch to prevent flickering }); + const { + data, + isLoading, + isFetched, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = infiniteQuery; + + // Flatten and deduplicate across pages + const items = useMemo(() => { + if (!data?.pages) return []; + const seen = new Set(); + return data.pages.flatMap((page) => page.items).filter((item) => { + if (seen.has(item.event.id)) return false; + seen.add(item.event.id); + return true; + }); + }, [data?.pages]); + // Only use cursor if settings have actually loaded, otherwise null - // This prevents false positives when settings are still loading - const notificationsCursor = settings !== undefined && settings !== null + const notificationsCursor = settings !== undefined && settings !== null ? (settings.notificationsCursor ?? 0) : null; - // Determine which notifications are new (created after cursor) - // Only calculate if we have a valid cursor (settings loaded) - const newNotifications = notificationsCursor !== null - ? notifications.filter((event) => event.created_at > notificationsCursor) - : []; + // Build set of unread notification IDs + const newNotificationIds = useMemo(() => { + if (notificationsCursor === null) return new Set(); + return new Set( + items + .filter((item) => item.event.created_at > notificationsCursor) + .map((item) => item.event.id), + ); + }, [items, notificationsCursor]); - // Only show unread badge when: - // 1. Settings have loaded (cursor is not null) - // 2. Notifications query has run - // 3. There are new notifications - const hasUnread = notificationsCursor !== null && queryEnabled && newNotifications.length > 0; + const hasUnread = notificationsCursor !== null && newNotificationIds.size > 0; // Mark all current notifications as read by updating the cursor const markAsRead = useCallback(async () => { - if (!user || notifications.length === 0 || notificationsCursor === null) return; + if (!user || items.length === 0 || notificationsCursor === null) return; - // Set cursor to the timestamp of the newest notification - const newestTimestamp = Math.max(...notifications.map((e) => e.created_at)); + const newestTimestamp = Math.max(...items.map((item) => item.event.created_at)); - // Only update if the cursor would actually change - if (newestTimestamp <= notificationsCursor) { - return; // Already marked as read - } + if (newestTimestamp <= notificationsCursor) return; try { await updateSettings.mutateAsync({ @@ -99,14 +207,17 @@ export function useNotifications(): NotificationData { console.error('Failed to mark notifications as read:', error); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.pubkey, notifications.length, notificationsCursor]); + }, [user?.pubkey, items.length, notificationsCursor]); return { - notifications, - newNotifications, + items, + newNotificationIds, hasUnread, markAsRead, isLoading, hasFetched: isFetched, + hasNextPage: hasNextPage ?? false, + isFetchingNextPage, + fetchNextPage, }; } diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 72d62111..a8025e63 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -1,16 +1,16 @@ import { useState, useMemo, useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; import { useSeoMeta } from '@unhead/react'; -import { Zap, AtSign } from 'lucide-react'; +import { Zap, AtSign, Loader2 } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { Link } from 'react-router-dom'; -import type { NostrEvent } from '@nostrify/nostrify'; import { Skeleton } from '@/components/ui/skeleton'; import { NoteCard } from '@/components/NoteCard'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useEvent } from '@/hooks/useEvent'; -import { useNotifications } from '@/hooks/useNotifications'; +import { useNotifications, type NotificationItem } from '@/hooks/useNotifications'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; @@ -29,39 +29,59 @@ export function NotificationsPage() { const [activeTab, setActiveTab] = useState('all'); const { user } = useCurrentUser(); - const { notifications, newNotifications, isLoading, hasFetched, markAsRead } = useNotifications(); + const { + items, + newNotificationIds, + isLoading, + hasFetched, + markAsRead, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotifications(); const { muteItems } = useMuteList(); // Mark notifications as read when user visits the page useEffect(() => { - // Only mark as read if there are actually NEW notifications - if (!user || newNotifications.length === 0) return; + if (!user || newNotificationIds.size === 0) return; - // Mark as read after a short delay to ensure user actually sees them const timer = setTimeout(() => { markAsRead(); }, 1000); return () => clearTimeout(timer); - }, [user, newNotifications.length, markAsRead]); + }, [user, newNotificationIds.size, markAsRead]); - const filteredNotifications = useMemo(() => { - let filtered = notifications; + // Intersection observer for infinite scroll + const { ref: scrollRef, inView } = useInView({ + threshold: 0, + rootMargin: '400px', + }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling + useEffect(() => { + if (hasNextPage && !isFetchingNextPage && items.length > 0 && items.length <= 20) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, items.length, fetchNextPage]); + + const filteredItems = useMemo(() => { + let filtered = items; // Filter out notifications from muted users/content if (muteItems.length > 0) { - filtered = filtered.filter((e) => !isEventMuted(e, muteItems)); + filtered = filtered.filter((item) => !isEventMuted(item.event, muteItems)); } if (activeTab === 'mentions') { - filtered = filtered.filter((e) => e.kind === 1); + filtered = filtered.filter((item) => item.event.kind === 1); } return filtered; - }, [notifications, activeTab, muteItems]); - - // Create a set of new notification IDs for quick lookup - const newNotificationIds = useMemo( - () => new Set(newNotifications.map((e) => e.id)), - [newNotifications] - ); + }, [items, activeTab, muteItems]); const tabs: { key: NotificationTab; label: string }[] = [ { key: 'all', label: 'All' }, @@ -69,78 +89,81 @@ export function NotificationsPage() { ]; return ( -
- {/* Tab bar */} -
- {tabs.map(({ key, label }) => ( - +
+ {/* Tab bar */} +
+ {tabs.map(({ key, label }) => ( + + ))} +
+ + {/* Content */} + {!user ? ( +
+ Log in to see your notifications. +
+ ) : isLoading || !hasFetched ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + ))}
- - {/* Content */} - {!user ? ( -
- Log in to see your notifications. -
- ) : isLoading || !hasFetched ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : filteredNotifications.length > 0 ? ( -
- {filteredNotifications.map((event) => ( - - ))} -
- ) : ( -
- No notifications yet. -
- )} -
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} +
+ ) : ( +
+ No notifications yet. +
+ )} +
); } /** Determines the type of notification and renders accordingly. */ -function NotificationItem({ event, isNew }: { event: NostrEvent; isNew: boolean }) { - switch (event.kind) { +function NotificationItemView({ item, isNew }: { item: NotificationItem; isNew: boolean }) { + switch (item.event.kind) { case 7: - return ; + return ; case 6: - return ; + return ; case 9735: - return ; + return ; case 1: - return ; + return ; default: return null; } } -/** Gets the referenced event ID from an event's tags. */ -function getReferencedEventId(event: NostrEvent): string | undefined { - const eTag = event.tags.find(([name]) => name === 'e'); - return eTag?.[1]; -} - /** Formats a sats amount into a compact human-readable string. */ function formatSats(sats: number): string { if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; @@ -148,41 +171,53 @@ function formatSats(sats: number): string { return sats.toString(); } -/** Wrapper that adds the new-notification indicator and renders the referenced post. */ +/** Wrapper that adds the new-notification indicator. */ function NotificationWrapper({ isNew, children }: { isNew: boolean; children: React.ReactNode }) { return (
{isNew && ( -
+
)} {children}
); } +/** + * Renders the referenced event as a NoteCard. + * Uses the pre-fetched event from the notification item, falling back to useEvent. + */ +function ReferencedNoteCard({ item }: { item: NotificationItem }) { + const referencedEventId = item.event.tags.find(([name]) => name === 'e')?.[1]; + // Fall back to useEvent if the batch fetch didn't find it + const { data: fetchedEvent } = useEvent( + item.referencedEvent ? undefined : referencedEventId, + ); + const event = item.referencedEvent ?? fetchedEvent; + + if (!event) return null; + + return ; +} + // ────────────────────────────────────── // Like Notification // ────────────────────────────────────── -function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) { - const referencedEventId = getReferencedEventId(event); - const { data: referencedEvent } = useEvent(referencedEventId); - +function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { return (
- + } action="reacted to your post" />
- {referencedEvent && ( - - )} +
); } @@ -190,22 +225,17 @@ function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean // ────────────────────────────────────── // Repost Notification // ────────────────────────────────────── -function RepostNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) { - const referencedEventId = getReferencedEventId(event); - const { data: referencedEvent } = useEvent(referencedEventId); - +function RepostNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { return (
} action="reposted your note" />
- {referencedEvent && ( - - )} +
); } @@ -213,9 +243,8 @@ function RepostNotification({ event, isNew }: { event: NostrEvent; isNew: boolea // ────────────────────────────────────── // Zap Notification // ────────────────────────────────────── -function ZapNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) { - const referencedEventId = getReferencedEventId(event); - const { data: referencedEvent } = useEvent(referencedEventId); +function ZapNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { + const { event } = item; // Extract zap amount const zapAmount = useMemo(() => { @@ -263,9 +292,7 @@ function ZapNotification({ event, isNew }: { event: NostrEvent; isNew: boolean } action={`zapped you${amountLabel}`} />
- {referencedEvent && ( - - )} + ); } @@ -273,17 +300,17 @@ function ZapNotification({ event, isNew }: { event: NostrEvent; isNew: boolean } // ────────────────────────────────────── // Mention Notification // ────────────────────────────────────── -function MentionNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) { +function MentionNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { return (
} action="mentioned you" />
- +
); }