mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 01:31:13 +02:00
Add notifications feature: implement useNotifications hook and NotificationCard component; update Notifications page for displaying user notifications
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
164
src/components/notifications/NotificationCard.tsx
Normal file
164
src/components/notifications/NotificationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/hooks/useNotifications.ts
Normal file
116
src/hooks/useNotifications.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user