a2dbc169b2
Lightning (kind 9735) and on-chain (kind 8333) zaps now share a single "zap" group bucket in the notifications page and render with the same header, sats label, and Zap icon. The Zaps preference toggles both kinds together; native and push notification queries pick up 8333 automatically through the shared kind list.
471 lines
18 KiB
TypeScript
471 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
import { useCurrentUser } from './useCurrentUser';
|
|
import { useEncryptedSettings } from './useEncryptedSettings';
|
|
import { useFollowList } from './useFollowActions';
|
|
import { LETTER_KIND } from '@/lib/letterTypes';
|
|
import { ALL_NOTIFICATION_KINDS, getEnabledNotificationKinds } from '@/lib/notificationKinds';
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
export interface NotificationItem {
|
|
/** The notification event (kind 1, 6, 16, 7, 8, 9735, 8333, 9802, 1111, 1222, 1244, or 8211). */
|
|
event: NostrEvent;
|
|
/** The referenced event (the post that was liked/reposted/zapped/highlighted), if available. */
|
|
referencedEvent?: NostrEvent;
|
|
}
|
|
|
|
/**
|
|
* A group of notification events that all refer to the same post/content and
|
|
* have the same interaction type (e.g. 5 people liking the same note).
|
|
*
|
|
* When `actors` has more than one entry the UI should condense them into a
|
|
* single row rather than repeating the referenced post card for each actor.
|
|
*/
|
|
export interface GroupedNotificationItem {
|
|
/**
|
|
* Stable key for React lists. For multi-actor groups this is
|
|
* `<kind>:<referencedEventId>`. For standalone items it's the event ID.
|
|
*/
|
|
key: string;
|
|
/**
|
|
* The kind that describes this group.
|
|
* 7 = reaction, 6/16 = repost, 9735/8333 = zap, 9802 = highlight, 1 = mention, 1111 = comment.
|
|
*/
|
|
kind: number;
|
|
/** All notification events that belong to this group, newest-first. */
|
|
actors: NotificationItem[];
|
|
/**
|
|
* The post/content being acted upon (same for every actor in the group).
|
|
* Undefined for mentions and comments where the event IS the content.
|
|
*/
|
|
referencedEvent?: NostrEvent;
|
|
/**
|
|
* True if ANY actor event in this group is newer than the read cursor
|
|
* (i.e. at least one event is unread).
|
|
*/
|
|
isNew: boolean;
|
|
/** The timestamp of the newest actor event, used for ordering. */
|
|
newestAt: number;
|
|
}
|
|
|
|
interface NotificationPage {
|
|
items: NotificationItem[];
|
|
/** Oldest event timestamp in this page, used for cursor-based pagination. */
|
|
oldestTimestamp: number;
|
|
}
|
|
|
|
export interface NotificationData {
|
|
/** All notification items (paginated, flattened). */
|
|
items: NotificationItem[];
|
|
/** Grouped / condensed notification items for rendering. */
|
|
groupedItems: GroupedNotificationItem[];
|
|
/** IDs of notifications newer than cursor (unread). */
|
|
newNotificationIds: Set<string>;
|
|
/** Whether there are any unread notifications. */
|
|
hasUnread: boolean;
|
|
/** Mark all current notifications as read. */
|
|
markAsRead: () => Promise<void>;
|
|
/** Loading state for the first page. */
|
|
isLoading: boolean;
|
|
/** Whether the query has completed at least once. */
|
|
hasFetched: boolean;
|
|
/** Whether more pages are available. */
|
|
hasNextPage: boolean;
|
|
/** Whether a next page is currently being fetched. */
|
|
isFetchingNextPage: boolean;
|
|
/** Fetch the next page. */
|
|
fetchNextPage: () => void;
|
|
}
|
|
|
|
/** Get the referenced event ID from an event's tags. */
|
|
function getReferencedEventId(event: NostrEvent): string | undefined {
|
|
const eTag = event.tags.findLast(([name]) => name === 'e');
|
|
return eTag?.[1];
|
|
}
|
|
|
|
/**
|
|
* Returns a stable "group key" for a notification item.
|
|
* Events that share the same group key will be condensed into one row.
|
|
*
|
|
* Reactions, reposts, zaps, and highlights group by (kind-bucket, referencedEventId).
|
|
* Lightning (9735) and on-chain (8333) zaps share a single "zap" bucket.
|
|
* Mentions and comments each stand alone (group key == event id).
|
|
*/
|
|
function groupKey(item: NotificationItem): string {
|
|
const { event } = item;
|
|
const refId = item.referencedEvent?.id ?? getReferencedEventId(event);
|
|
|
|
if ((event.kind === 7 || event.kind === 6 || event.kind === 16 || event.kind === 9735 || event.kind === 8333 || event.kind === 9802) && refId) {
|
|
// Profile reactions (kind 7 on kind 0) are standalone — users can react
|
|
// to a profile multiple times, so each reaction gets its own notification.
|
|
const isProfileReaction = event.kind === 7 && (
|
|
item.referencedEvent?.kind === 0
|
|
|| event.tags.some(([name, value]) => name === 'k' && value === '0')
|
|
);
|
|
if (isProfileReaction) {
|
|
return event.id;
|
|
}
|
|
// Highlights with different excerpts of the same post are conceptually
|
|
// distinct events (different quotes). Include the highlight event id in
|
|
// the key so multiple excerpts from the same source don't collapse into
|
|
// a misleading "X people highlighted this" row.
|
|
if (event.kind === 9802) {
|
|
return `highlight:${refId}:${event.id}`;
|
|
}
|
|
// Use a canonical kind bucket so kind-6/16 reposts merge together, and
|
|
// lightning (9735) and on-chain (8333) zaps share a single "zap" group.
|
|
const bucket = event.kind === 6 || event.kind === 16
|
|
? 'repost'
|
|
: event.kind === 9735 || event.kind === 8333
|
|
? 'zap'
|
|
: String(event.kind);
|
|
return `${bucket}:${refId}`;
|
|
}
|
|
|
|
// Mentions (kind 1), comments (kind 1111), and letters (8211) are always standalone
|
|
return event.id;
|
|
}
|
|
|
|
/**
|
|
* Build condensed groups from a flat, newest-first list of notification items.
|
|
* Groups preserve the order of the first (newest) item that seeds each group.
|
|
*/
|
|
function buildGroups(
|
|
items: NotificationItem[],
|
|
newNotificationIds: Set<string>,
|
|
): GroupedNotificationItem[] {
|
|
const groupMap = new Map<string, GroupedNotificationItem>();
|
|
const groupOrder: string[] = [];
|
|
|
|
for (const item of items) {
|
|
const key = groupKey(item);
|
|
|
|
if (!groupMap.has(key)) {
|
|
groupOrder.push(key);
|
|
groupMap.set(key, {
|
|
key,
|
|
kind: item.event.kind,
|
|
actors: [],
|
|
referencedEvent: item.referencedEvent,
|
|
isNew: false,
|
|
newestAt: item.event.created_at,
|
|
});
|
|
}
|
|
|
|
const group = groupMap.get(key)!;
|
|
// Skip if this pubkey is already represented in the group
|
|
if (group.actors.some((a) => a.event.pubkey === item.event.pubkey)) continue;
|
|
group.actors.push(item);
|
|
|
|
if (newNotificationIds.has(item.event.id)) {
|
|
group.isNew = true;
|
|
}
|
|
|
|
// Keep the newest timestamp in sync
|
|
if (item.event.created_at > group.newestAt) {
|
|
group.newestAt = item.event.created_at;
|
|
}
|
|
|
|
// If the first actor already had the referenced event, prefer that; otherwise
|
|
// use whichever actor first provided a referencedEvent.
|
|
if (!group.referencedEvent && item.referencedEvent) {
|
|
group.referencedEvent = item.referencedEvent;
|
|
}
|
|
}
|
|
|
|
return groupOrder.map((k) => groupMap.get(k)!);
|
|
}
|
|
|
|
/**
|
|
* Hook to query notifications with infinite scroll pagination and track unread status.
|
|
* Per-type preferences and the "only from people I follow" filter are applied at
|
|
* query time (relay-level), not client-side.
|
|
*/
|
|
export function useNotifications(): NotificationData {
|
|
const { nostr } = useNostr();
|
|
const queryClient = useQueryClient();
|
|
const { user } = useCurrentUser();
|
|
const { settings, updateSettings } = useEncryptedSettings();
|
|
const { data: followData } = useFollowList();
|
|
|
|
const prefs = settings?.notificationPreferences;
|
|
|
|
// Derive kinds from per-type prefs — changes cause a new relay query
|
|
const enabledKinds = useMemo(() => getEnabledNotificationKinds(prefs), [prefs]);
|
|
const kindsKey = [...enabledKinds].sort().join(',');
|
|
|
|
// Authors filter: when onlyFollowing is set, restrict to followed pubkeys
|
|
const followedPubkeys = useMemo(
|
|
() => followData?.pubkeys ?? [],
|
|
[followData?.pubkeys],
|
|
);
|
|
const onlyFollowing = prefs?.onlyFollowing === true;
|
|
// Only gate on the follow list when it's actually loaded (non-empty or follow list fetch done)
|
|
const authorsFilter = onlyFollowing && followedPubkeys.length > 0
|
|
? followedPubkeys
|
|
: undefined;
|
|
const authorsKey = authorsFilter ? authorsFilter.slice().sort().join(',') : 'all';
|
|
|
|
const infiniteQuery = useInfiniteQuery<NotificationPage, Error>({
|
|
queryKey: ['notifications', user?.pubkey ?? '', kindsKey, authorsKey],
|
|
queryFn: async ({ pageParam, signal }) => {
|
|
if (!user) return { items: [], oldestTimestamp: Math.floor(Date.now() / 1000) };
|
|
|
|
const filter: Record<string, unknown> = {
|
|
kinds: enabledKinds,
|
|
'#p': [user.pubkey],
|
|
limit: PAGE_SIZE,
|
|
};
|
|
if (authorsFilter) {
|
|
filter.authors = authorsFilter;
|
|
}
|
|
if (pageParam) {
|
|
filter.until = pageParam;
|
|
}
|
|
|
|
const events = await nostr.query(
|
|
[filter as { kinds: number[]; '#p': string[]; limit: number; authors?: string[]; until?: number }],
|
|
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
|
);
|
|
|
|
const rawEvents = Array.isArray(events) ? events : [];
|
|
|
|
// Filter out own events and sort
|
|
const filtered = rawEvents
|
|
.filter((e) => e.pubkey !== user.pubkey)
|
|
.sort((a, b) => b.created_at - a.created_at);
|
|
|
|
// Track oldest timestamp from the raw query for pagination
|
|
const oldestTimestamp = filtered.length > 0
|
|
? Math.min(...filtered.map((e) => e.created_at))
|
|
: Math.floor(Date.now() / 1000);
|
|
|
|
// Collect referenced event IDs for batch fetching
|
|
const referencedIds: string[] = [];
|
|
for (const ev of filtered) {
|
|
// kind 1 (mention), voice messages (1222/1244), and letters (8211) ARE the notification content;
|
|
// highlights (9802) and kind 1111 (comment) ARE the content but we also fetch the parent for context.
|
|
if (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) {
|
|
const refId = getReferencedEventId(ev);
|
|
if (refId) referencedIds.push(refId);
|
|
}
|
|
}
|
|
|
|
// Batch-fetch referenced events in a single query
|
|
const referencedMap = new Map<string, NostrEvent>();
|
|
if (referencedIds.length > 0) {
|
|
// Check query cache first, only fetch IDs we don't have
|
|
const uncachedIds: string[] = [];
|
|
for (const id of referencedIds) {
|
|
const cached = queryClient.getQueryData<NostrEvent | null>(['event', id]);
|
|
if (cached) {
|
|
referencedMap.set(id, cached);
|
|
} else {
|
|
uncachedIds.push(id);
|
|
}
|
|
}
|
|
|
|
if (uncachedIds.length > 0) {
|
|
try {
|
|
const refEvents = await nostr.query(
|
|
[{ ids: uncachedIds, limit: uncachedIds.length }],
|
|
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
|
);
|
|
for (const refEv of refEvents) {
|
|
referencedMap.set(refEv.id, refEv);
|
|
// Seed the event cache so NoteCard sub-queries resolve instantly
|
|
if (!queryClient.getQueryData(['event', refEv.id])) {
|
|
queryClient.setQueryData(['event', refEv.id], refEv);
|
|
}
|
|
}
|
|
} catch {
|
|
// Timeout — referenced events will load individually via useEvent
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build notification items, filtering out reactions/reposts on posts the
|
|
// user didn't author (i.e. they were only tagged in them).
|
|
const items: NotificationItem[] = filtered.flatMap((ev) => {
|
|
const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) ? getReferencedEventId(ev) : undefined;
|
|
const referencedEvent = refId ? referencedMap.get(refId) : undefined;
|
|
|
|
// For reactions (7) and reposts (6, 16), only exclude if we know for
|
|
// certain the referenced post belongs to someone else. When the
|
|
// referenced event couldn't be fetched (timeout / missing from relay),
|
|
// keep the notification — it's better to show a notification with
|
|
// missing context than to silently drop it.
|
|
//
|
|
// Highlights (9802) follow the same rule: only notify when the
|
|
// highlighted source was authored by the current user.
|
|
if (ev.kind === 7 || ev.kind === 6 || ev.kind === 16 || ev.kind === 9802) {
|
|
if (referencedEvent && referencedEvent.pubkey !== user.pubkey) return [];
|
|
}
|
|
|
|
// Zaps (9735 lightning, 8333 on-chain) don't need the author-ownership
|
|
// check at all: the query already filters by `#p: [user.pubkey]`, which
|
|
// means the zap event explicitly names the current user as the recipient.
|
|
// Profile-level zaps (no `e` tag) are also valid notifications.
|
|
|
|
return [{ event: ev, referencedEvent }];
|
|
});
|
|
|
|
return { items, oldestTimestamp };
|
|
},
|
|
getNextPageParam: (lastPage) => {
|
|
if (!lastPage || lastPage.items.length === 0) return undefined;
|
|
return lastPage.oldestTimestamp - 1;
|
|
},
|
|
initialPageParam: undefined as number | undefined,
|
|
enabled: !!user,
|
|
staleTime: 30_000,
|
|
refetchInterval: Capacitor.isNativePlatform() ? false : 60_000,
|
|
});
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetched,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
fetchNextPage,
|
|
} = infiniteQuery;
|
|
|
|
// Real-time subscription: open a persistent REQ with `since: now` so new
|
|
// notifications are detected immediately instead of waiting for the next poll.
|
|
// When any new event arrives, invalidate the query cache to trigger a refetch.
|
|
useEffect(() => {
|
|
if (!user || Capacitor.isNativePlatform()) return;
|
|
|
|
const ac = new AbortController();
|
|
const since = Math.floor(Date.now() / 1000);
|
|
|
|
(async () => {
|
|
try {
|
|
for await (const msg of nostr.req(
|
|
[{
|
|
kinds: [...ALL_NOTIFICATION_KINDS],
|
|
'#p': [user.pubkey],
|
|
since,
|
|
}],
|
|
{ signal: ac.signal },
|
|
)) {
|
|
if (msg[0] === 'EVENT') {
|
|
const ev = msg[2];
|
|
// Ignore own events
|
|
if (ev.pubkey === user.pubkey) continue;
|
|
// New notification arrived — invalidate both the full list and the
|
|
// unread indicator so the UI updates immediately.
|
|
queryClient.invalidateQueries({ queryKey: ['notifications', user.pubkey] });
|
|
queryClient.invalidateQueries({ queryKey: ['notifications-unread', user.pubkey] });
|
|
}
|
|
}
|
|
} catch {
|
|
// AbortError on cleanup — expected
|
|
}
|
|
})();
|
|
|
|
return () => ac.abort();
|
|
}, [nostr, user, queryClient]);
|
|
|
|
// Flatten and deduplicate across pages
|
|
const items = useMemo(() => {
|
|
if (!data?.pages) return [];
|
|
const seen = new Set<string>();
|
|
return data.pages.flatMap((page) => page.items).filter((item) => {
|
|
if (seen.has(item.event.id)) return false;
|
|
seen.add(item.event.id);
|
|
return true;
|
|
});
|
|
}, [data?.pages]);
|
|
|
|
// Only use cursor if settings have actually loaded, otherwise null
|
|
const remoteCursor = settings !== undefined && settings !== null
|
|
? (settings.notificationsCursor ?? 0)
|
|
: null;
|
|
|
|
// Optimistic local cursor — updated immediately on markAsRead so that
|
|
// newNotificationIds collapses to empty before the query cache catches up,
|
|
// preventing the NotificationsPage effect from re-triggering markAsRead.
|
|
const optimisticCursor = useRef<number | null>(null);
|
|
const notificationsCursor = optimisticCursor.current !== null
|
|
? Math.max(optimisticCursor.current, remoteCursor ?? 0)
|
|
: remoteCursor;
|
|
|
|
// Build set of unread notification IDs
|
|
const newNotificationIds = useMemo(() => {
|
|
if (notificationsCursor === null) return new Set<string>();
|
|
return new Set(
|
|
items
|
|
.filter((item) => item.event.created_at > notificationsCursor)
|
|
.map((item) => item.event.id),
|
|
);
|
|
}, [items, notificationsCursor]);
|
|
|
|
const hasUnread = notificationsCursor !== null && newNotificationIds.size > 0;
|
|
|
|
// Build grouped items for condensed display
|
|
const groupedItems = useMemo(
|
|
() => buildGroups(items, newNotificationIds),
|
|
[items, newNotificationIds],
|
|
);
|
|
|
|
// Mark all current notifications as read by updating the cursor
|
|
const markAsRead = useCallback(async () => {
|
|
if (!user || items.length === 0 || notificationsCursor === null) return;
|
|
|
|
const newestTimestamp = Math.max(...items.map((item) => item.event.created_at));
|
|
|
|
if (newestTimestamp <= notificationsCursor) return;
|
|
|
|
// Update optimistic cursor immediately so unread state clears before
|
|
// the query cache updates, preventing re-trigger loops.
|
|
optimisticCursor.current = newestTimestamp;
|
|
|
|
try {
|
|
await updateSettings.mutateAsync({
|
|
notificationsCursor: newestTimestamp,
|
|
});
|
|
// Immediately clear the nav dot — use setQueriesData with a partial key
|
|
// match because useHasUnreadNotifications uses a 4-element key
|
|
// ['notifications-unread', pubkey, kindsKey, authorsKey] and setQueryData
|
|
// requires an exact match (which silently misses the real cache entry).
|
|
//
|
|
// NOTE: We intentionally do NOT call invalidateQueries here. Invalidation
|
|
// triggers an immediate refetch whose queryFn closure may still hold the
|
|
// old notificationsCursor (from a render before the settings cache update
|
|
// propagates). That stale refetch re-queries the relay with the old
|
|
// `since` value, finds the same "unread" events, returns `true`, and
|
|
// overwrites the `false` we just set — causing the dot to reappear.
|
|
// The 60-second poll (or real-time subscription) will naturally
|
|
// re-evaluate once the cursor has fully propagated.
|
|
queryClient.setQueriesData<boolean>(
|
|
{ queryKey: ['notifications-unread', user.pubkey] },
|
|
false,
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to mark notifications as read:', error);
|
|
optimisticCursor.current = null;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.pubkey, items.length, notificationsCursor]);
|
|
|
|
return {
|
|
items,
|
|
groupedItems,
|
|
newNotificationIds,
|
|
hasUnread,
|
|
markAsRead,
|
|
isLoading,
|
|
hasFetched: isFetched,
|
|
hasNextPage: hasNextPage ?? false,
|
|
isFetchingNextPage,
|
|
fetchNextPage,
|
|
};
|
|
}
|