Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
918d712845
  1. 25
      src/components/NoteList/index.tsx
  2. 8
      src/i18n/locales/cs.ts
  3. 18
      src/i18n/locales/de.ts
  4. 8
      src/i18n/locales/en.ts
  5. 8
      src/i18n/locales/es.ts
  6. 8
      src/i18n/locales/fr.ts
  7. 8
      src/i18n/locales/nl.ts
  8. 8
      src/i18n/locales/pl.ts
  9. 8
      src/i18n/locales/ru.ts
  10. 8
      src/i18n/locales/tr.ts
  11. 8
      src/i18n/locales/zh.ts
  12. 77
      src/lib/discussion-topics.test.ts
  13. 47
      src/lib/discussion-topics.ts
  14. 15
      src/lib/relay-strikes.test.ts
  15. 12
      src/lib/relay-strikes.ts
  16. 41
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts
  17. 148
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  18. 88
      src/pages/secondary/NoteListPage/index.tsx

25
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 { function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void {
applyProgressiveSessionSearchLayer(params) applyProgressiveSessionSearchLayer(params)
startProgressiveIdbSearchLayer(params) startProgressiveIdbSearchLayer(params)
startProgressiveArchiveKindWarmMatchLayer(params)
} }
/** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */ /** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */

8
src/i18n/locales/cs.ts

@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

18
src/i18n/locales/de.ts

@ -1124,18 +1124,18 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', 'Kein Lese-Relay-Stack — nur Sitzungs-Cache und Geräte-Archiv (Relays in den Einstellungen für Live-Daten).',
topicMapLoading: 'Merging session cache, archive, and relays…', topicMapLoading: 'Sitzungs-Cache, Archiv und Relays werden zusammengeführt…',
topicMapEmpty: topicMapEmpty:
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.', 'Keine Themen- oder Hashtag-Signale im Scan-Fenster. Feeds lesen oder nach Sync erneut scannen.',
topicMapFetchError: 'Could not build the topic map from your sources.', topicMapFetchError: 'Topic Map konnte aus deinen Quellen nicht aufgebaut werden.',
topicMapRescan: 'Rescan', topicMapRescan: 'Erneut scannen',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} mit ·t·-Tag · {{kw}} mit #hashtag im Text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Themen-Feed öffnen',
topicMapClickHint: 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', Calendar: 'Kalender',
'No subscribed interests yet.': 'No subscribed interests yet.':
'Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.', 'Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.',

8
src/i18n/locales/en.ts

@ -1140,7 +1140,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/es.ts

@ -1088,7 +1088,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/fr.ts

@ -1089,7 +1089,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/nl.ts

@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/pl.ts

@ -1086,7 +1086,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/ru.ts

@ -1087,7 +1087,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/tr.ts

@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/zh.ts

@ -1081,7 +1081,7 @@ export default {
'incoming interactions': '{{count}} toward this profile', 'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map', 'Topic map': 'Topic map',
topicMapDescription: 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: 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).', '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…', 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.', '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.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed', topicMapOpenMergedFeed: 'Open topic feed',
topicMapClickHint: 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', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', 'No subscribed interests yet. Add topics in settings to see them here.',

77
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)
})
})

47
src/lib/discussion-topics.ts

@ -84,6 +84,53 @@ export function extractHashtagsFromContent(content: string): string[] {
return hashtags 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<string>([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 * Extract h-tag (group ID) from event tags
*/ */

15
src/lib/relay-strikes.test.ts

@ -33,6 +33,21 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => {
expect(relaySessionStrikes.isReadHttpSkipped(fast)).toBe(false) 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', () => { it('clears slow parking on fast EOSE via recordReadSuccess', () => {
const url = 'wss://recover.example.com/' const url = 'wss://recover.example.com/'
relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 15_000)]) relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 15_000)])

12
src/lib/relay-strikes.ts

@ -7,7 +7,7 @@ import {
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { getRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' 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 { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url'
import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' 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 const fastEose = row.outcome === 'eose' && row.msFromBatchStart < slowThresholdMs * 0.6
if (timedOut || slowEose) { if (timedOut || slowEose) {
const parked = this.recordSlowSignalKey(key, now) const parked = this.recordSlowSignalKey(key, now, row.relayUrl)
if (parked) socketsToClose.push(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 continue
} }
@ -304,9 +306,11 @@ class RelaySessionStrikes {
return socketsToClose return socketsToClose
} }
private recordSlowSignalKey(key: string, now: number): boolean { private recordSlowSignalKey(key: string, now: number, url?: string): boolean {
const e = this.getEntry(key) const e = this.getEntry(key)
if (this.cacheRelayKeys.has(key)) return false 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 e.slowSignals += 1
if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false
e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS) e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS)

41
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)
})
})

148
src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx

