Add encrypted notification tracking with visual indicators
- Add notificationsCursor to EncryptedSettings interface - Create useNotifications hook to track read/unread status - Update NotificationsPage to mark as read after 1 second - Highlight new notifications with accent background and left border - Add dot indicator to Bell icon in sidebar and mobile nav when unread - Auto-mark notifications as read when viewing the page - Sync notification cursor across devices via NIP-78 Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
@@ -15,6 +15,7 @@ import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -25,19 +26,23 @@ interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
showIndicator?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ to, icon, label, active }: NavItemProps) {
|
||||
function NavItem({ to, icon, label, active, showIndicator }: NavItemProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
'flex items-center gap-4 px-4 py-3 rounded-full transition-colors text-lg hover:bg-secondary/60',
|
||||
'flex items-center gap-4 px-4 py-3 rounded-full transition-colors text-lg hover:bg-secondary/60 relative',
|
||||
active ? 'font-bold' : 'font-normal text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{showIndicator && (
|
||||
<span className="absolute right-4 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +55,7 @@ export function LeftSidebar() {
|
||||
const { logout } = useLoginActions();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const { hasUnread } = useNotifications();
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
@@ -138,6 +144,7 @@ export function LeftSidebar() {
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
active={location.pathname === item.to}
|
||||
showIndicator={item.to === '/notifications' && hasUnread}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Bell, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
interface NavTabProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
showIndicator?: boolean;
|
||||
}
|
||||
|
||||
function NavTab({ to, icon, label, active }: NavTabProps) {
|
||||
function NavTab({ to, icon, label, active, showIndicator }: NavTabProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors relative',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
{showIndicator && (
|
||||
<span className="absolute top-1.5 left-1/2 translate-x-1 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const { hasUnread } = useNotifications();
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-20 flex items-center bg-background/95 backdrop-blur-md border-t border-border sidebar:hidden safe-area-bottom">
|
||||
@@ -40,6 +46,7 @@ export function MobileBottomNav() {
|
||||
icon={<Bell className="size-5" />}
|
||||
label="Notifications"
|
||||
active={location.pathname === '/notifications'}
|
||||
showIndicator={hasUnread}
|
||||
/>
|
||||
<NavTab
|
||||
to="/search"
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface EncryptedSettings {
|
||||
feedSettings?: FeedSettings;
|
||||
/** Advanced content filters */
|
||||
contentFilters?: ContentFilter[];
|
||||
/** Timestamp of last viewed notification (Unix timestamp in seconds) */
|
||||
notificationsCursor?: number;
|
||||
/** Last sync timestamp */
|
||||
lastSync?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useEncryptedSettings } from './useEncryptedSettings';
|
||||
|
||||
export interface NotificationData {
|
||||
/** All notifications */
|
||||
notifications: NostrEvent[];
|
||||
/** Notifications newer than cursor (unread) */
|
||||
newNotifications: NostrEvent[];
|
||||
/** Whether there are any unread notifications */
|
||||
hasUnread: boolean;
|
||||
/** Mark all current notifications as read */
|
||||
markAsRead: () => Promise<void>;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query notifications and track unread status
|
||||
* Uses encrypted settings to persist the last-viewed timestamp
|
||||
*/
|
||||
export function useNotifications(): NotificationData {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { settings, updateSettings } = useEncryptedSettings();
|
||||
|
||||
const { data: notifications = [], isLoading } = useQuery<NostrEvent[]>({
|
||||
queryKey: ['notifications', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user) return [];
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1, 6, 7, 9735], '#p': [user.pubkey], limit: 50 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
return events
|
||||
.filter((e) => e.pubkey !== user.pubkey)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !!user,
|
||||
refetchInterval: 60_000, // Refetch every minute for new notifications
|
||||
});
|
||||
|
||||
// Get cursor from encrypted settings (defaults to 0 if not set)
|
||||
const notificationsCursor = settings?.notificationsCursor ?? 0;
|
||||
|
||||
// Determine which notifications are new (created after cursor)
|
||||
const newNotifications = notifications.filter(
|
||||
(event) => event.created_at > notificationsCursor
|
||||
);
|
||||
|
||||
const hasUnread = newNotifications.length > 0;
|
||||
|
||||
// Mark all current notifications as read by updating the cursor
|
||||
const markAsRead = async () => {
|
||||
if (!user || notifications.length === 0) return;
|
||||
|
||||
// Set cursor to the timestamp of the newest notification
|
||||
const newestTimestamp = Math.max(...notifications.map((e) => e.created_at));
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
notificationsCursor: newestTimestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
newNotifications,
|
||||
hasUnread,
|
||||
markAsRead,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Repeat2, Zap, AtSign, MessageCircle, MoreHorizontal } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -21,6 +19,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
@@ -36,31 +35,33 @@ export function NotificationsPage() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<NotificationTab>('all');
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { notifications, newNotifications, isLoading, markAsRead } = useNotifications();
|
||||
|
||||
const { data: notifications, isLoading } = useQuery<NostrEvent[]>({
|
||||
queryKey: ['notifications', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user) return [];
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1, 6, 7, 9735], '#p': [user.pubkey], limit: 50 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
return events
|
||||
.filter((e) => e.pubkey !== user.pubkey)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
// Mark notifications as read when user visits the page
|
||||
useEffect(() => {
|
||||
if (!user || notifications.length === 0) return;
|
||||
|
||||
// Mark as read after a short delay to ensure user actually sees them
|
||||
const timer = setTimeout(() => {
|
||||
markAsRead();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [user, notifications.length, markAsRead]);
|
||||
|
||||
const filteredNotifications = useMemo(() => {
|
||||
if (!notifications) return [];
|
||||
if (activeTab === 'mentions') {
|
||||
return notifications.filter((e) => e.kind === 1);
|
||||
}
|
||||
return notifications;
|
||||
}, [notifications, activeTab]);
|
||||
|
||||
// Create a set of new notification IDs for quick lookup
|
||||
const newNotificationIds = useMemo(
|
||||
() => new Set(newNotifications.map((e) => e.id)),
|
||||
[newNotifications]
|
||||
);
|
||||
|
||||
const tabs: { key: NotificationTab; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'mentions', label: 'Mentions' },
|
||||
@@ -102,7 +103,11 @@ export function NotificationsPage() {
|
||||
) : filteredNotifications.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredNotifications.map((event) => (
|
||||
<NotificationItem key={event.id} event={event} />
|
||||
<NotificationItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
isNew={newNotificationIds.has(event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -116,16 +121,16 @@ export function NotificationsPage() {
|
||||
}
|
||||
|
||||
/** Determines the type of notification and renders accordingly. */
|
||||
function NotificationItem({ event }: { event: NostrEvent }) {
|
||||
function NotificationItem({ event, isNew }: { event: NostrEvent; isNew: boolean }) {
|
||||
switch (event.kind) {
|
||||
case 7:
|
||||
return <LikeNotification event={event} />;
|
||||
return <LikeNotification event={event} isNew={isNew} />;
|
||||
case 6:
|
||||
return <RepostNotification event={event} />;
|
||||
return <RepostNotification event={event} isNew={isNew} />;
|
||||
case 9735:
|
||||
return <ZapNotification event={event} />;
|
||||
return <ZapNotification event={event} isNew={isNew} />;
|
||||
case 1:
|
||||
return <MentionNotification event={event} />;
|
||||
return <MentionNotification event={event} isNew={isNew} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -155,7 +160,7 @@ function formatSats(sats: number): string {
|
||||
// Like Notification: "{emoji} {name} reacted to your post"
|
||||
// Shows the original post being reacted to
|
||||
// ──────────────────────────────────────
|
||||
function LikeNotification({ event }: { event: NostrEvent }) {
|
||||
function LikeNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) {
|
||||
const referencedEventId = getReferencedEventId(event);
|
||||
const { data: referencedEvent } = useEvent(referencedEventId);
|
||||
|
||||
@@ -165,7 +170,10 @@ function LikeNotification({ event }: { event: NostrEvent }) {
|
||||
const emoji = content === '+' || content === '' ? '👍' : content;
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className={cn('px-4 pt-3 pb-1 relative', isNew && 'bg-primary/5')}>
|
||||
{isNew && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
|
||||
)}
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
icon={<span className="text-base leading-none size-4 flex items-center justify-center">{emoji}</span>}
|
||||
@@ -182,12 +190,15 @@ function LikeNotification({ event }: { event: NostrEvent }) {
|
||||
// Repost Notification: "🔁 {name} reposted your note"
|
||||
// Shows the original post being reposted
|
||||
// ──────────────────────────────────────
|
||||
function RepostNotification({ event }: { event: NostrEvent }) {
|
||||
function RepostNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) {
|
||||
const referencedEventId = getReferencedEventId(event);
|
||||
const { data: referencedEvent } = useEvent(referencedEventId);
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className={cn('px-4 pt-3 pb-1 relative', isNew && 'bg-primary/5')}>
|
||||
{isNew && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
|
||||
)}
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
icon={<Repeat2 className="size-4 text-green-500" />}
|
||||
@@ -204,7 +215,7 @@ function RepostNotification({ event }: { event: NostrEvent }) {
|
||||
// Zap Notification: "⚡ {name} zapped you"
|
||||
// Shows the original post being zapped
|
||||
// ──────────────────────────────────────
|
||||
function ZapNotification({ event }: { event: NostrEvent }) {
|
||||
function ZapNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) {
|
||||
const referencedEventId = getReferencedEventId(event);
|
||||
const { data: referencedEvent } = useEvent(referencedEventId);
|
||||
|
||||
@@ -246,7 +257,10 @@ function ZapNotification({ event }: { event: NostrEvent }) {
|
||||
const amountLabel = zapAmount > 0 ? ` ${formatSats(zapAmount)} sats` : '';
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className={cn('px-4 pt-3 pb-1 relative', isNew && 'bg-primary/5')}>
|
||||
{isNew && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
|
||||
)}
|
||||
<NotificationHeader
|
||||
actorPubkey={senderPubkey}
|
||||
icon={<Zap className="size-4 text-amber-500 fill-amber-500" />}
|
||||
@@ -263,9 +277,12 @@ function ZapNotification({ event }: { event: NostrEvent }) {
|
||||
// Mention Notification: "@ {name} mentioned you"
|
||||
// Shows the full mention post with action buttons
|
||||
// ──────────────────────────────────────
|
||||
function MentionNotification({ event }: { event: NostrEvent }) {
|
||||
function MentionNotification({ event, isNew }: { event: NostrEvent; isNew: boolean }) {
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className={cn('px-4 pt-3 pb-1 relative', isNew && 'bg-primary/5')}>
|
||||
{isNew && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
|
||||
)}
|
||||
<NotificationHeader
|
||||
actorPubkey={event.pubkey}
|
||||
icon={<AtSign className="size-4 text-primary" />}
|
||||
|
||||
Reference in New Issue
Block a user