From 918d7128450bcddd5354ae7e8877d564962cc365 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 31 May 2026 22:27:32 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 25 +++ src/i18n/locales/cs.ts | 8 +- src/i18n/locales/de.ts | 18 +- src/i18n/locales/en.ts | 8 +- src/i18n/locales/es.ts | 8 +- src/i18n/locales/fr.ts | 8 +- src/i18n/locales/nl.ts | 8 +- src/i18n/locales/pl.ts | 8 +- src/i18n/locales/ru.ts | 8 +- src/i18n/locales/tr.ts | 8 +- src/i18n/locales/zh.ts | 8 +- src/lib/discussion-topics.test.ts | 77 ++++++++ src/lib/discussion-topics.ts | 47 +++++ src/lib/relay-strikes.test.ts | 15 ++ src/lib/relay-strikes.ts | 12 +- .../SpellsPage/TopicKeywordHeatMap.test.ts | 41 +++++ .../SpellsPage/TopicKeywordHeatMap.tsx | 164 ++++++++++++++---- src/pages/secondary/NoteListPage/index.tsx | 88 +++++----- 18 files changed, 432 insertions(+), 127 deletions(-) create mode 100644 src/lib/discussion-topics.test.ts create mode 100644 src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f5d69648..4243f3df 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -451,9 +451,34 @@ function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts) })() } +function startProgressiveArchiveKindWarmMatchLayer(params: ProgressiveSearchLocalLayerOpts): void { + if (!params.warmMatch) return + const { warmMatch, isStale, kindsForWarm, afterSort, setEvents, setLoading } = params + void (async () => { + try { + const since = Math.floor(Date.now() / 1000) - 30 * 24 * 3600 + const evs = await indexedDb.scanEventArchiveByKinds({ + kinds: kindsForWarm, + since, + maxRowsScanned: 22_000, + maxMatches: 400 + }) + if (isStale()) return + const matched = evs.filter(warmMatch) + if (matched.length) { + setEvents((prev) => mergeProgressiveSearchEvents(prev, matched, afterSort)) + setLoading(false) + } + } catch { + /* ignore */ + } + })() +} + function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void { applyProgressiveSessionSearchLayer(params) startProgressiveIdbSearchLayer(params) + startProgressiveArchiveKindWarmMatchLayer(params) } /** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */ diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index a7e56b7f..440bba7f 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -1084,7 +1084,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1092,10 +1092,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b8570c32..92d2dac7 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1124,18 +1124,18 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Die zehn größten Blasen: gültige Labels der letzten ~30 Tage aus ·t·-Themen-Tags und echten #hashtags im Notiztext (keine Volltextsuche). Größe = kombinierte Häufigkeit; kleine Gesichter = Autoren mit diesem Label. Aus Sitzungs-Cache, Archiv und Relays. Tippen öffnet passende Notizen.', topicMapLocalOnlyBanner: - 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', - topicMapLoading: 'Merging session cache, archive, and relays…', + 'Kein Lese-Relay-Stack — nur Sitzungs-Cache und Geräte-Archiv (Relays in den Einstellungen für Live-Daten).', + topicMapLoading: 'Sitzungs-Cache, Archiv und Relays werden zusammengeführt…', topicMapEmpty: - 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', - topicMapFetchError: 'Could not build the topic map from your sources.', - topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + 'Keine Themen- oder Hashtag-Signale im Scan-Fenster. Feeds lesen oder nach Sync erneut scannen.', + topicMapFetchError: 'Topic Map konnte aus deinen Quellen nicht aufgebaut werden.', + topicMapRescan: 'Erneut scannen', + topicMapBubbleCounts: '{{topic}} mit ·t·-Tag · {{kw}} mit #hashtag im Text', + topicMapOpenMergedFeed: 'Themen-Feed öffnen', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Öffnet Notizen mit diesem ·t·-Tag oder diesem #hashtag im Text.', Calendar: 'Kalender', 'No subscribed interests yet.': 'Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d37d308d..31db8ea1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1140,7 +1140,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1148,10 +1148,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d373372a..6e0c1f08 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -1088,7 +1088,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1096,10 +1096,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index ff4ed7a9..dd5090a4 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -1089,7 +1089,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1097,10 +1097,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 6d58d736..5e595f3e 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -1084,7 +1084,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1092,10 +1092,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 4a6a937b..35a63c99 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -1086,7 +1086,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1094,10 +1094,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 17dc98be..2a8686da 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1087,7 +1087,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1095,10 +1095,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 26451859..5b5d6785 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -1084,7 +1084,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1092,10 +1092,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 1f1ae6dc..70507f23 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1081,7 +1081,7 @@ export default { 'incoming interactions': '{{count}} toward this profile', 'Topic map': 'Topic map', topicMapDescription: - 'The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.', + 'Top ten labels from the last ~30 days: each counts ·t· topic tags and valid #hashtags in note text (not full-text search). Bubble size is the combined count; small faces are people who used that label. Built from session cache, on-device archive, and relays. Tap a bubble to open matching notes.', topicMapLocalOnlyBanner: 'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).', topicMapLoading: 'Merging session cache, archive, and relays…', @@ -1089,10 +1089,10 @@ export default { 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', topicMapFetchError: 'Could not build the topic map from your sources.', topicMapRescan: 'Rescan', - topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', - topicMapOpenMergedFeed: 'Open merged topic and keyword feed', + topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text', + topicMapOpenMergedFeed: 'Open topic feed', topicMapClickHint: - 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.', + 'Opens notes that carry this ·t· tag or this #hashtag in the body.', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', diff --git a/src/lib/discussion-topics.test.ts b/src/lib/discussion-topics.test.ts new file mode 100644 index 00000000..5d66b0d4 --- /dev/null +++ b/src/lib/discussion-topics.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { eventMatchesTopicOrContentHashtag, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizedKeyMatchesHashtagPattern, normalizeTopic, relayTopicTagFilterValues } from '@/lib/discussion-topics' + +describe('eventMatchesTopicOrContentHashtag', () => { + it('matches normalized t tags', () => { + const ev = { + kind: kinds.ShortTextNote, + tags: [['t', 'catholic']], + content: 'hello', + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + sig: 'c'.repeat(128), + created_at: 1 + } + expect(eventMatchesTopicOrContentHashtag(ev, 'catholic')).toBe(true) + }) + + it('matches #hashtag in content', () => { + const ev = { + kind: kinds.ShortTextNote, + tags: [], + content: 'Prayers for the #catholic church today', + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + sig: 'c'.repeat(128), + created_at: 1 + } + expect(eventMatchesTopicOrContentHashtag(ev, 'catholic')).toBe(true) + }) + + it('rejects plain text without t tag or #hashtag', () => { + const ev = { + kind: kinds.ShortTextNote, + tags: [], + content: 'That catholic school is weird', + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + sig: 'c'.repeat(128), + created_at: 1 + } + expect(eventMatchesTopicOrContentHashtag(ev, 'catholic')).toBe(false) + }) +}) + +describe('isValidNormalizedTopicKey', () => { + it('accepts normalized topic keys', () => { + expect(isValidNormalizedTopicKey('nostr')).toBe(true) + expect(isValidNormalizedTopicKey('grownostr')).toBe(true) + }) + + it('rejects empty, numeric-only, and invalid characters', () => { + expect(isValidNormalizedTopicKey('')).toBe(false) + expect(isValidNormalizedTopicKey('123')).toBe(false) + expect(isValidNormalizedTopicKey('-bad')).toBe(false) + }) +}) + +describe('formatTopicMapBubbleLabel', () => { + it('shows readable text without a hash prefix', () => { + expect(formatTopicMapBubbleLabel('decent-newsroom')).toBe('decent newsroom') + expect(formatTopicMapBubbleLabel('nostr')).toBe('nostr') + }) +}) + +describe('relayTopicTagFilterValues', () => { + it('includes plural t-tag variants for singularized map keys', () => { + expect(relayTopicTagFilterValues('jesu')).toEqual(expect.arrayContaining(['jesu', 'jesus'])) + }) +}) + +describe('normalizedKeyMatchesHashtagPattern', () => { + it('matches valid ascii hashtag bodies', () => { + expect(normalizedKeyMatchesHashtagPattern('imwald')).toBe(true) + expect(normalizedKeyMatchesHashtagPattern('')).toBe(false) + }) +}) diff --git a/src/lib/discussion-topics.ts b/src/lib/discussion-topics.ts index c3730a46..35000dcb 100644 --- a/src/lib/discussion-topics.ts +++ b/src/lib/discussion-topics.ts @@ -84,6 +84,53 @@ export function extractHashtagsFromContent(content: string): string[] { return hashtags } +/** True when the event carries `topic` as a normalized `t` tag or `#topic` in note content. */ +export function eventMatchesTopicOrContentHashtag(event: NostrEvent, topic: string): boolean { + const key = normalizeTopic(topic) + if (!key) return false + for (const row of event.tags) { + if (row[0] === 't' && row[1] && normalizeTopic(row[1]) === key) return true + } + return extractHashtagsFromContent(event.content ?? '').includes(key) +} + +/** Normalized topic/hashtag keys suitable for topic-map bubbles and `#t` feeds. */ +export function isValidNormalizedTopicKey(key: string): boolean { + const k = key.trim() + if (!k || /^[0-9]+$/.test(k)) return false + return /^[a-z0-9][a-z0-9_-]*$/.test(k) +} + +/** True when `#${key}` matches the content {@link HASHTAG_REGEX} (ASCII keys after normalization). */ +export function normalizedKeyMatchesHashtagPattern(key: string): boolean { + if (!isValidNormalizedTopicKey(key)) return false + return /^#[a-z0-9_-]+$/i.test(`#${key}`) +} + +/** Topic-map bubble label: readable words, no `#` prefix. */ +export function formatTopicMapBubbleLabel(key: string): string { + return key.replace(/-/g, ' ') +} + +/** + * `#t` filter values for relay REQs when opening a normalized topic-map key. + * Map keys singularize (e.g. `jesus` → `jesu`); relays often still store the unsingularized t-tag. + */ +export function relayTopicTagFilterValues(normalizedKey: string): string[] { + const k = normalizeTopic(normalizedKey) || normalizedKey.trim().toLowerCase() + if (!k) return [] + const out = new Set([k]) + if (!k.endsWith('s')) { + out.add(`${k}s`) + if (k.endsWith('y')) out.add(`${k.slice(0, -1)}ies`) + else out.add(`${k}es`) + } + if (k.endsWith('s') && k.length > 2 && !k.endsWith('ss')) { + out.add(k.slice(0, -1)) + } + return [...out] +} + /** * Extract h-tag (group ID) from event tags */ diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index 684d2e2d..cb19252d 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -33,6 +33,21 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => { expect(relaySessionStrikes.isReadHttpSkipped(fast)).toBe(false) }) + it('does not session-park read-only index relays (e.g. aggr.nostr.land)', () => { + const aggr = 'wss://aggr.nostr.land/' + const fast = 'wss://fast.example.com/' + + relaySessionStrikes.observeSubscribeBatch([ + row(fast, 'eose', 400), + row(aggr, 'eose', 12_000) + ]) + relaySessionStrikes.observeSubscribeBatch([ + row(fast, 'eose', 500), + row(aggr, 'timeout', 10_000) + ]) + expect(relaySessionStrikes.isReadHttpSkipped(aggr)).toBe(false) + }) + it('clears slow parking on fast EOSE via recordReadSuccess', () => { const url = 'wss://recover.example.com/' relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 15_000)]) diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index edada505..22fb8a0b 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -7,7 +7,7 @@ import { import type { Event } from 'nostr-tools' import { getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' -import { isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter' +import { isReadOnlyRelayUrl, isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter' import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' @@ -287,9 +287,11 @@ class RelaySessionStrikes { const fastEose = row.outcome === 'eose' && row.msFromBatchStart < slowThresholdMs * 0.6 if (timedOut || slowEose) { - const parked = this.recordSlowSignalKey(key, now) + const parked = this.recordSlowSignalKey(key, now, row.relayUrl) if (parked) socketsToClose.push(row.relayUrl) - if (timedOut) this.recordReadFailureKey(key, 'connection', row.relayUrl) + if (timedOut && !isReadOnlyRelayUrl(row.relayUrl)) { + this.recordReadFailureKey(key, 'connection', row.relayUrl) + } continue } @@ -304,9 +306,11 @@ class RelaySessionStrikes { return socketsToClose } - private recordSlowSignalKey(key: string, now: number): boolean { + private recordSlowSignalKey(key: string, now: number, url?: string): boolean { const e = this.getEntry(key) if (this.cacheRelayKeys.has(key)) return false + // Read-only index relays (aggr.nostr.land, search.nos.today, …) are intentionally slower than inbox relays. + if (url && isReadOnlyRelayUrl(url)) return false e.slowSignals += 1 if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS) diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts new file mode 100644 index 00000000..be472c2c --- /dev/null +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { DEFAULT_FEED_SHOW_KINDS } from '@/constants' +import { buildTopicKeywordBubbles } from './TopicKeywordHeatMap' + +function note(pubkey: string, tags: string[][], content = '') { + return { + kind: kinds.ShortTextNote, + pubkey, + content, + tags, + id: `${pubkey.slice(0, 8)}${'a'.repeat(56)}`, + sig: 'b'.repeat(128), + created_at: 1_700_000_000 + } +} + +describe('buildTopicKeywordBubbles', () => { + it('ranks pubkeys by how often they used the topic', () => { + const pkA = 'a'.repeat(64) + const pkB = 'b'.repeat(64) + const pkC = 'c'.repeat(64) + const bubbles = buildTopicKeywordBubbles( + [ + note(pkA, [['t', 'nostr']]), + note(pkA, [['t', 'nostr']]), + note(pkB, [['t', 'nostr']]), + note(pkC, [], 'hello #nostr'), + note(pkC, [], 'again #nostr') + ], + DEFAULT_FEED_SHOW_KINDS, + true, + true, + true + ) + const nostr = bubbles.find((b) => b.key === 'nostr') + expect(nostr?.pubkeys[0]).toBe(pkA) + expect(nostr?.pubkeys).toContain(pkC) + expect(nostr?.pubkeys).toContain(pkB) + }) +}) diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx index 55fe0d8e..15f2cebd 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -3,7 +3,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/h import { ExtendedKind } from '@/constants' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingTombstones } from '@/lib/event' -import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' +import { extractHashtagsFromContent, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNoteList } from '@/lib/link' import logger from '@/lib/logger' @@ -14,6 +14,7 @@ import { useNostr } from '@/providers/NostrProvider' import client, { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { cn } from '@/lib/utils' +import { SimpleUserAvatar } from '@/components/UserAvatar' import { Loader2, RefreshCw } from 'lucide-react' import type { Event } from 'nostr-tools' import { kinds, verifyEvent } from 'nostr-tools' @@ -32,12 +33,88 @@ const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 const RELAY_FETCH_TIMEOUT_MS = 26_000 const TOMBSTONES_TIMEOUT_MS = 8_000 +/** Max profile avatars shown around each topic bubble (by tag usage count). */ +const MAX_BUBBLE_AVATARS = 7 export type TTopicKeywordBubble = { key: string score: number topicNoteCount: number keywordNoteCount: number + pubkeys: string[] +} + +type TopicKeyAccum = { + topicNoteCount: number + keywordNoteCount: number + pubkeyHits: Map +} + +function topPubkeysForTopic(hits: Map, limit: number): string[] { + return [...hits.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([pk]) => pk) +} + +function TopicBubbleAvatarRing({ + pubkeys, + bubbleSizePx +}: { + pubkeys: readonly string[] + bubbleSizePx: number +}) { + if (pubkeys.length === 0) return null + const avatarPx = Math.max(16, Math.min(26, Math.round(bubbleSizePx * 0.15))) + const orbitR = bubbleSizePx * (pubkeys.length === 1 ? 0 : 0.34) + + if (pubkeys.length === 1) { + return ( +
+ +
+ ) + } + + return ( + <> + {pubkeys.map((pk, i) => { + const angle = (i / pubkeys.length) * Math.PI * 2 - Math.PI / 2 + const left = bubbleSizePx / 2 + orbitR * Math.cos(angle) + const top = bubbleSizePx / 2 + orbitR * Math.sin(angle) + return ( +
+ +
+ ) + })} + + ) } function raceWithTimeout(promise: Promise, ms: number, fallback: T, label: string): Promise { @@ -66,15 +143,29 @@ function raceWithTimeout(promise: Promise, ms: number, fallback: T, label: }) } -function buildTopicKeywordBubbles( +export function buildTopicKeywordBubbles( events: Event[], showKinds: readonly number[], showKind1OPs: boolean, showKind1Replies: boolean, showKind1111: boolean ): TTopicKeywordBubble[] { - const topicHits = new Map() - const kwHits = new Map() + const accum = new Map() + + const bump = (key: string, ev: Event, viaTopicTag: boolean) => { + if (!isValidNormalizedTopicKey(key)) return + let row = accum.get(key) + if (!row) { + row = { topicNoteCount: 0, keywordNoteCount: 0, pubkeyHits: new Map() } + accum.set(key, row) + } + if (viaTopicTag) row.topicNoteCount += 1 + else row.keywordNoteCount += 1 + const pk = ev.pubkey.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(pk)) { + row.pubkeyHits.set(pk, (row.pubkeyHits.get(pk) ?? 0) + 1) + } + } for (const ev of events) { if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue @@ -82,27 +173,27 @@ function buildTopicKeywordBubbles( for (const row of ev.tags) { if (row[0] === 't' && row[1]) { const n = normalizeTopic(row[1]) - if (n) topics.add(n) + if (n && isValidNormalizedTopicKey(n)) topics.add(n) } } const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) - for (const k of topics) { - topicHits.set(k, (topicHits.get(k) ?? 0) + 1) - } - for (const k of kws) { - kwHits.set(k, (kwHits.get(k) ?? 0) + 1) - } + for (const k of topics) bump(k, ev, true) + for (const k of kws) bump(k, ev, false) } - const keys = new Set([...topicHits.keys(), ...kwHits.keys()]) const out: TTopicKeywordBubble[] = [] - for (const key of keys) { - const a = topicHits.get(key) ?? 0 - const b = kwHits.get(key) ?? 0 - const score = a + b + for (const [key, row] of accum) { + if (!isValidNormalizedTopicKey(key)) continue + const score = row.topicNoteCount + row.keywordNoteCount if (score <= 0) continue - out.push({ key, score, topicNoteCount: a, keywordNoteCount: b }) + out.push({ + key, + score, + topicNoteCount: row.topicNoteCount, + keywordNoteCount: row.keywordNoteCount, + pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS) + }) } out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) return out.slice(0, MAX_BUBBLES) @@ -221,17 +312,22 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { } }, [mergeData, refreshKey, rescanTick, t]) + useEffect(() => { + const pubkeys = [...new Set(rows.flatMap((r) => r.pubkeys))].slice(0, 48) + if (pubkeys.length === 0) return + void Promise.allSettled(pubkeys.map((pk) => client.fetchProfileEvent(pk).catch(() => {}))) + }, [rows]) + const maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows]) const openMergedFeed = useCallback( (key: string) => { - const searchPhrase = key.replace(/-/g, ' ') - navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase })) + navigateToHashtag(toNoteList({ hashtag: key })) }, [navigateToHashtag] ) - const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}` + const displayLabel = (key: string) => formatTopicMapBubbleLabel(key) return (
@@ -302,16 +398,26 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { onClick={() => openMergedFeed(row.key)} aria-label={ariaLabel} > + {row.pubkeys.length > 0 ? ( + + ) : ( + + )} - + className={cn( + 'pointer-events-none absolute inset-x-1 bottom-1.5 z-[1] rounded-md px-1 py-0.5', + 'text-pretty text-center text-[10px] font-semibold leading-tight text-foreground sm:text-xs', + 'bg-background/75 backdrop-blur-[2px] shadow-sm' + )} + > {displayLabel(row.key)} diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 6fa0dc66..fcf52ef9 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button' import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, - NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import { @@ -24,6 +23,7 @@ import { buildAlexandriaEventsUrlForHashtagParam } from '@/lib/alexandria-events-search-url' import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search' +import { eventMatchesTopicOrContentHashtag, normalizeTopic, relayTopicTagFilterValues } from '@/lib/discussion-topics' import { fetchPubkeysFromDomain } from '@/lib/nip05' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useSecondaryPage } from '@/PageManager' @@ -58,7 +58,7 @@ const NoteListPage = forwardRef(({ index, hid const [controls, setControls] = useState(null) const [data, setData] = useState< | { - type: 'hashtag' | 'hashtagSearch' | 'search' | 'externalContent' | 'dtag' + type: 'hashtag' | 'search' | 'externalContent' | 'dtag' kinds?: number[] dtag?: string } @@ -74,7 +74,7 @@ const NoteListPage = forwardRef(({ index, hid const alexandriaEmptyUrl = useMemo(() => { if (!data) return null if (data.type === 'dtag' && data.dtag) return buildAlexandriaEventsUrlForDTagParam(data.dtag) - if (data.type === 'hashtag' || data.type === 'hashtagSearch') { + if (data.type === 'hashtag') { const t = new URLSearchParams(window.location.search).get('t') ?? '' return buildAlexandriaEventsUrlForHashtagParam(t) } @@ -83,13 +83,28 @@ const NoteListPage = forwardRef(({ index, hid // Get hashtag from URL if this is a hashtag page const hashtag = useMemo(() => { - if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') { + if (data?.type === 'hashtag') { const searchParams = new URLSearchParams(window.location.search) return searchParams.get('t') } return null }, [data]) + const topicKey = useMemo( + () => (hashtag ? normalizeTopic(hashtag) || hashtag.toLowerCase() : ''), + [hashtag] + ) + + const topicMatchesEvent = useCallback( + (ev: import('nostr-tools').Event) => eventMatchesTopicOrContentHashtag(ev, topicKey), + [topicKey] + ) + + const shouldHideNonTopicEvent = useCallback( + (ev: import('nostr-tools').Event) => !topicMatchesEvent(ev), + [topicMatchesEvent] + ) + // Check if the hashtag is already in the user's interest list const isHashtagSubscribed = useMemo(() => { if (!hashtag) return false @@ -118,52 +133,16 @@ const NoteListPage = forwardRef(({ index, hid includeGlobalFastRead: useGlobalRelayBootstrap } const hashtag = searchParams.get('t') - const searchFromUrl = searchParams.get('s') - if (hashtag && searchFromUrl) { - setData({ type: 'hashtagSearch' }) - setTitle(`${t('Search')}: #${hashtag} · ${searchFromUrl}`) - const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - userReadInboxUrls(relayList, cacheRelayListEvent), - readUrlOpts - ) - const mergedSearchKinds = Array.from( - new Set([...NIP_SEARCH_PAGE_KINDS, ...(kinds.length > 0 ? kinds : [])]) - ).sort((a, b) => a - b) - setSubRequests([ - { - filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, - urls: relayUrls - }, - { - filter: { search: searchFromUrl, kinds: mergedSearchKinds }, - urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])] - } - ]) - const isSubscribedToHashtag = isSubscribed(hashtag) - if (pubkey) { - setControls( - - ) - } else { - setControls(null) - } - return - } if (hashtag) { + const topicKey = normalizeTopic(hashtag) || hashtag.toLowerCase() setData({ type: 'hashtag' }) setTitle(`# ${hashtag}`) setSubRequests([ { - filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, + filter: { + '#t': relayTopicTagFilterValues(topicKey), + ...(kinds.length > 0 ? { kinds } : {}) + }, urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, @@ -172,7 +151,6 @@ const NoteListPage = forwardRef(({ index, hid ) } ]) - // Set controls for hashtag subscribe button - check subscription status const isSubscribedToHashtag = isSubscribed(hashtag) if (pubkey) { setControls( @@ -185,6 +163,8 @@ const NoteListPage = forwardRef(({ index, hid {isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} ) + } else { + setControls(null) } return } @@ -332,7 +312,7 @@ const NoteListPage = forwardRef(({ index, hid // Update controls when subscription status changes useEffect(() => { - if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) { + if (data?.type === 'hashtag' && pubkey) { setControls(