From eb05c6e9d7d1ec9dc29298b213c7c36a1b3e3f43 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:56:14 +0200 Subject: [PATCH] Remove kind 3 follow notifications to eliminate false new-follower alerts (#49) * Initial plan * Remove kind 3 follow notifications to eliminate false new-follower alerts Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/components/nostr/NotificationsCenter.tsx | 4 +- src/hooks/useNotifications.test.tsx | 94 ++++++++++++++++++++ src/hooks/useNotifications.ts | 20 +++-- 3 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useNotifications.test.tsx diff --git a/src/components/nostr/NotificationsCenter.tsx b/src/components/nostr/NotificationsCenter.tsx index 0503438..1b64443 100644 --- a/src/components/nostr/NotificationsCenter.tsx +++ b/src/components/nostr/NotificationsCenter.tsx @@ -1,4 +1,4 @@ -import { Bell, BellRing, Heart, MessageCircle, Repeat2, UserPlus, Zap } from 'lucide-react'; +import { Bell, BellRing, Heart, MessageCircle, Repeat2, Zap } from 'lucide-react'; import { useState, type ComponentProps } from 'react'; import { Button } from '@/components/ui/button'; import { @@ -30,7 +30,6 @@ const ICONS: Record = { reply: MessageCircle, reaction: Heart, repost: Repeat2, - follow: UserPlus, zap: Zap, }; @@ -39,7 +38,6 @@ const LABELS: Record = { reply: 'replied to you', reaction: 'reacted to your note', repost: 'reposted your note', - follow: 'followed you', zap: 'sent you a zap', }; diff --git a/src/hooks/useNotifications.test.tsx b/src/hooks/useNotifications.test.tsx new file mode 100644 index 0000000..374c74b --- /dev/null +++ b/src/hooks/useNotifications.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; + +import { TestApp } from '@/test/TestApp'; +import { useLoginActions } from './useLoginActions'; +import { useNotifications } from './useNotifications'; + +const alice = getPublicKey(generateSecretKey()); +const carol = getPublicKey(generateSecretKey()); + +function contactListEvent(author: string, follows: string[], createdAt: number, id: string): NostrEvent { + return { + id, + pubkey: author, + created_at: createdAt, + kind: 3, + tags: follows.map((pubkey) => ['p', pubkey]), + content: '', + sig: '', + }; +} + +beforeEach(() => { + // NostrLoginProvider persists logins to localStorage; start each test logged out. + window.localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * Renders the notifications hook and logs in a fresh user while `nostr.query` + * is mocked. The mock must be installed before login, because logging in + * immediately triggers the notification query. `events` receives the new + * user's pubkey so tests can build events that tag them. + */ +async function renderLoggedInNotifications(events: (pubkey: string) => NostrEvent[]) { + const nsec = nip19.nsecEncode(generateSecretKey()); + const pubkey = getPublicKey(nip19.decode(nsec).data as Uint8Array); + + const { result } = renderHook( + () => ({ actions: useLoginActions(), nostr: useNostr(), notifications: useNotifications() }), + { wrapper: TestApp }, + ); + + // NostrLoginProvider renders null while it reads logins from storage. + await waitFor(() => expect(result.current).not.toBeNull()); + + const query = vi.spyOn(result.current.nostr.nostr, 'query').mockImplementation(async () => events(pubkey)); + + act(() => result.current.actions.nsec(nsec)); + await waitFor(() => expect(result.current.notifications.isSuccess).toBe(true)); + + return { result, query, pubkey }; +} + +describe('useNotifications', () => { + it('does not show a follow notification when a replacement contact list keeps the recipient', async () => { + // The exact issue reproduction: the user's pubkey stays in Alice's list + // across a replacement that only changes an unrelated contact. + const { result, query } = await renderLoggedInNotifications((pubkey) => [ + contactListEvent(alice, [pubkey], 100, 'list-1'), + contactListEvent(alice, [pubkey, carol], 200, 'list-2'), + ]); + + const filters = query.mock.calls.flatMap(([f]) => f); + expect(filters.length).toBeGreaterThan(0); + for (const filter of filters) { + expect(filter.kinds).not.toContain(3); + } + expect(result.current.notifications.data).toEqual([]); + }); + + it('keeps mention, reply, reaction, repost and zap notifications', async () => { + const { result } = await renderLoggedInNotifications((pubkey) => [ + { id: 'mention', pubkey: alice, created_at: 300, kind: 1, tags: [['p', pubkey]], content: 'hi', sig: '' }, + { id: 'reply', pubkey: alice, created_at: 400, kind: 1, tags: [['e', 'root-id', '', 'root'], ['p', pubkey]], content: 're', sig: '' }, + { id: 'reaction', pubkey: carol, created_at: 500, kind: 7, tags: [['p', pubkey], ['e', 'note-id']], content: '+', sig: '' }, + { id: 'repost', pubkey: carol, created_at: 600, kind: 6, tags: [['p', pubkey], ['e', 'note-id']], content: '', sig: '' }, + { id: 'zap', pubkey: carol, created_at: 700, kind: 9735, tags: [['p', pubkey]], content: '', sig: '' }, + ]); + + const byId = new Map((result.current.notifications.data ?? []).map((n) => [n.event.id, n.kind])); + expect(byId.get('mention')).toBe('mention'); + expect(byId.get('reply')).toBe('reply'); + expect(byId.get('reaction')).toBe('reaction'); + expect(byId.get('repost')).toBe('repost'); + expect(byId.get('zap')).toBe('zap'); + }); +}); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 407208d..b9ee2a2 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -5,10 +5,18 @@ import { isReply } from '@/lib/nostrUtils'; import { useCurrentUser } from './useCurrentUser'; import { useLocalStorage } from './useLocalStorage'; -/** Nostr event kinds that commonly represent activity directed at a person. */ -const NOTIFICATION_KINDS = [1, 3, 6, 7, 16, 9735] as const; +/** + * Nostr event kinds that commonly represent activity directed at a person. + * + * Kind 3 is deliberately excluded: a contact list is a replaceable whole-list + * snapshot, so any later edit that keeps the recipient's `p` tag would resurface + * as a false "followed you" notification. Relays usually only retain the latest + * version of a replaceable event, so there is no reliable per-author delta to + * tell a genuine new follow apart from an unrelated list edit. + */ +const NOTIFICATION_KINDS = [1, 6, 7, 16, 9735] as const; -export type NotificationKind = 'mention' | 'reply' | 'reaction' | 'repost' | 'follow' | 'zap'; +export type NotificationKind = 'mention' | 'reply' | 'reaction' | 'repost' | 'zap'; export interface Notification { event: NostrEvent; @@ -17,8 +25,6 @@ export interface Notification { function notificationKind(event: NostrEvent): NotificationKind { switch (event.kind) { - case 3: - return 'follow'; case 7: return 'reaction'; case 6: @@ -60,6 +66,10 @@ export function useNotifications() { const seen = new Set(); return events .filter((event) => { + // Kind 3 is no longer queried, but guard anyway: a stale cache entry + // or a misbehaving relay must not classify a contact-list snapshot + // as a notification. + if (event.kind === 3) return false; if (event.pubkey === user.pubkey || seen.has(event.id)) return false; seen.add(event.id); return true;