diff --git a/package-lock.json b/package-lock.json index 2d6d735..3b5d1db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.3.0", "idb": "^8.0.3", "input-otp": "^1.2.4", @@ -4711,9 +4711,9 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 166e7f5..65f7681 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.3.0", "idb": "^8.0.3", "input-otp": "^1.2.4", diff --git a/src/components/notifications/NotificationCard.tsx b/src/components/notifications/NotificationCard.tsx new file mode 100644 index 0000000..d3409b9 --- /dev/null +++ b/src/components/notifications/NotificationCard.tsx @@ -0,0 +1,164 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { nip19, nip57 } from 'nostr-tools'; +import { Heart, MessageSquare, Zap, AtSign } from 'lucide-react'; +import { useAuthor } from '@/hooks/useAuthor'; +import { usePictureEvent } from '@/hooks/usePictureEvent'; +import { genUserName } from '@/lib/genUserName'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Card, CardContent } from '@/components/ui/card'; +import { formatDistanceToNow } from 'date-fns'; +import type { NotificationEvent } from '@/hooks/useNotifications'; +import { NoteContent } from '@/components/NoteContent'; + +interface NotificationCardProps { + notification: NotificationEvent; +} + +export function NotificationCard({ notification }: NotificationCardProps) { + const author = useAuthor(notification.pubkey); + const navigate = useNavigate(); + + // Fetch the target picture if this notification references one + const { data: targetPicture } = usePictureEvent(notification.targetEventId || ''); + + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(notification.pubkey); + const profileImage = metadata?.picture; + const npub = nip19.npubEncode(notification.pubkey); + const timeAgo = formatDistanceToNow(new Date(notification.created_at * 1000), { addSuffix: true }); + + // Get notification icon and label + const getNotificationInfo = () => { + switch (notification.notificationType) { + case 'reaction': + return { + icon: , + label: 'liked your picture', + color: 'text-red-500' + }; + case 'comment': + return { + icon: , + label: 'commented on your picture', + color: 'text-blue-500' + }; + case 'zap': + // Extract amount from zap + const bolt11Tag = notification.tags.find(([name]) => name === 'bolt11')?.[1]; + let amount = ''; + if (bolt11Tag) { + try { + const sats = nip57.getSatoshisAmountFromBolt11(bolt11Tag); + amount = ` ${sats} sats`; + } catch { + // Fallback if parsing fails + } + } + return { + icon: , + label: `zapped your picture${amount}`, + color: 'text-yellow-500' + }; + case 'mention': + return { + icon: , + label: 'mentioned you', + color: 'text-purple-500' + }; + default: + return { + icon: , + label: 'interacted with your content', + color: 'text-muted-foreground' + }; + } + }; + + const notificationInfo = getNotificationInfo(); + + // Get the target picture thumbnail if available + const targetImageUrl = targetPicture?.tags.find(([name]) => name === 'url')?.[1] || + targetPicture?.tags.find(([name]) => name === 'image')?.[1]; + + const handleClick = () => { + // Navigate to the target event or the notification event itself + if (notification.targetEventId && targetPicture) { + const nevent = nip19.neventEncode({ + id: notification.targetEventId, + author: targetPicture.pubkey + }); + navigate(`/${nevent}`); + } else { + const nevent = nip19.neventEncode({ + id: notification.id, + author: notification.pubkey + }); + navigate(`/${nevent}`); + } + }; + + return ( + + +
+ {/* Author Avatar */} + e.stopPropagation()} + className="shrink-0" + > + + + {displayName[0]?.toUpperCase()} + + + + {/* Notification Content */} +
+
+ e.stopPropagation()} + className="font-semibold hover:underline" + > + {displayName} + + {notificationInfo.label} + {notificationInfo.icon} +
+ + {/* Show comment content if it's a comment */} + {notification.notificationType === 'comment' && notification.content && ( +
+ +
+ )} + + {/* Show mention content if it's a mention */} + {notification.notificationType === 'mention' && notification.content && ( +
+ +
+ )} + +

{timeAgo}

