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:
Chad Curtis
2026-02-22 03:15:13 -06:00
parent 65fc03db8c
commit bcd4e4bb80
4 changed files with 384 additions and 196 deletions
+87 -26
View File
@@ -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 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}