@ -3,7 +3,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/h
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event' 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 { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -14,6 +14,7 @@ import { useNostr } from '@/providers/NostrProvider'
import client, { eventService } from '@/services/client.service' import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { Loader2, RefreshCw } from 'lucide-react' import { Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds, verifyEvent } 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 ARCHIVE_SCAN_TIMEOUT_MS = 22_000
const RELAY_FETCH_TIMEOUT_MS = 26_000 const RELAY_FETCH_TIMEOUT_MS = 26_000
const TOMBSTONES_TIMEOUT_MS = 8_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 = { export type TTopicKeywordBubble = {
key: string key: string
score: number score: number
topicNoteCount: number topicNoteCount: number
keywordNoteCount: number keywordNoteCount: number
pubkeys: string[]
}
type TopicKeyAccum = {
topicNoteCount: number
keywordNoteCount: number
pubkeyHits: Map<string, number>
}
function topPubkeysForTopic(hits: Map<string, number>, 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 (
<div
className="pointer-events-none overflow-hidden rounded-full ring-2 ring-primary/35"
style={{ width: avatarPx * 1.35, height: avatarPx * 1.35 }}
aria-hidden
>
<SimpleUserAvatar
userId={pubkeys[0]!}
deferRemoteAvatar
maxFileSizeKb={400}
className="!size-full max-w-none"
/>
</div>
)
}
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 (
<div
key={pk}
className="pointer-events-none absolute overflow-hidden rounded-full ring-2 ring-background"
style={{
width: avatarPx,
height: avatarPx,
left,
top,
transform: 'translate(-50%, -50%)'
}}
aria-hidden
>
<SimpleUserAvatar
userId={pk}
deferRemoteAvatar
maxFileSizeKb={400}
className="!size-full max-w-none"
/>
</div>
)
})}
</>
)
} }
function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: string): Promise<T> { function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: string): Promise<T> {
@ -66,15 +143,29 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label:
}) })
} }
function buildTopicKeywordBubbles( export function buildTopicKeywordBubbles(
events: Event[], events: Event[],
showKinds: readonly number[], showKinds: readonly number[],
showKind1OPs: boolean, showKind1OPs: boolean,
showKind1Replies: boolean, showKind1Replies: boolean,
showKind1111: boolean showKind1111: boolean
): TTopicKeywordBubble[] { ): TTopicKeywordBubble[] {
const topicHits = new Map<string, number>() const accum = new Map<string, TopicKeyAccum>()
const kwHits = new Map<string, number>()
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) { for (const ev of events) {
if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue
@ -82,27 +173,27 @@ function buildTopicKeywordBubbles(
for (const row of ev.tags) { for (const row of ev.tags) {
if (row[0] === 't' && row[1]) { if (row[0] === 't' && row[1]) {
const n = normalizeTopic(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 ?? '')) const kws = new Set(extractHashtagsFromContent(ev.content ?? ''))
for (const k of topics) { for (const k of topics) bump(k, ev, true)
topicHits.set(k, (topicHits.get(k) ?? 0) + 1) for (const k of kws) bump(k, ev, false)
}
for (const k of kws) {
kwHits.set(k, (kwHits.get(k) ?? 0) + 1)
}
} }
const keys = new Set<string>([...topicHits.keys(), ...kwHits.keys()])
const out: TTopicKeywordBubble[] = [] const out: TTopicKeywordBubble[] = []
for (const key of keys) { for (const [key, row] of accum) {
const a = topicHits.get(key) ?? 0 if (!isValidNormalizedTopicKey(key)) continue
const b = kwHits.get(key) ?? 0 const score = row.topicNoteCount + row.keywordNoteCount
const score = a + b
if (score <= 0) continue 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)) out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key))
return out.slice(0, MAX_BUBBLES) return out.slice(0, MAX_BUBBLES)
@ -221,17 +312,22 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
} }
}, [mergeData, refreshKey, rescanTick, t]) }, [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 maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows])
const openMergedFeed = useCallback( const openMergedFeed = useCallback(
(key: string) => { (key: string) => {
const searchPhrase = key.replace(/-/g, ' ') navigateToHashtag(toNoteList({ hashtag: key }))
navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase }))
}, },
[navigateToHashtag] [navigateToHashtag]
) )
const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}` const displayLabel = (key: string) => formatTopicMapBubbleLabel(key)
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4"> <div className="flex min-h-0 flex-1 flex-col gap-4">
@ -302,6 +398,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
onClick={() => openMergedFeed(row.key)} onClick={() => openMergedFeed(row.key)}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{row.pubkeys.length > 0 ? (
<TopicBubbleAvatarRing pubkeys={row.pubkeys} bubbleSizePx={size} />
) : (
<span <span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35" className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{ style={{
@ -311,7 +410,14 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
}} }}
aria-hidden aria-hidden
/> />
<span className="pointer-events-none absolute inset-2 flex items-center justify-center text-pretty text-xs font-semibold leading-tight text-foreground drop-shadow-sm sm:text-sm"> )}
<span
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)} {displayLabel(row.key)}
</span> </span>
</button> </button>

88
src/pages/secondary/NoteListPage/index.tsx

@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { import {
isSocialKindBlockedKind, isSocialKindBlockedKind,
NIP_SEARCH_DOCUMENT_KINDS, NIP_SEARCH_DOCUMENT_KINDS,
NIP_SEARCH_PAGE_KINDS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { import {
@ -24,6 +23,7 @@ import {
buildAlexandriaEventsUrlForHashtagParam buildAlexandriaEventsUrlForHashtagParam
} from '@/lib/alexandria-events-search-url' } from '@/lib/alexandria-events-search-url'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search' import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { eventMatchesTopicOrContentHashtag, normalizeTopic, relayTopicTagFilterValues } from '@/lib/discussion-topics'
import { fetchPubkeysFromDomain } from '@/lib/nip05' import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@ -58,7 +58,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const [controls, setControls] = useState<React.ReactNode>(null) const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState< const [data, setData] = useState<
| { | {
type: 'hashtag' | 'hashtagSearch' | 'search' | 'externalContent' | 'dtag' type: 'hashtag' | 'search' | 'externalContent' | 'dtag'
kinds?: number[] kinds?: number[]
dtag?: string dtag?: string
} }
@ -74,7 +74,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const alexandriaEmptyUrl = useMemo(() => { const alexandriaEmptyUrl = useMemo(() => {
if (!data) return null if (!data) return null
if (data.type === 'dtag' && data.dtag) return buildAlexandriaEventsUrlForDTagParam(data.dtag) 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') ?? '' const t = new URLSearchParams(window.location.search).get('t') ?? ''
return buildAlexandriaEventsUrlForHashtagParam(t) return buildAlexandriaEventsUrlForHashtagParam(t)
} }
@ -83,13 +83,28 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
// Get hashtag from URL if this is a hashtag page // Get hashtag from URL if this is a hashtag page
const hashtag = useMemo(() => { const hashtag = useMemo(() => {
if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') { if (data?.type === 'hashtag') {
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
return searchParams.get('t') return searchParams.get('t')
} }
return null return null
}, [data]) }, [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 // Check if the hashtag is already in the user's interest list
const isHashtagSubscribed = useMemo(() => { const isHashtagSubscribed = useMemo(() => {
if (!hashtag) return false if (!hashtag) return false
@ -118,52 +133,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
includeGlobalFastRead: useGlobalRelayBootstrap includeGlobalFastRead: useGlobalRelayBootstrap
} }
const hashtag = searchParams.get('t') 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<number>([...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(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isSubscribedToHashtag}
>
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} else {
setControls(null)
}
return
}
if (hashtag) { if (hashtag) {
const topicKey = normalizeTopic(hashtag) || hashtag.toLowerCase()
setData({ type: 'hashtag' }) setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`) setTitle(`# ${hashtag}`)
setSubRequests([ setSubRequests([
{ {
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, filter: {
'#t': relayTopicTagFilterValues(topicKey),
...(kinds.length > 0 ? { kinds } : {})
},
urls: getRelayUrlsWithFavoritesFastReadAndInbox( urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
@ -172,7 +151,6 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
) )
} }
]) ])
// Set controls for hashtag subscribe button - check subscription status
const isSubscribedToHashtag = isSubscribed(hashtag) const isSubscribedToHashtag = isSubscribed(hashtag)
if (pubkey) { if (pubkey) {
setControls( setControls(
@ -185,6 +163,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus /> {isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button> </Button>
) )
} else {
setControls(null)
} }
return return
} }
@ -332,7 +312,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
// Update controls when subscription status changes // Update controls when subscription status changes
useEffect(() => { useEffect(() => {
if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) { if (data?.type === 'hashtag' && pubkey) {
setControls( setControls(
<Button <Button
variant="ghost" variant="ghost"
@ -349,7 +329,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
useEffect(() => { useEffect(() => {
const inlineHeader = const inlineHeader =
hideTitlebar && hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag') (data?.type === 'hashtag' || data?.type === 'dtag')
if (!hideTitlebar || inlineHeader) { if (!hideTitlebar || inlineHeader) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
return return
@ -376,6 +356,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
oneShotMergedCap={400} oneShotMergedCap={400}
alexandriaEmptyUrl={alexandriaEmptyUrl} alexandriaEmptyUrl={alexandriaEmptyUrl}
/> />
) : data.type === 'hashtag' ? (
<NormalFeed
ref={feedRef}
subRequests={subRequests}
extraShouldHideEvent={shouldHideNonTopicEvent}
extraShouldHideRepliesEvent={shouldHideNonTopicEvent}
progressiveWarmupQuery={topicKey || undefined}
progressiveWarmupMatch={topicMatchesEvent}
alexandriaEmptyUrl={alexandriaEmptyUrl}
/>
) : ( ) : (
<NormalFeed ref={feedRef} subRequests={subRequests} alexandriaEmptyUrl={alexandriaEmptyUrl} /> <NormalFeed ref={feedRef} subRequests={subRequests} alexandriaEmptyUrl={alexandriaEmptyUrl} />
) )
@ -399,7 +389,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
displayScrollToTopButton displayScrollToTopButton
> >
{hideTitlebar && {hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag') ? ( (data?.type === 'hashtag' || data?.type === 'dtag') ? (
<> <>
<div className="px-4 py-2 border-b"> <div className="px-4 py-2 border-b">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

Loading…
Cancel
Save