+
+ + {/* Target Picture Thumbnail */} + {targetImageUrl && ( +
+ Target picture +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..3378d82 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,116 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; + +export interface NotificationEvent extends NostrEvent { + notificationType: 'reaction' | 'comment' | 'zap' | 'mention'; + targetEventId?: string; + targetEventKind?: number; +} + +/** + * Hook for fetching notifications for the current user + * Includes reactions, comments, zaps, and mentions + */ +export function useNotifications() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + return useInfiniteQuery({ + queryKey: ['notifications', user?.pubkey], + queryFn: async ({ pageParam, signal }) => { + if (!user) return []; + + // First, get the user's picture events (kind 20) to find what can be reacted to + const userPicturesFilter = pageParam + ? { kinds: [20], authors: [user.pubkey], limit: 100, until: pageParam as number } + : { kinds: [20], authors: [user.pubkey], limit: 100 }; + + const userPictures = await nostr.query([userPicturesFilter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) + }); + + const userPictureIds = userPictures.map(e => e.id); + + // Query for notifications using a single combined query + // Reactions (kind 7), Comments (kind 1111), Zaps (kind 9735) on user's pictures + // Plus mentions in text notes (kind 1) and comments (kind 1111) + const notificationFilter = pageParam + ? { + kinds: [7, 1111, 9735, 1], + '#e': userPictureIds.length > 0 ? userPictureIds : undefined, + '#p': [user.pubkey], + limit: 50, + until: pageParam as number, + } + : { + kinds: [7, 1111, 9735, 1], + '#e': userPictureIds.length > 0 ? userPictureIds : undefined, + '#p': [user.pubkey], + limit: 50, + }; + + // Remove undefined filters + if (!notificationFilter['#e']) { + delete notificationFilter['#e']; + } + + const events = await nostr.query([notificationFilter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) + }); + + // Filter out events by the user themselves and classify by type + const notifications: NotificationEvent[] = events + .filter(event => event.pubkey !== user.pubkey) + .map(event => { + // Determine notification type + let notificationType: 'reaction' | 'comment' | 'zap' | 'mention' = 'mention'; + let targetEventId: string | undefined; + let targetEventKind: number | undefined; + + if (event.kind === 7) { + notificationType = 'reaction'; + targetEventId = event.tags.find(([name]) => name === 'e')?.[1]; + } else if (event.kind === 1111) { + notificationType = 'comment'; + // Get the root event being commented on + const ETag = event.tags.find(([name]) => name === 'E')?.[1]; + const eTag = event.tags.find(([name]) => name === 'e')?.[1]; + targetEventId = ETag || eTag; + } else if (event.kind === 9735) { + notificationType = 'zap'; + targetEventId = event.tags.find(([name]) => name === 'e')?.[1]; + } else if (event.kind === 1) { + // Check if it's a mention or a reply + const pTags = event.tags.filter(([name]) => name === 'p'); + const eTags = event.tags.filter(([name]) => name === 'e'); + + // If it has e-tags, it might be a reply to user's content + if (eTags.length > 0) { + notificationType = 'mention'; + targetEventId = eTags[eTags.length - 1]?.[1]; // Get the reply target + } else { + notificationType = 'mention'; + } + } + + return { + ...event, + notificationType, + targetEventId, + targetEventKind, + } as NotificationEvent; + }) + .sort((a, b) => b.created_at - a.created_at); // Sort by newest first + + return notifications; + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!user, + }); +} diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 322c882..0891b9e 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,12 +1,157 @@ +import { useEffect, useMemo } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { useInView } from 'react-intersection-observer'; import { Layout } from '@/components/Layout'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNotifications } from '@/hooks/useNotifications'; +import { NotificationCard } from '@/components/notifications/NotificationCard'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent } from '@/components/ui/card'; +import { Bell } from 'lucide-react'; export function Notifications() { + useSeoMeta({ + title: 'Notifications - LUMINA', + description: 'View your notifications on LUMINA.', + }); + + const { user } = useCurrentUser(); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useNotifications(); + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Remove duplicate events by ID + const notifications = useMemo(() => { + const seen = new Set(); + return data?.pages.flat().filter(event => { + if (!event.id || seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) || []; + }, [data?.pages]); + + // Show login prompt for logged-out users + if (!user) { + return ( + +
+
+ + +
+
+
+ +
+
+
+

Login to view notifications

+

+ Sign in to see reactions, comments, and zaps on your content. +

+
+
+ +
+
+
+
+
+
+
+ ); + } + return (
-

Notifications

-

Notifications page - coming soon

+
+ +

Notifications

+
+ +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: 5 }).map((_, i) => ( + + +
+ +
+
+ + +
+ +
+ +
+
+
+ )) + ) : notifications.length === 0 ? ( + // Empty state + + +
+
+
+ +
+
+
+

No notifications yet

+

+ When others interact with your pictures, you'll see notifications here. +

+
+
+
+
+ ) : ( + // Notifications feed + <> + {notifications.map((notification) => ( + + ))} + + {/* Infinite scroll trigger */} + {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + +
+ +
+
+ + +
+ +
+ +
+
+
+ ))} +
+ )} +
+ )} + + )} +