Add infinite scroll to notifications, skeleton loading for images
- Convert useNotifications from useQuery to useInfiniteQuery with timestamp-based cursor pagination (20 per page) - Batch-fetch referenced events per page and seed the query cache - Add intersection observer + auto page-2 prefetch to NotificationsPage - Render referenced posts via NoteCard (same as main feed) - Add skeleton loading state to ImageGallery grid tiles with fade-in - Remove 3-second delay from notification queries
This commit is contained in:
@@ -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) => (
|
||||
<button
|
||||
<GridImage
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
visibleImages.length === 3 && i === 0 && 'row-span-2',
|
||||
)}
|
||||
onClick={(e) => openLightbox(i, e)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full object-cover transition-transform duration-200 hover:scale-[1.02]"
|
||||
style={{
|
||||
height: visibleImages.length === 1 ? 'auto' : visibleImages.length === 3 && i === 0 ? maxGridHeight : `calc(${maxGridHeight} / 2)`,
|
||||
maxHeight: visibleImages.length === 1 ? '85dvh' : undefined,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* "+N" overlay on last visible image */}
|
||||
{overflow > 0 && i === visibleImages.length - 1 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<span className="text-white text-2xl font-bold">+{overflow}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
url={url}
|
||||
index={i}
|
||||
visibleCount={visibleImages.length}
|
||||
maxGridHeight={maxGridHeight}
|
||||
overflow={i === visibleImages.length - 1 ? overflow : 0}
|
||||
onOpen={(e) => openLightbox(i, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLImageElement>(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 (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
visibleCount === 3 && index === 0 && 'row-span-2',
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* Skeleton placeholder — matches the image dimensions */}
|
||||
{!loaded && (
|
||||
<Skeleton
|
||||
className="absolute inset-0 w-full rounded-none"
|
||||
style={{
|
||||
height: isSingle ? '200px' : heightStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={url}
|
||||
alt=""
|
||||
className={cn(
|
||||
'w-full object-cover transition-all duration-300 hover:scale-[1.02]',
|
||||
loaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
height: heightStyle,
|
||||
maxHeight: isSingle ? '85dvh' : undefined,
|
||||
}}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
{/* "+N" overlay on last visible image */}
|
||||
{overflow > 0 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<span className="text-white text-2xl font-bold">+{overflow}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LightboxProps {
|
||||
images: string[];
|
||||
currentIndex: number;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+165
-54
@@ -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<string>;
|
||||
/** Whether there are any unread notifications. */
|
||||
hasUnread: boolean;
|
||||
/** Mark all current notifications as read */
|
||||
/** Mark all current notifications as read. */
|
||||
markAsRead: () => Promise<void>;
|
||||
/** 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<NostrEvent[]>({
|
||||
const infiniteQuery = useInfiniteQuery<NotificationPage, Error>({
|
||||
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<string, unknown> = {
|
||||
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<string, NostrEvent>();
|
||||
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<NostrEvent | null>(['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<string>();
|
||||
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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+131
-104
@@ -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<NotificationTab>('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 (
|
||||
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={cn(
|
||||
'flex-1 py-3.5 sidebar:py-5 text-sm font-medium sidebar:font-semibold transition-colors relative hover:bg-secondary/40',
|
||||
activeTab === key ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{activeTab === key && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 sidebar:h-[3px] bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={cn(
|
||||
'flex-1 py-3.5 sidebar:py-5 text-sm font-medium sidebar:font-semibold transition-colors relative hover:bg-secondary/40',
|
||||
activeTab === key ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{activeTab === key && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 sidebar:h-[3px] bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!user ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
Log in to see your notifications.
|
||||
</div>
|
||||
) : isLoading || !hasFetched ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<NotificationSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!user ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
Log in to see your notifications.
|
||||
</div>
|
||||
) : isLoading || !hasFetched ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<NotificationSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredNotifications.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredNotifications.map((event) => (
|
||||
<NotificationItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
isNew={newNotificationIds.has(event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
No notifications yet.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div>
|
||||
{filteredItems.map((item) => (
|
||||
<NotificationItemView
|
||||
key={item.event.id}
|
||||
item={item}
|
||||
isNew={newNotificationIds.has(item.event.id)}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
No notifications yet.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <LikeNotification event={event} isNew={isNew} />;
|
||||
return <LikeNotification item={item} isNew={isNew} />;
|
||||
case 6:
|
||||
return <RepostNotification event={event} isNew={isNew} />;
|
||||
return <RepostNotification item={item} isNew={isNew} />;
|
||||
case 9735:
|
||||
return <ZapNotification event={event} isNew={isNew} />;
|
||||
return <ZapNotification item={item} isNew={isNew} />;
|
||||
case 1:
|
||||
return <MentionNotification event={event} isNew={isNew} />;
|
||||
return <MentionNotification item={item} isNew={isNew} />;
|
||||
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 (
|
||||
<div className={cn('relative', isNew && 'bg-primary/5')}>
|
||||
{isNew && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary z-10" />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <NoteCard event={event} className="border-0" />;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// 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 (
|
||||
<NotificationWrapper isNew={isNew}>
|
||||
<div className="px-4 pt-3">
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
actorPubkey={item.event.pubkey}
|
||||
icon={
|
||||
<span className="text-base leading-none size-4 flex items-center justify-center">
|
||||
<ReactionEmoji content={event.content.trim()} tags={event.tags} className="inline-block h-4 w-4" />
|
||||
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
|
||||
</span>
|
||||
}
|
||||
action="reacted to your post"
|
||||
/>
|
||||
</div>
|
||||
{referencedEvent && (
|
||||
<NoteCard event={referencedEvent} className="border-0" />
|
||||
)}
|
||||
<ReferencedNoteCard item={item} />
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<NotificationWrapper isNew={isNew}>
|
||||
<div className="px-4 pt-3">
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
actorPubkey={item.event.pubkey}
|
||||
icon={<RepostIcon className="size-4 text-green-500" />}
|
||||
action="reposted your note"
|
||||
/>
|
||||
</div>
|
||||
{referencedEvent && (
|
||||
<NoteCard event={referencedEvent} className="border-0" />
|
||||
)}
|
||||
<ReferencedNoteCard item={item} />
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
</div>
|
||||
{referencedEvent && (
|
||||
<NoteCard event={referencedEvent} className="border-0" />
|
||||
)}
|
||||
<ReferencedNoteCard item={item} />
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<NotificationWrapper isNew={isNew}>
|
||||
<div className="px-4 pt-3">
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
actorPubkey={item.event.pubkey}
|
||||
icon={<AtSign className="size-4 text-primary" />}
|
||||
action="mentioned you"
|
||||
/>
|
||||
</div>
|
||||
<NoteCard event={event} className="border-0" />
|
||||
<NoteCard event={item.event} className="border-0" />
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user