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:
shakespeare.diy
2026-02-18 20:54:01 -06:00
parent fd2232f671
commit e7e4a1fa5c
5 changed files with 149 additions and 37 deletions
+9 -2
View File
@@ -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}
/>
))}
+9 -2
View File
@@ -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"
+2
View File
@@ -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;
}
+79
View File
@@ -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,
};
}
+50 -33
View File
@@ -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" />}