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. 164
      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) @@ -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. */

8
src/i18n/locales/cs.ts

@ -1084,7 +1084,7 @@ export default { @@ -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 { @@ -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.',

18
src/i18n/locales/de.ts

@ -1124,18 +1124,18 @@ export default { @@ -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.',

8
src/i18n/locales/en.ts

@ -1140,7 +1140,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/es.ts

@ -1088,7 +1088,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/fr.ts

@ -1089,7 +1089,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/nl.ts

@ -1084,7 +1084,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/pl.ts

@ -1086,7 +1086,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/ru.ts

@ -1087,7 +1087,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/tr.ts

@ -1084,7 +1084,7 @@ export default { @@ -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 { @@ -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.',

8
src/i18n/locales/zh.ts

@ -1081,7 +1081,7 @@ export default { @@ -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 { @@ -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.',

77
src/lib/discussion-topics.test.ts

@ -0,0 +1,77 @@ @@ -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[] { @@ -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<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
*/

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

@ -33,6 +33,21 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => { @@ -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)])

12
src/lib/relay-strikes.ts

@ -7,7 +7,7 @@ import { @@ -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 { @@ -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 { @@ -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)

41
src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts

@ -0,0 +1,41 @@ @@ -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)
})
})

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

@ -3,7 +3,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/h @@ -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' @@ -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 @@ -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<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> {
@ -66,15 +143,29 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: @@ -66,15 +143,29 @@ function raceWithTimeout<T>(promise: Promise<T>, 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<string, number>()
const kwHits = new Map<string, number>()
const accum = new Map<string, TopicKeyAccum>()
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( @@ -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<string>([...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) { @@ -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 (
<div className="flex min-h-0 flex-1 flex-col gap-4">
@ -302,16 +398,26 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { @@ -302,16 +398,26 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
onClick={() => openMergedFeed(row.key)}
aria-label={ariaLabel}
>
{row.pubkeys.length > 0 ? (
<TopicBubbleAvatarRing pubkeys={row.pubkeys} bubbleSizePx={size} />
) : (
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
)}
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
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">
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)}
</span>
</button>

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

@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button' @@ -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 { @@ -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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -58,7 +58,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const [controls, setControls] = useState<React.ReactNode>(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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -74,7 +74,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ 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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -83,13 +83,28 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ 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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -118,52 +133,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ 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<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) {
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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -172,7 +151,6 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ 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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -185,6 +163,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} else {
setControls(null)
}
return
}
@ -332,7 +312,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -332,7 +312,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
// Update controls when subscription status changes
useEffect(() => {
if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) {
if (data?.type === 'hashtag' && pubkey) {
setControls(
<Button
variant="ghost"
@ -349,7 +329,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -349,7 +329,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
useEffect(() => {
const inlineHeader =
hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag')
(data?.type === 'hashtag' || data?.type === 'dtag')
if (!hideTitlebar || inlineHeader) {
registerPrimaryPanelRefresh(null)
return
@ -376,6 +356,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -376,6 +356,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
oneShotMergedCap={400}
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} />
)
@ -399,7 +389,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -399,7 +389,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
displayScrollToTopButton
>
{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="flex items-center justify-between gap-2">

Loading…
Cancel
Save