Skip to content

Commit e9a8c5c

Browse files
committed
global feed fixes
1 parent 6d2025f commit e9a8c5c

5 files changed

Lines changed: 111 additions & 19 deletions

File tree

src/lib/nostr/client.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ describe('nostr client helpers', () => {
131131
]);
132132
});
133133

134+
it('keeps trusted global authors through spam filtering unless muted', () => {
135+
const trusted = 'a'.repeat(64);
136+
const muted = new Set<string>();
137+
const trustedSet = new Set([trusted]);
138+
const noisy = event({ id: '1'.repeat(64), pubkey: trusted, content: `${'long '.repeat(300)} #adult` });
139+
const regularNoisy = event({ id: '2'.repeat(64), pubkey: 'b'.repeat(64), content: `${'long '.repeat(300)} #adult` });
140+
141+
expect(filterSpam([noisy, regularNoisy], muted, trustedSet).map((item) => item.id)).toEqual([noisy.id]);
142+
expect(filterSpam([noisy], new Set([trusted]), trustedSet)).toEqual([]);
143+
});
144+
134145
it('returns an empty follow or custom feed when there are no follows', async () => {
135146
await expect(fetchFeed('follow', [], [])).resolves.toEqual([]);
136147
await expect(fetchFeed('custom', [], [])).resolves.toEqual([]);
@@ -327,11 +338,20 @@ describe('nostr client helpers', () => {
327338
const root = event({ content: 'top-level note' });
328339
const markedReply = event({ content: 'reply', tags: [['e', root.id, 'wss://relay.example', 'reply', root.pubkey]] });
329340
const legacyReply = event({ content: 'legacy reply', tags: [['e', root.id]] });
341+
const quoted = event({ content: 'quoted note', tags: [['e', root.id], ['q', root.id]] });
342+
const mentioned = event({ content: 'mentioned note', tags: [['e', root.id, 'wss://relay.example', 'mention']] });
343+
const embedded = event({
344+
content: 'nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j',
345+
tags: [['e', root.id]]
346+
});
330347

331348
expect(isReplyEvent(root)).toBe(false);
332349
expect(isReplyEvent(markedReply)).toBe(true);
333350
expect(isReplyEvent(legacyReply)).toBe(true);
334-
expect(topLevelFeedEvents([root, markedReply, legacyReply])).toEqual([root]);
351+
expect(isReplyEvent(quoted)).toBe(false);
352+
expect(isReplyEvent(mentioned)).toBe(false);
353+
expect(isReplyEvent(embedded)).toBe(false);
354+
expect(topLevelFeedEvents([root, markedReply, legacyReply, quoted, mentioned, embedded])).toEqual([root, quoted, mentioned, embedded]);
335355
});
336356

337357
it('hides events only when deletion requests are signed by the original author', () => {

src/lib/nostr/client.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,10 @@ function pomegranateEmailFromToken(token: string) {
364364
}
365365
}
366366

367-
export function filterSpam(events: NostrEvent[], mutedPubkeys = new Set<string>()) {
367+
export function filterSpam(events: NostrEvent[], mutedPubkeys = new Set<string>(), trustedPubkeys = new Set<string>()) {
368368
return events.filter((event) => {
369369
if (mutedPubkeys.has(event.pubkey)) return false;
370+
if (trustedPubkeys.has(event.pubkey)) return true;
370371
if (event.kind === 6) return Boolean(parseRepostContent(event));
371372
if (event.kind === 1 && event.content.length > maxFeedPostContentLength) return false;
372373
if (isMachineGeneratedContent(event.content)) return false;
@@ -384,7 +385,11 @@ export function isReplyEvent(event: NostrEvent) {
384385
const eventTags = event.tags.filter((tag) => tag[0] === 'e' && tag[1]);
385386
if (!eventTags.length) return false;
386387

387-
return eventTags.some((tag) => tag[3] === 'root' || tag[3] === 'reply') || eventTags.length > 0;
388+
if (eventTags.some((tag) => tag[3] === 'root' || tag[3] === 'reply')) return true;
389+
if (eventTags.some((tag) => tag[3] === 'mention')) return false;
390+
if (event.tags.some((tag) => tag[0] === 'q' && tag[1])) return false;
391+
if (/\bnostr:(?:note|nevent)1[023456789acdefghjklmnpqrstuvwxyz]+/i.test(event.content)) return false;
392+
return true;
388393
}
389394

390395
export function isFamilySafeEvent(event: NostrEvent) {
@@ -609,16 +614,30 @@ export async function fetchFeed(
609614
if (options.until) base.until = options.until;
610615
const filters = await feedFiltersForMode(mode, base, follows, settings, since, relayUrls, options);
611616

617+
const trustedGlobalAuthors = mode === 'global' ? new Set(options.globalAuthors?.filter((pubkey) => /^[0-9a-f]{64}$/i.test(pubkey)) ?? []) : new Set<string>();
612618
const events = verifiedRelayEvents((await Promise.all(filters.map((filter) => queryShortLived(relayUrls, filter, 5000, { minEvents: feedIdleMinEvents(mode, filter.limit), idleAfterMs: 650 })))).flat()).filter((event) =>
613619
filters.some((filter) => eventMatchesTimeWindow(event, filter.since, filter.until))
614620
);
615-
const clean = dedupeEvents(topLevelFeedEvents(filterSpam(events)));
616-
const feedClean = mode === 'global' ? clean.filter((event) => !eventHasImgurUrl(event)) : clean;
617-
const output = mode === 'global' ? limitCryptoTopicDensity(limitConsecutiveAuthors(feedClean, 2), 10) : feedClean;
621+
const clean = dedupeEvents(topLevelFeedEvents(filterSpam(events, new Set<string>(), trustedGlobalAuthors)));
622+
const feedClean = mode === 'global' ? clean.filter((event) => trustedGlobalAuthors.has(event.pubkey) || !eventHasImgurUrl(event)) : clean;
623+
const output = mode === 'global' ? applyGlobalFeedLimits(feedClean, trustedGlobalAuthors) : feedClean;
618624
await cacheEvents(output);
619625
return output;
620626
}
621627

628+
function applyGlobalFeedLimits(events: NostrEvent[], trustedAuthors = new Set<string>()) {
629+
const trusted = events.filter((event) => trustedAuthors.has(event.pubkey));
630+
const limited = limitCryptoTopicDensity(
631+
limitConsecutiveAuthors(
632+
events.filter((event) => !trustedAuthors.has(event.pubkey)),
633+
2
634+
),
635+
10
636+
);
637+
if (!trusted.length) return limited;
638+
return dedupeEvents([...trusted, ...limited]);
639+
}
640+
622641
function feedIdleMinEvents(mode: FeedMode, limit = 24) {
623642
if (mode === 'follow' || mode === 'custom') return Math.min(limit, 8);
624643
return Math.min(limit, 24);
@@ -684,7 +703,8 @@ export async function subscribeFeed(
684703
return pool.subscribeMap(requests, {
685704
label: 'main-feed-live',
686705
onevent(event) {
687-
const [clean] = topLevelFeedEvents(filterSpam(verifiedRelayEvents([event as NostrEvent])));
706+
const trustedGlobalAuthors = mode === 'global' ? new Set(options.globalAuthors?.filter((pubkey) => /^[0-9a-f]{64}$/i.test(pubkey)) ?? []) : new Set<string>();
707+
const [clean] = topLevelFeedEvents(filterSpam(verifiedRelayEvents([event as NostrEvent]), new Set<string>(), trustedGlobalAuthors));
688708
if (clean) {
689709
void cacheEvents([clean]);
690710
onEvent(clean);

src/lib/nostr/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export const globalFeedHashtags = ['technology', 'food', 'foodstr', 'music', 'mu
2323

2424
export const globalFeedCuratorPubkey = 'c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1';
2525

26+
export const defaultGlobalFeedAuthors = [
27+
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
28+
'e83b66a8ed2d37c07d1abea6e1b000a15549c69508fa4c5875556d52b0526c2b',
29+
'460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
30+
'76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa',
31+
'683211bd155c7b764e4b99ba263a151d81209be7a566a2bb1971dc1bbd3b715e',
32+
'c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1'
33+
];
34+
2635
export const socialInterests = [
2736
'art',
2837
'photography',

src/lib/stores/app.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ describe('app store helpers', () => {
4040
expect(mergeEvents(items, []).map((item) => item.id)).toEqual(['a1', 'a2', 'b1', 'a3', 'a4']);
4141
});
4242

43+
it('does not de-flood explicit global authors from the global feed', () => {
44+
const globalAuthor = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
45+
const items = [
46+
authorEvent('trusted-1', 60, globalAuthor),
47+
authorEvent('trusted-2', 50, globalAuthor),
48+
authorEvent('trusted-3', 40, globalAuthor),
49+
authorEvent('trusted-4', 30, globalAuthor)
50+
];
51+
52+
expect(mergeEvents(items, []).map((item) => item.id)).toEqual(['trusted-1', 'trusted-2', 'trusted-3', 'trusted-4']);
53+
});
54+
4355
it('caps the in-memory feed after merging', () => {
4456
const authors = ['a'.repeat(64), 'b'.repeat(64), 'c'.repeat(64)];
4557
const items = Array.from({ length: 605 }, (_, index) => authorEvent(`event-${index}`, index, authors[index % authors.length]));
@@ -50,14 +62,16 @@ describe('app store helpers', () => {
5062
expect(merged.at(-1)?.id).toBe('event-5');
5163
});
5264

53-
it('limits global display to the default feed hashtags', () => {
65+
it('limits global display to the default feed hashtags and explicit global authors', () => {
66+
const globalAuthor = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
5467
const items = [
5568
{ ...authorEvent('technology', 30, 'a'.repeat(64)), tags: [['t', 'technology']] },
5669
{ ...authorEvent('food-content', 20, 'b'.repeat(64)), content: 'made lunch #foodstr' },
70+
authorEvent('global-author', 15, globalAuthor),
5771
{ ...authorEvent('other', 10, 'c'.repeat(64)), tags: [['t', 'nostr']] }
5872
];
5973

60-
expect(displayEventsForFeedMode('global', items).map((item) => item.id)).toEqual(['technology', 'food-content']);
74+
expect(displayEventsForFeedMode('global', items).map((item) => item.id)).toEqual(['technology', 'food-content', 'global-author']);
6175
});
6276

6377
it('allows direct follows, friends of friends, hashtags, and keyword matches in custom display', () => {

src/lib/stores/app.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
getCachedProfileEvents,
1313
getCachedProfiles
1414
} from '$lib/nostr/cache';
15-
import { defaultCustomFeedSettings, defaultGuestNip05, defaultRelays, globalFeedCuratorPubkey, globalFeedHashtags, keywordsForInterests } from '$lib/nostr/config';
15+
import { defaultCustomFeedSettings, defaultGlobalFeedAuthors, defaultGuestNip05, defaultRelays, globalFeedCuratorPubkey, globalFeedHashtags, keywordsForInterests } from '$lib/nostr/config';
1616
import { appPath } from '$lib/paths';
1717
import { markRelaysOffline, markRelaysOnline, syncRelayStatus } from '$lib/stores/relayStatus';
1818
import { insertTimelineItems, timelineCursor, uniqueFreshItems } from '$lib/timeline/window';
@@ -83,7 +83,7 @@ const maxFeedEvents = 600;
8383
const maxPendingNewerEvents = 600;
8484
const maxCachedOlderEvents = 600;
8585
const cachedFeedBufferLimit = maxFeedEvents + maxPendingNewerEvents + maxCachedOlderEvents;
86-
const olderFetchBatchLimit = 80;
86+
const olderFetchBatchLimit = 160;
8787
const olderFetchTarget = pageFeedLimit;
8888
const olderFetchMaxAttempts = 5;
8989
const olderFetchEmptyAttemptLimit = 2;
@@ -153,7 +153,7 @@ let currentSettings = defaultCustomFeedSettings;
153153
let currentMode: FeedMode = initialFeedMode;
154154
let currentHashtag = '';
155155
let currentSessionValue: Session | null = initialSession;
156-
let currentGlobalFeedAuthors: string[] = [];
156+
let currentGlobalFeedAuthors: string[] = defaultGlobalFeedAuthors;
157157
let currentNotifications: NotificationItem[] = [];
158158
let currentDirectMessages: DirectMessage[] = [];
159159
let currentNotificationSeenAt = initialSession && browser ? readLastSeen(notificationSeenStorageKey, initialSession.pubkey) : 0;
@@ -239,7 +239,10 @@ export async function bootstrap() {
239239
}
240240

241241
const [cachedEvents, cachedProfiles] = await Promise.all([getCachedEvents(cachedFeedBufferLimit), getCachedProfiles()]);
242-
const cachedTopLevelEvents = recentFeedEventsForMode(currentMode, cachedEventsForMode(currentMode, topLevelFeedEvents(filterSpam(cachedEvents, currentMutedPubkeys)), cachedFeedBufferLimit));
242+
const cachedTopLevelEvents = recentFeedEventsForMode(
243+
currentMode,
244+
cachedEventsForMode(currentMode, topLevelFeedEvents(filterSpam(cachedEvents, currentMutedPubkeys, trustedFeedPubkeys(currentMode))), cachedFeedBufferLimit)
245+
);
243246
const visibleEvents = cachedTopLevelEvents.slice(0, Math.min(initialFeedLimit, maxFeedEvents));
244247
cachedOlderEvents = limitFeedBuffer(cachedTopLevelEvents.slice(visibleEvents.length), maxCachedOlderEvents);
245248
events.set(visibleEvents);
@@ -375,12 +378,16 @@ function eventsForFeedMode(mode: FeedMode, items: NostrEvent[], follows = curren
375378
);
376379
}
377380
if (currentHashtag) return items.filter((event) => !eventHasImgurUrl(event));
378-
return items.filter((event) => globalFeedHashtags.some((tag) => eventHasHashtag(event, tag)) && !eventHasImgurUrl(event));
381+
return items.filter((event) => isGlobalFeedEvent(event));
379382
}
380383

381384
async function getCachedFeedCandidates(mode: FeedMode, limit = cachedFeedBufferLimit) {
382385
const cachedEvents = currentHashtag ? await getCachedHashtagEvents(currentHashtag, limit) : await getCachedEvents(limit);
383-
return recentFeedEventsForMode(mode, topLevelFeedEvents(filterSpam(cachedEventsForMode(mode, topLevelFeedEvents(filterSpam(cachedEvents, currentMutedPubkeys)), limit), currentMutedPubkeys)));
386+
const trustedPubkeys = trustedFeedPubkeys(mode);
387+
return recentFeedEventsForMode(
388+
mode,
389+
topLevelFeedEvents(filterSpam(cachedEventsForMode(mode, topLevelFeedEvents(filterSpam(cachedEvents, currentMutedPubkeys, trustedPubkeys)), limit), currentMutedPubkeys, trustedPubkeys))
390+
);
384391
}
385392

386393
async function primeCachedFeedBuffers(mode: FeedMode, visibleEvents: NostrEvent[]) {
@@ -1401,8 +1408,8 @@ function mergeFeedEvents(incoming: NostrEvent[], existing: NostrEvent[]) {
14011408
const byId = new Map<string, NostrEvent>();
14021409
[...existing, ...incoming].forEach((event) => byId.set(event.id, event));
14031410
const merged = [...byId.values()].filter((event) => !currentDeletedEventIds.has(event.id)).sort((a, b) => b.created_at - a.created_at);
1404-
const globalClean = currentMode === 'global' ? merged.filter((event) => !eventHasImgurUrl(event)) : merged;
1405-
const limited = currentMode === 'global' ? limitCryptoTopicDensity(limitConsecutiveAuthors(globalClean, 2), 10) : globalClean;
1411+
const globalClean = currentMode === 'global' ? merged.filter((event) => currentGlobalFeedAuthors.includes(event.pubkey) || !eventHasImgurUrl(event)) : merged;
1412+
const limited = currentMode === 'global' ? applyGlobalFeedLimits(globalClean) : globalClean;
14061413
return limited;
14071414
}
14081415

@@ -1423,6 +1430,28 @@ function eventHasHashtag(event: NostrEvent, tag: string) {
14231430
return new RegExp(`(^|\\s)#${normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(event.content);
14241431
}
14251432

1433+
function isGlobalFeedEvent(event: NostrEvent) {
1434+
if (currentGlobalFeedAuthors.includes(event.pubkey)) return true;
1435+
return globalFeedHashtags.some((tag) => eventHasHashtag(event, tag)) && !eventHasImgurUrl(event);
1436+
}
1437+
1438+
function trustedFeedPubkeys(mode: FeedMode) {
1439+
return mode === 'global' && !currentHashtag ? new Set(currentGlobalFeedAuthors) : new Set<string>();
1440+
}
1441+
1442+
function applyGlobalFeedLimits(items: NostrEvent[]) {
1443+
const trusted = items.filter((event) => currentGlobalFeedAuthors.includes(event.pubkey));
1444+
const limited = limitCryptoTopicDensity(
1445+
limitConsecutiveAuthors(
1446+
items.filter((event) => !currentGlobalFeedAuthors.includes(event.pubkey)),
1447+
2
1448+
),
1449+
10
1450+
);
1451+
if (!trusted.length) return limited;
1452+
return [...trusted, ...limited].sort((a, b) => b.created_at - a.created_at);
1453+
}
1454+
14261455
function feedKeywordHashtags(settings: CustomFeedSettings) {
14271456
return [
14281457
...new Set(
@@ -2014,9 +2043,9 @@ async function resolveDefaultGlobalFeedContext() {
20142043
mergeRelayHints(relayList.map((relay) => relay.url), 90);
20152044
const contacts = await fetchContactListDetails(profile.pubkey, currentRelays).catch(() => ({ pubkeys: [], relayHints: [], items: [] }));
20162045
mergeRelayHints(contacts.relayHints, 74);
2017-
currentGlobalFeedAuthors = mergeGlobalFeedAuthors(contacts.pubkeys, curatorContacts.pubkeys);
2046+
currentGlobalFeedAuthors = mergeGlobalFeedAuthors(defaultGlobalFeedAuthors, contacts.pubkeys, curatorContacts.pubkeys);
20182047
} else {
2019-
currentGlobalFeedAuthors = mergeGlobalFeedAuthors(curatorContacts.pubkeys);
2048+
currentGlobalFeedAuthors = mergeGlobalFeedAuthors(defaultGlobalFeedAuthors, curatorContacts.pubkeys);
20202049
}
20212050
}
20222051

0 commit comments

Comments
 (0)