Remove kind 3 follow notifications to eliminate false new-follower alerts

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 09:44:16 +00:00
committed by GitHub
parent 1c86b40549
commit 046e6eec32
3 changed files with 110 additions and 8 deletions

View File

@@ -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<NotificationKind, typeof Bell> = {
reply: MessageCircle,
reaction: Heart,
repost: Repeat2,
follow: UserPlus,
zap: Zap,
};
@@ -39,7 +38,6 @@ const LABELS: Record<NotificationKind, string> = {
reply: 'replied to you',
reaction: 'reacted to your note',
repost: 'reposted your note',
follow: 'followed you',
zap: 'sent you a zap',
};

View File

@@ -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');
});
});

View File

@@ -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<string>();
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;