Add notifications feature: implement useNotifications hook and NotificationCard component; update Notifications page for displaying user notifications

This commit is contained in:
2025-11-27 22:31:20 +01:00
parent 831fdc14f7
commit 51294a7a2d
5 changed files with 432 additions and 7 deletions

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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: <Heart className="h-4 w-4 fill-red-500 text-red-500" />,
label: 'liked your picture',
color: 'text-red-500'
};
case 'comment':
return {
icon: <MessageSquare className="h-4 w-4 text-blue-500" />,
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: <Zap className="h-4 w-4 fill-yellow-500 text-yellow-500" />,
label: `zapped your picture${amount}`,
color: 'text-yellow-500'
};
case 'mention':
return {
icon: <AtSign className="h-4 w-4 text-purple-500" />,
label: 'mentioned you',
color: 'text-purple-500'
};
default:
return {
icon: <MessageSquare className="h-4 w-4" />,
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 (
<Card
className="cursor-pointer hover:bg-accent/50 transition-colors"
onClick={handleClick}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Author Avatar */}
<Link
to={`/${npub}`}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
<Avatar className="h-10 w-10 hover:ring-2 hover:ring-primary/30 transition-all">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
{/* Notification Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/${npub}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold hover:underline"
>
{displayName}
</Link>
<span className="text-sm text-muted-foreground">{notificationInfo.label}</span>
<span className={`${notificationInfo.color}`}>{notificationInfo.icon}</span>
</div>
{/* Show comment content if it's a comment */}
{notification.notificationType === 'comment' && notification.content && (
<div className="mt-2 text-sm text-muted-foreground line-clamp-2">
<NoteContent event={notification} className="text-sm" />
</div>
)}
{/* Show mention content if it's a mention */}
{notification.notificationType === 'mention' && notification.content && (
<div className="mt-2 text-sm text-muted-foreground line-clamp-2">
<NoteContent event={notification} className="text-sm" />
</div>
)}
<p className="text-xs text-muted-foreground mt-1">{timeAgo}</p>
</div>
{/* Target Picture Thumbnail */}
{targetImageUrl && (
<div className="shrink-0">
<img
src={targetImageUrl}
alt="Target picture"
className="h-16 w-16 object-cover rounded-md"
/>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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,
});
}

View File

@@ -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<string>();
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 (
<Layout>
<div className="container py-8">
<div className="max-w-2xl mx-auto">
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<Bell className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold">Login to view notifications</h2>
<p className="text-muted-foreground">
Sign in to see reactions, comments, and zaps on your content.
</p>
</div>
<div className="flex justify-center">
<LoginArea className="max-w-60" />
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container py-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-4">Notifications</h1>
<p className="text-muted-foreground">Notifications page - coming soon</p>
<div className="flex items-center gap-3 mb-6">
<Bell className="h-8 w-8" />
<h1 className="text-3xl font-bold">Notifications</h1>
</div>
<div className="space-y-4">
{isLoading ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-16 w-16 rounded-md" />
</div>
</CardContent>
</Card>
))
) : notifications.length === 0 ? (
// Empty state
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Bell className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">No notifications yet</h3>
<p className="text-muted-foreground">
When others interact with your pictures, you'll see notifications here.
</p>
</div>
</div>
</CardContent>
</Card>
) : (
// Notifications feed
<>
{notifications.map((notification) => (
<NotificationCard key={notification.id} notification={notification} />
))}
{/* Infinite scroll trigger */}
{hasNextPage && (
<div ref={ref} className="py-4">
{isFetchingNextPage && (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-16 w-16 rounded-md" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
</div>
</Layout>