Compare commits

..

7 Commits

  1. 4
      package-lock.json
  2. 2
      package.json
  3. 25
      src/components/NoteList/index.tsx
  4. 8
      src/components/NoteStats/LikeButton.tsx
  5. 2
      src/components/NoteStats/ReplyButton.tsx
  6. 4
      src/components/NoteStats/RepostButton.tsx
  7. 2
      src/components/NoteStats/ZapButton.tsx
  8. 4
      src/components/NoteStats/index.tsx
  9. 24
      src/components/PostEditor/PostEditorFormatToolbar.tsx
  10. 6
      src/components/ProfileListBySearch/index.tsx
  11. 8
      src/components/SearchBar/index.tsx
  12. 3
      src/components/SearchResult/index.tsx
  13. 1
      src/components/TooManyRelaysAlertDialog/index.tsx
  14. 2
      src/constants.ts
  15. 8
      src/i18n/locales/cs.ts
  16. 18
      src/i18n/locales/de.ts
  17. 8
      src/i18n/locales/en.ts
  18. 8
      src/i18n/locales/es.ts
  19. 8
      src/i18n/locales/fr.ts
  20. 8
      src/i18n/locales/nl.ts
  21. 8
      src/i18n/locales/pl.ts
  22. 8
      src/i18n/locales/ru.ts
  23. 8
      src/i18n/locales/tr.ts
  24. 8
      src/i18n/locales/zh.ts
  25. 77
      src/lib/discussion-topics.test.ts
  26. 47
      src/lib/discussion-topics.ts
  27. 50
      src/lib/feed-relay-urls.ts
  28. 2
      src/lib/metadata-policy-curated-relays.ts
  29. 17
      src/lib/mute-set.test.ts
  30. 19
      src/lib/mute-set.ts
  31. 28
      src/lib/relay-list-builder.test.ts
  32. 52
      src/lib/relay-list-builder.ts
  33. 10
      src/lib/relay-nip42-auth.ts
  34. 12
      src/lib/relay-publish-filter.test.ts
  35. 36
      src/lib/relay-publish-filter.ts
  36. 15
      src/lib/relay-strikes.test.ts
  37. 12
      src/lib/relay-strikes.ts
  38. 7
      src/lib/relay-thread-heat-cache.ts
  39. 16
      src/lib/relay-url-priority.test.ts
  40. 4
      src/lib/relay-url-priority.ts
  41. 24
      src/lib/url-relay-input.test.ts
  42. 37
      src/lib/url.ts
  43. 35
      src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts
  44. 74
      src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx
  45. 21
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  46. 62
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts
  47. 185
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  48. 25
      src/pages/primary/SpellsPage/fauxSpellFeeds.test.ts
  49. 52
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  50. 8
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  51. 88
      src/pages/secondary/NoteListPage/index.tsx
  52. 35
      src/providers/NostrProvider/index.tsx
  53. 4
      src/services/client-replaceable-events.service.ts
  54. 109
      src/services/client.service.ts
  55. 1
      src/services/gif.service.test.ts
  56. 7
      src/services/nip66.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.0", "version": "23.17.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.17.0", "version": "23.17.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.0", "version": "23.17.2",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

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/components/NoteStats/LikeButton.tsx

@ -244,13 +244,13 @@ export function LikeButtonWithStats({
const likeIconButton = ( const likeIconButton = (
<button <button
type="button" type="button"
className="flex h-full items-center gap-0.5 px-1.5 text-muted-foreground enabled:hover:text-primary" className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation"
title={t('Like')} title={t('Like')}
disabled={liking} disabled={liking}
onClick={openReactionPicker} onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji && !useIconOnlyLikeTrigger ? ( ) : myLastEmoji && !useIconOnlyLikeTrigger ? (
<Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} /> <Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
) : ( ) : (
@ -288,7 +288,7 @@ export function LikeButtonWithStats({
> >
<button <button
type="button" type="button"
className="flex h-full shrink-0 items-center px-1.5 sm:px-2 enabled:hover:text-primary" className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation"
title={emoji === '+' ? t('Upvote') : t('Downvote')} title={emoji === '+' ? t('Upvote') : t('Downvote')}
disabled={liking} disabled={liking}
onClick={() => { onClick={() => {
@ -296,7 +296,7 @@ export function LikeButtonWithStats({
}} }}
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
{arrow} {arrow}

2
src/components/NoteStats/ReplyButton.tsx

@ -40,7 +40,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
<> <>
<button <button
className={cn( className={cn(
'flex gap-1 items-center enabled:hover:text-blue-400 px-1.5 h-full', 'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation',
hasReplied ? 'text-blue-400' : 'text-muted-foreground' hasReplied ? 'text-blue-400' : 'text-muted-foreground'
)} )}
onClick={(e) => { onClick={(e) => {

4
src/components/NoteStats/RepostButton.tsx

@ -108,7 +108,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
<button <button
type="button" type="button"
className={cn( className={cn(
'flex h-full items-center enabled:hover:text-lime-500 px-1.5', 'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
title={t('Boost')} title={t('Boost')}
@ -118,7 +118,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
} }
}} }}
> >
{reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />} {reposting ? <Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> : <Repeat />}
</button> </button>
) )

2
src/components/NoteStats/ZapButton.tsx

@ -225,7 +225,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
<button <button
type="button" type="button"
className={cn( className={cn(
'group flex h-full items-center px-1.5', 'group flex h-full items-center px-2 touch-manipulation',
disable ? 'cursor-not-allowed' : 'cursor-pointer' disable ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
title={zapButtonTitle} title={zapButtonTitle}

4
src/components/NoteStats/index.tsx

@ -184,8 +184,8 @@ export default function NoteStats({
> >
<div <div
className={cn( className={cn(
'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-3 gap-y-1 sm:gap-x-4', 'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-6 gap-y-2 sm:gap-x-5',
'[&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5', '[&_svg]:size-5 [&_button]:min-h-11 [&_button]:max-w-full [&_button]:px-3 [&_button]:touch-manipulation sm:[&_button]:min-h-10 sm:[&_button]:px-2',
loading ? 'animate-pulse' : '', loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}

24
src/components/PostEditor/PostEditorFormatToolbar.tsx

@ -3,7 +3,7 @@ import GifPicker from '@/components/GifPicker'
import MemePicker from '@/components/MemePicker' import MemePicker from '@/components/MemePicker'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { cn, isTouchDevice } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TEmoji } from '@/types' import type { TEmoji } from '@/types'
import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react' import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -85,18 +85,16 @@ export function PostEditorFormatToolbar({
</Button> </Button>
</Uploader> </Uploader>
<Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" /> <Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" />
{!isTouchDevice() && ( <EmojiPickerDialog
<EmojiPickerDialog onEmojiClick={(emoji) => {
onEmojiClick={(emoji) => { if (emoji == null) return
if (emoji == null) return insertEmoji(emoji)
insertEmoji(emoji) }}
}} >
> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert emoji')}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert emoji')}> <Smile />
<Smile /> </Button>
</Button> </EmojiPickerDialog>
</EmojiPickerDialog>
)}
<GifPicker onSelect={(gifUrl) => insertText(gifUrl)}> <GifPicker onSelect={(gifUrl) => insertText(gifUrl)}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert GIF')}> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert GIF')}>
<Film className="h-4 w-4" /> <Film className="h-4 w-4" />

6
src/components/ProfileListBySearch/index.tsx

@ -1,8 +1,8 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PROFILE_RELAY_URLS } from '@/constants' import { PROFILE_RELAY_URLS } from '@/constants'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -13,9 +13,7 @@ import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSea
const LIMIT = 50 const LIMIT = 50
const PROFILE_SEARCH_RELAY_URLS = Array.from( const PROFILE_SEARCH_RELAY_URLS = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean))
)
export function ProfileListBySearch({ export function ProfileListBySearch({
search, search,

8
src/components/SearchBar/index.tsx

@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { isKind10243HttpRelayTagUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import { isKind10243HttpRelayTagUrl, isWebsocketUrl, looksLikeNostrBech32Identifier, looksLikeRelayUrlInput, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser' import { normalizeToDTag } from '@/lib/search-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
@ -55,8 +55,12 @@ const SearchBar = forwardRef<
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
return undefined return undefined
} }
const trimmed = input.trim()
if (!trimmed || looksLikeNostrBech32Identifier(trimmed) || !looksLikeRelayUrlInput(trimmed)) {
return undefined
}
try { try {
const n = normalizeAnyRelayUrl(input) || normalizeHttpRelayUrl(input) const n = normalizeAnyRelayUrl(trimmed) || normalizeHttpRelayUrl(trimmed)
if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined
return n return n
} catch { } catch {

3
src/components/SearchResult/index.tsx

@ -33,7 +33,8 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
) { ) {
return return
} }
client.interruptBackgroundQueries({ closePooledRelayConnections: true }) /** Yield pool capacity to search REQs without closing in-flight NIP-50 sockets (that zeroed results). */
client.interruptBackgroundQueries()
}, [searchParams?.type, searchParams?.search, searchParams?.input]) }, [searchParams?.type, searchParams?.search, searchParams?.input])
/** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */ /** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */

1
src/components/TooManyRelaysAlertDialog/index.tsx

@ -17,6 +17,7 @@ import {
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { MAILBOX_RELAY_COUNT_WARNING_THRESHOLD } from '@/constants' import { MAILBOX_RELAY_COUNT_WARNING_THRESHOLD } from '@/constants'
import { toRelaySettings } from '@/lib/link'
import { useSecondaryPage } from '@/contexts/secondary-page-context' import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'

2
src/constants.ts

@ -471,7 +471,7 @@ export const READ_ONLY_RELAY_URLS = [
'wss://filter.nostr.wine', 'wss://filter.nostr.wine',
'wss://primus.nostr1.com', 'wss://primus.nostr1.com',
'wss://feeds.nostrarchives.com', 'wss://feeds.nostrarchives.com',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today' 'wss://spatia-arcana.com'
] ]
/** /**

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
*/ */

50
src/lib/feed-relay-urls.ts

@ -65,3 +65,53 @@ export function pinHttpIndexRelaysInRelayCap(
return out.slice(0, maxRelays) return out.slice(0, maxRelays)
} }
/**
* Keep global mention / read aggregators in a capped stack (notifications `#p` REQs).
* Long NIP-65 lists otherwise fill {@link FAUX_SPELL_MAX_RELAYS} before index relays are reached.
*/
export function pinMentionRelaysInRelayCap(
capped: readonly string[],
mentionSources: readonly string[],
maxRelays: number,
minPinned: number
): string[] {
const pinKeys = new Set(
mentionSources
.slice(0, Math.max(0, minPinned))
.map((u) => relayDedupeKey(u))
.filter(Boolean)
)
if (pinKeys.size === 0) return [...capped]
const mentionKeySet = new Set(mentionSources.map((u) => relayDedupeKey(u)).filter(Boolean))
const out = [...capped]
const outKeys = new Set(out.map(relayDedupeKey))
for (const raw of mentionSources) {
const key = relayDedupeKey(raw)
if (!key || outKeys.has(key)) continue
while (out.length >= maxRelays) {
let dropped = false
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i]!
const ck = relayDedupeKey(candidate)
if (pinKeys.has(ck) || mentionKeySet.has(ck)) continue
out.splice(i, 1)
outKeys.delete(ck)
dropped = true
break
}
if (!dropped) break
}
if (out.length >= maxRelays) continue
out.push(raw)
outKeys.add(key)
pinKeys.add(key)
if ([...pinKeys].every((k) => outKeys.has(k))) break
}
return out.slice(0, maxRelays)
}

2
src/lib/metadata-policy-curated-relays.ts

@ -1,6 +1,7 @@
import { import {
BOOKSTR_RELAY_URLS, BOOKSTR_RELAY_URLS,
DOCUMENT_RELAY_URLS, DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS,
FOLLOWS_HISTORY_RELAY_URLS, FOLLOWS_HISTORY_RELAY_URLS,
GIF_RELAY_URLS, GIF_RELAY_URLS,
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS,
@ -46,6 +47,7 @@ function relayKeyForCuratedSet(url: string): string {
/** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */ /** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */
const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [ const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [
...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS, ...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS,
FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS, SEARCHABLE_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
NIP66_DISCOVERY_RELAY_URLS NIP66_DISCOVERY_RELAY_URLS

17
src/lib/mute-set.test.ts

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import { filterEventsExcludingMutedAuthors } from './mute-set'
describe('filterEventsExcludingMutedAuthors', () => {
it('drops events from muted pubkeys', () => {
const muted = 'a'.repeat(64)
const other = 'b'.repeat(64)
const events = [
{ kind: kinds.ShortTextNote, pubkey: muted, id: '1'.repeat(64), sig: 's', tags: [], content: '', created_at: 1 },
{ kind: kinds.ShortTextNote, pubkey: other, id: '2'.repeat(64), sig: 's', tags: [], content: '', created_at: 1 }
]
const out = filterEventsExcludingMutedAuthors(events, new Set([muted]))
expect(out).toHaveLength(1)
expect(out[0]?.pubkey).toBe(other)
})
})

19
src/lib/mute-set.ts

@ -1,7 +1,24 @@
import type { Event } from 'nostr-tools'
/** /**
* Mute pubkey sets use lowercase hex so lookups match Nostr events and `p` tags regardless of casing. * Mute pubkey sets use lowercase hex so lookups match Nostr events and `p` tags regardless of casing.
*/ */
export function muteSetHas(mutePubkeySet: Set<string>, pubkey: string | undefined | null): boolean { export function muteSetHas(mutePubkeySet: ReadonlySet<string>, pubkey: string | undefined | null): boolean {
if (!pubkey) return false if (!pubkey) return false
return mutePubkeySet.has(pubkey.toLowerCase()) return mutePubkeySet.has(pubkey.toLowerCase())
} }
/** Drop notes whose author is in the viewer's public or private mute list. */
export function filterEventsExcludingMutedAuthors(
events: readonly Event[],
mutePubkeySet: ReadonlySet<string>
): Event[] {
if (mutePubkeySet.size === 0) return [...events]
return events.filter((ev) => !muteSetHas(mutePubkeySet, ev.pubkey))
}
/** Stable SETTINGS / cache segment when mute lists change. */
export function mutePubkeySetFingerprint(mutePubkeySet: ReadonlySet<string>): string {
if (mutePubkeySet.size === 0) return '0'
return [...mutePubkeySet].sort().join('\n')
}

28
src/lib/relay-list-builder.test.ts

@ -1,5 +1,31 @@
import { PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildReplyReadRelayList } from '@/lib/relay-list-builder' import {
buildAccountSessionNetworkHydrateRelayUrls,
buildReplyReadRelayList
} from '@/lib/relay-list-builder'
import { kinds } from 'nostr-tools'
describe('buildAccountSessionNetworkHydrateRelayUrls', () => {
it('uses personal mailbox and profile index relays, not FAST_READ', () => {
const relayListEvent = {
id: 'a',
pubkey: 'b'.repeat(64),
created_at: 1,
kind: kinds.RelayList,
tags: [
['r', 'wss://relay.example.com/', 'read'],
['r', 'wss://relay.example.com/', 'write']
],
content: '',
sig: 'c'.repeat(128)
}
const urls = buildAccountSessionNetworkHydrateRelayUrls({ relayListEvent })
expect(urls).toContain('wss://relay.example.com/')
expect(urls.some((u) => PROFILE_RELAY_URLS.includes(u))).toBe(true)
expect(urls.some((u) => u.includes('theforest.nostr1.com'))).toBe(false)
})
})
describe('buildReplyReadRelayList relayAuthoritative', () => { describe('buildReplyReadRelayList relayAuthoritative', () => {
it('returns only thread hints and author/user layers without favorite bootstrap', async () => { it('returns only thread hints and author/user layers without favorite bootstrap', async () => {

52
src/lib/relay-list-builder.ts

@ -10,6 +10,7 @@
*/ */
import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { isRelayBlockedByUser } from '@/lib/relay-blocked'
@ -33,6 +34,57 @@ import type { Event } from 'nostr-tools'
/** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */ /** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */
export const AUTHOR_NIP65_RELAY_CAP = 2 export const AUTHOR_NIP65_RELAY_CAP = 2
/**
* Relays for logged-in account session network hydrate (NostrProvider).
* Uses the viewer's cached mailbox / favorites plus {@link PROFILE_RELAY_URLS} not {@link FAST_READ_RELAY_URLS},
* which are blocked under the personal-relay read policy and caused empty/slow startup merges.
*/
export function buildAccountSessionNetworkHydrateRelayUrls(options: {
relayListEvent?: Event | null
cacheRelayListEvent?: Event | null
httpRelayListEvent?: Event | null
favoriteRelaysEvent?: Event | null
blockedRelays?: string[]
cap?: number
}): string[] {
const blocked = options.blockedRelays ?? []
const seen = new Set<string>()
const out: string[] = []
const push = (raw: string | undefined) => {
if (!raw) return
const n = normalizeAnyRelayUrl(raw) || normalizeUrl(raw) || raw.trim()
if (!n) return
const key = relayKey(n)
if (!key || seen.has(key)) return
seen.add(key)
out.push(n)
}
if (options.relayListEvent) {
const rl = getRelayListFromEvent(options.relayListEvent, blocked)
for (const u of [...rl.read, ...rl.write, ...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])]) {
push(u)
}
}
if (options.cacheRelayListEvent) {
const crl = getRelayListFromEvent(options.cacheRelayListEvent)
for (const u of [...crl.read, ...crl.write]) push(u)
}
if (options.httpRelayListEvent) {
const hrl = getHttpRelayListFromEvent(options.httpRelayListEvent, blocked)
for (const u of [...hrl.httpRead, ...hrl.httpWrite]) push(u)
}
if (options.favoriteRelaysEvent) {
for (const [tag, val] of options.favoriteRelaysEvent.tags) {
if (tag === 'relay' && val) push(val)
}
}
for (const u of PROFILE_RELAY_URLS) push(u)
const cap = options.cap ?? 16
return out.slice(0, cap)
}
function relayKey(url: string): string { function relayKey(url: string): string {
return canonicalRelaySessionKey(url) return canonicalRelaySessionKey(url)
} }

10
src/lib/relay-nip42-auth.ts

@ -19,6 +19,16 @@ export function isRelayAuthRequiredErrorMessage(message: string): boolean {
return /auth-required/i.test(message) return /auth-required/i.test(message)
} }
/** Socket dropped between AUTH and EVENT, or nostr-tools {@link SendingOnClosedConnection}. */
export function isRelayConnectionClosedError(err: unknown): boolean {
if (err != null && typeof err === 'object' && 'name' in err) {
const name = String((err as { name: unknown }).name)
if (name === 'SendingOnClosedConnection') return true
}
const msg = err instanceof Error ? err.message : String(err)
return /SendingOnClosedConnection|on a closed connection|relay connection closed|websocket closed/i.test(msg)
}
/** nostr-tools default when {@link Subscription.close} runs from the client. */ /** nostr-tools default when {@link Subscription.close} runs from the client. */
export function isRelaySubscriptionClosedByCaller(reason: string): boolean { export function isRelaySubscriptionClosedByCaller(reason: string): boolean {
return reason.trim() === 'closed by caller' return reason.trim() === 'closed by caller'

12
src/lib/relay-publish-filter.test.ts

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { import {
filterContextAuthorReadRelaysForPublish, filterContextAuthorReadRelaysForPublish,
filterRelaysForEventPublish, filterRelaysForEventPublish,
isReadOnlyRelayUrl,
isRelayPublishPolicyRejection, isRelayPublishPolicyRejection,
relayAllowsPublishKind relayAllowsPublishKind
} from './relay-publish-filter' } from './relay-publish-filter'
@ -32,6 +33,17 @@ describe('relay-publish-filter', () => {
expect(out).toEqual(['wss://relay.primal.net/']) expect(out).toEqual(['wss://relay.primal.net/'])
}) })
it('strips filter.nostr.wine broadcast paths (hostname match on READ_ONLY_RELAY_URLS)', () => {
const broadcast =
'wss://filter.nostr.wine/npub13epj452d892app3mjath3uxgs9l03rylzxwkymdp50avukztmfeschauwt?broadcast=true'
expect(isReadOnlyRelayUrl(broadcast)).toBe(true)
const out = filterRelaysForEventPublish(
['wss://relay.damus.io/', broadcast],
kinds.Reaction
)
expect(out).toEqual(['wss://relay.damus.io/'])
})
it('strips profile mirrors from author read hints', () => { it('strips profile mirrors from author read hints', () => {
const out = filterContextAuthorReadRelaysForPublish([ const out = filterContextAuthorReadRelaysForPublish([
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',

36
src/lib/relay-publish-filter.ts

@ -16,6 +16,20 @@ export const PROFILE_INDEX_ONLY_RELAY_URLS = [
'wss://indexer.coracle.social/' 'wss://indexer.coracle.social/'
] as const ] as const
function relayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
function relayHostname(url: string): string | null {
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return null
try {
return new URL(normalized).hostname.toLowerCase()
} catch {
return null
}
}
const profileIndexOnlyKeySet = new Set( const profileIndexOnlyKeySet = new Set(
PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean) PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
) )
@ -24,20 +38,30 @@ const readOnlyKeySet = new Set(
READ_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean) READ_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
) )
const profileIndexOnlyHostSet = new Set(
PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => relayHostname(u)).filter((h): h is string => !!h)
)
const readOnlyHostSet = new Set(
READ_ONLY_RELAY_URLS.map((u) => relayHostname(u)).filter((h): h is string => !!h)
)
const profileIndexPublishKindSet = new Set<number>(AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS) const profileIndexPublishKindSet = new Set<number>(AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS)
function relayKey(url: string): string { /** True when `url` matches a known entry exactly or shares its hostname (e.g. filter.nostr.wine/npub… paths). */
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() function relayMatchesHostOrExact(url: string, keySet: ReadonlySet<string>, hostSet: ReadonlySet<string>): boolean {
const key = relayKey(url)
if (key.length > 0 && keySet.has(key)) return true
const host = relayHostname(url)
return host != null && hostSet.has(host)
} }
export function isProfileIndexOnlyRelay(url: string): boolean { export function isProfileIndexOnlyRelay(url: string): boolean {
const key = relayKey(url) return relayMatchesHostOrExact(url, profileIndexOnlyKeySet, profileIndexOnlyHostSet)
return key.length > 0 && profileIndexOnlyKeySet.has(key)
} }
export function isReadOnlyRelayUrl(url: string): boolean { export function isReadOnlyRelayUrl(url: string): boolean {
const key = relayKey(url) return relayMatchesHostOrExact(url, readOnlyKeySet, readOnlyHostSet)
return key.length > 0 && readOnlyKeySet.has(key)
} }
/** True when this relay may receive an EVENT for `eventKind` (profile/list replaceables only on profile mirrors). */ /** True when this relay may receive an EVENT for `eventKind` (profile/list replaceables only on profile mirrors). */

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)

7
src/lib/relay-thread-heat-cache.ts

@ -26,7 +26,9 @@ export function relayThreadHeatMapSettingKey(
relayUrls: readonly string[], relayUrls: readonly string[],
followPubkeys: readonly string[], followPubkeys: readonly string[],
/** Serialized home kind-picker state so cache invalidates when feed filters change. */ /** Serialized home kind-picker state so cache invalidates when feed filters change. */
feedFilterKey: string feedFilterKey: string,
/** Sorted mute pubkeys so cache invalidates when mutes change. */
muteFingerprint: string
): string { ): string {
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n')) const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n'))
@ -38,7 +40,8 @@ export function relayThreadHeatMapSettingKey(
.join('\n') .join('\n')
) )
const feedKey = digestHeatMapKeyPart(feedFilterKey) const feedKey = digestHeatMapKeyPart(feedFilterKey)
return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}` const muteKey = digestHeatMapKeyPart(muteFingerprint)
return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}:${muteKey}`
} }
export function parseRelayThreadHeatMapCache(raw: string | null): TRelayThreadHeatMapCacheEnvelope | null { export function parseRelayThreadHeatMapCache(raw: string | null): TRelayThreadHeatMapCacheEnvelope | null {

16
src/lib/relay-url-priority.test.ts

@ -8,6 +8,22 @@ import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
describe('dedupeNormalizeRelayUrlsOrdered', () => {
it('drops npub, nevent, and other non-relay strings', () => {
const npub = 'npub1uq6dv4yq94704gk5r22jsqg9gy2wpxkk5dft9q5gugc8tj53nq2qg5q22d'
const nevent =
'nevent1qvzqqqqqqypzpcp56e2gqttul23dgx549qqs2sg5uzdddg6jk2pg3c3swh9frxq5qqsx3aamhhkjej4jhn0v7693j6hj08mpp7j87w2mt8vdnjja60t04rgalkuxh'
expect(
dedupeNormalizeRelayUrlsOrdered([
'wss://relay.example.com/',
npub,
nevent,
'not-a-relay'
])
).toEqual(['wss://relay.example.com/'])
})
})
describe('filterContextAuthorReadRelaysForPublish', () => { describe('filterContextAuthorReadRelaysForPublish', () => {
it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => { it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => {
const out = filterContextAuthorReadRelaysForPublish([ const out = filterContextAuthorReadRelaysForPublish([

4
src/lib/relay-url-priority.ts

@ -7,6 +7,7 @@ import {
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { import {
isLocalNetworkUrl, isLocalNetworkUrl,
isValidRelayFetchUrl,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
normalizeRelayUrlByScheme, normalizeRelayUrlByScheme,
normalizeUrl normalizeUrl
@ -18,7 +19,8 @@ export function dedupeNormalizeRelayUrlsOrdered(urls: readonly string[]): string
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of urls) { for (const u of urls) {
const n = normalizeRelayUrlByScheme(u) || u.trim() if (!isValidRelayFetchUrl(u)) continue
const n = normalizeRelayUrlByScheme(u)
if (!n || seen.has(n)) continue if (!n || seen.has(n)) continue
seen.add(n) seen.add(n)
out.push(n) out.push(n)

24
src/lib/url-relay-input.test.ts

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { looksLikeRelayUrlInput } from '@/lib/url'
describe('looksLikeRelayUrlInput', () => {
it('rejects profile names and partial username typing', () => {
for (const name of ['N', 'Nu', 'Nus', 'Nusa', 'alice', '@bob']) {
expect(looksLikeRelayUrlInput(name)).toBe(false)
}
})
it('accepts relay URL shapes', () => {
expect(looksLikeRelayUrlInput('wss://relay.example.com/')).toBe(true)
expect(looksLikeRelayUrlInput('ws://localhost:4869/')).toBe(true)
expect(looksLikeRelayUrlInput('https://index.example.com/')).toBe(true)
expect(looksLikeRelayUrlInput('relay.nostr1.com')).toBe(true)
expect(looksLikeRelayUrlInput('nostr.wine')).toBe(true)
})
it('rejects bech32 identifiers', () => {
expect(
looksLikeRelayUrlInput('npub1uq6dv4yq94704gk5r22jsqg9gy2wpxkk5dft9q5gugc8tj53nq2qg5q22d')
).toBe(false)
})
})

37
src/lib/url.ts

@ -33,6 +33,39 @@ export function isKind10243HttpRelayTagUrl(url: string): boolean {
return /^https?:\/\/.+/i.test(u) return /^https?:\/\/.+/i.test(u)
} }
/** Bech32 nostr identifiers (npub, nevent, …) — not relay URLs. */
export function looksLikeNostrBech32Identifier(value: string): boolean {
const v = value.trim().replace(/^nostr:/i, '').trim()
if (!v) return false
if (/^[0-9a-f]{64}$/i.test(v)) return true
return /^(npub|nprofile|nevent|note|naddr)1[a-z0-9]+$/i.test(v)
}
/**
* True when free-text input plausibly targets a relay URL (scheme, `://`, or hostname shape).
* Usernames, hashtags, and partial profile names must not trigger relay normalization.
*/
export function looksLikeRelayUrlInput(value: string): boolean {
const v = value.trim()
if (!v || looksLikeNostrBech32Identifier(v)) return false
if (/^(wss?|https?):?\/?/i.test(v)) return true
if (v.includes('://')) return true
// hostname.tld — e.g. relay.example.com, nostr.wine (not bare names like "Nusa")
if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:[:/].*)?$/i.test(v)) return true
return false
}
/** True when normalized to a WebSocket relay or kind-10243 HTTP index base. */
export function isValidRelayFetchUrl(url: string): boolean {
const trimmed = url.trim()
if (!trimmed || looksLikeNostrBech32Identifier(trimmed)) return false
if (isKind10243HttpRelayTagUrl(trimmed)) {
return Boolean(normalizeHttpRelayUrl(trimmed))
}
const ws = normalizeUrl(trimmed)
return Boolean(ws && isWebsocketUrl(ws))
}
/** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */ /** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */
export function isHttpRelayUrl(url: string): boolean { export function isHttpRelayUrl(url: string): boolean {
return isKind10243HttpRelayTagUrl(url) return isKind10243HttpRelayTagUrl(url)
@ -216,7 +249,9 @@ export function normalizeUrl(url: string): string {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return '' if (!trimmed) return ''
if (!trimmed.includes('://')) { if (!trimmed.includes('://')) {
logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed }) if (!looksLikeNostrBech32Identifier(trimmed) && looksLikeRelayUrlInput(trimmed)) {
logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed })
}
return '' return ''
} }

35
src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import { mergeInteractionEvents } from './ProfileInteractionsMap'
function interaction(pubkey: string, pTags: string[]) {
return {
kind: kinds.ShortTextNote,
pubkey,
tags: pTags.map((p) => ['p', p]),
content: '',
id: `${pubkey.slice(0, 8)}${'c'.repeat(56)}`,
sig: 's'.repeat(128),
created_at: 1_700_000_000
}
}
describe('mergeInteractionEvents', () => {
it('excludes muted partners and events authored by muted pubkeys', () => {
const profile = 'a'.repeat(64)
const partner = 'b'.repeat(64)
const muted = 'f'.repeat(64)
const cards = mergeInteractionEvents(
profile,
[
interaction(profile, [partner]),
interaction(profile, [muted]),
interaction(muted, [profile]),
interaction(partner, [profile])
],
new Set([muted])
)
expect(cards.map((c) => c.pubkey)).toEqual([partner])
expect(cards[0]?.score).toBe(2)
})
})

74
src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx

@ -4,7 +4,9 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useMuteList } from '@/contexts/mute-list-context'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { muteSetHas } from '@/lib/mute-set'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -57,12 +59,18 @@ function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[]
] ]
} }
function mergeInteractionEvents(targetPubkey: string, events: Event[]): InteractionCard[] { export function mergeInteractionEvents(
targetPubkey: string,
events: Event[],
mutePubkeySet: ReadonlySet<string>
): InteractionCard[] {
const target = targetPubkey.toLowerCase() const target = targetPubkey.toLowerCase()
const byPubkey = new Map<string, InteractionCard>() const byPubkey = new Map<string, InteractionCard>()
const add = (partnerRaw: string | undefined, event: Event, direction: 'out' | 'in') => { const add = (partnerRaw: string | undefined, event: Event, direction: 'out' | 'in') => {
if (muteSetHas(mutePubkeySet, event.pubkey)) return
const partner = partnerRaw?.trim().toLowerCase() const partner = partnerRaw?.trim().toLowerCase()
if (!partner || partner === target || !/^[0-9a-f]{64}$/.test(partner)) return if (!partner || partner === target || !/^[0-9a-f]{64}$/.test(partner)) return
if (muteSetHas(mutePubkeySet, partner)) return
let row = byPubkey.get(partner) let row = byPubkey.get(partner)
if (!row) { if (!row) {
row = { row = {
@ -112,6 +120,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList, cacheRelayListEvent } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { mutePubkeySet } = useMuteList()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [cards, setCards] = useState<InteractionCard[]>([]) const [cards, setCards] = useState<InteractionCard[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -168,12 +177,12 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
try { try {
const local = await load(false) const local = await load(false)
if (cancelled) return if (cancelled) return
setCards(mergeInteractionEvents(pubkey, local)) setCards(mergeInteractionEvents(pubkey, local, mutePubkeySet))
setLoading(false) setLoading(false)
const all = await load(true) const all = await load(true)
if (cancelled) return if (cancelled) return
setCards(mergeInteractionEvents(pubkey, all)) setCards(mergeInteractionEvents(pubkey, all, mutePubkeySet))
} catch (e) { } catch (e) {
if (cancelled) return if (cancelled) return
setError(e instanceof Error ? e.message : String(e)) setError(e instanceof Error ? e.message : String(e))
@ -187,7 +196,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [pubkey, refreshKey, load]) }, [pubkey, refreshKey, load, mutePubkeySet])
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">
@ -203,7 +212,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
onClick={() => { onClick={() => {
setRefreshing(true) setRefreshing(true)
void load(true) void load(true)
.then((rows) => setCards(mergeInteractionEvents(pubkey, rows))) .then((rows) => setCards(mergeInteractionEvents(pubkey, rows, mutePubkeySet)))
.catch((e) => setError(e instanceof Error ? e.message : String(e))) .catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setRefreshing(false)) .finally(() => setRefreshing(false))
}} }}
@ -221,7 +230,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
{loading && cards.length === 0 ? ( {loading && cards.length === 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => ( {Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" /> <Skeleton key={i} className="h-44 rounded-xl" />
))} ))}
</div> </div>
) : error && cards.length === 0 ? ( ) : error && cards.length === 0 ? (
@ -234,7 +243,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
</div> </div>
) : ( ) : (
<div className="min-h-0 flex-1 overflow-y-auto pb-4"> <div className="min-h-0 flex-1 overflow-y-auto pb-4">
<div className="grid grid-cols-1 gap-2 min-[720px]:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 min-[720px]:grid-cols-2 xl:grid-cols-3">
{cards.map((card, index) => ( {cards.map((card, index) => (
<button <button
key={card.pubkey} key={card.pubkey}
@ -244,38 +253,39 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
> >
<Card <Card
className={cn( className={cn(
'flex h-full min-w-0 items-center gap-2 p-2 transition-colors hover:bg-accent/70 min-[720px]:gap-3 min-[720px]:p-3', 'flex h-full min-w-0 flex-col overflow-hidden p-3 transition-colors hover:bg-accent/70',
index < 3 && 'border-primary/40 bg-primary/5' index < 3 && 'border-primary/40 bg-primary/5'
)} )}
> >
<div className="relative shrink-0"> <div className="flex min-w-0 items-start justify-between gap-2">
<UserAvatar userId={card.pubkey} size="semiBig" className="min-[720px]:h-16 min-[720px]:w-16" /> <div className="min-w-0 flex-1">
<span className="absolute -bottom-1 -right-1 z-10 rounded-full bg-background px-1.5 py-0.5 text-[10px] font-semibold shadow ring-1 ring-border"> <Username userId={card.pubkey} className="block truncate text-sm font-semibold" />
<div className="truncate text-xs text-muted-foreground">{formatPubkey(card.pubkey)}</div>
</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold text-foreground ring-1 ring-border">
#{index + 1} #{index + 1}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1">
<Username userId={card.pubkey} className="block truncate text-sm font-semibold" /> <div className="mt-2 flex min-w-0 flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<div className="truncate text-xs text-muted-foreground">{formatPubkey(card.pubkey)}</div> <span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5 font-medium text-foreground">
<div className="mt-1.5 flex min-w-0 flex-wrap gap-1 text-[11px] text-muted-foreground min-[720px]:mt-2 min-[720px]:gap-1.5 min-[720px]:text-xs"> <UserRound className="mr-1 inline size-3 shrink-0" aria-hidden />
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-foreground"> {t('n interactions', { count: card.score, formattedCount: compactCount(card.score) })}
<UserRound className="mr-1 inline size-3" aria-hidden /> </span>
<span className="min-[720px]:hidden">{compactCount(card.score)}</span> {card.authoredByProfile > 0 ? (
<span className="hidden min-[720px]:inline"> <span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5">
{t('n interactions', { count: card.score, formattedCount: compactCount(card.score) })} {t('outgoing interactions', { count: card.authoredByProfile })}
</span>
</span> </span>
{card.authoredByProfile > 0 ? ( ) : null}
<span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline"> {card.mentionsProfile > 0 ? (
{t('outgoing interactions', { count: card.authoredByProfile })} <span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5">
</span> {t('incoming interactions', { count: card.mentionsProfile })}
) : null} </span>
{card.mentionsProfile > 0 ? ( ) : null}
<span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline"> </div>
{t('incoming interactions', { count: card.mentionsProfile })}
</span> <div className="mt-3 flex justify-center">
) : null} <UserAvatar userId={card.pubkey} size="big" className="size-16 shrink-0 sm:size-20" />
</div>
</div> </div>
</Card> </Card>
</button> </button>

21
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -4,6 +4,7 @@ import { SimpleUserAvatar } from '@/components/UserAvatar'
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 { filterEventsExcludingMutedAuthors, mutePubkeySetFingerprint, muteSetHas } from '@/lib/mute-set'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -22,6 +23,7 @@ import {
type TRelayThreadHeatEdge type TRelayThreadHeatEdge
} from '@/lib/relay-thread-heat' } from '@/lib/relay-thread-heat'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useMuteList } from '@/contexts/mute-list-context'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { encodeProfileInteractionsSpellId } from './fauxSpellConfig' import { encodeProfileInteractionsSpellId } from './fauxSpellConfig'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -101,6 +103,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const { navigate: navigatePrimary } = usePrimaryPage() const { navigate: navigatePrimary } = usePrimaryPage()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { pubkey, relayList, cacheRelayListEvent } = useNostr() const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const { mutePubkeySet } = useMuteList()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
@ -142,9 +145,14 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [rescanTick, setRescanTick] = useState(0) const [rescanTick, setRescanTick] = useState(0)
const muteFingerprint = useMemo(() => mutePubkeySetFingerprint(mutePubkeySet), [mutePubkeySet])
const cacheSettingKey = useMemo( const cacheSettingKey = useMemo(
() => (pubkey ? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey) : ''), () =>
[pubkey, relayUrls, followPubkeys, feedFilterKey] pubkey
? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey, muteFingerprint)
: '',
[pubkey, relayUrls, followPubkeys, feedFilterKey, muteFingerprint]
) )
const mergeHeatMapData = useCallback(async (includeRelay = true): Promise<{ const mergeHeatMapData = useCallback(async (includeRelay = true): Promise<{
@ -204,7 +212,10 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
dedup.set(ev.id.toLowerCase(), ev) dedup.set(ev.id.toLowerCase(), ev)
} }
} }
const merged = filterEventsExcludingTombstones([...dedup.values()], tombstones) const merged = filterEventsExcludingMutedAuthors(
filterEventsExcludingTombstones([...dedup.values()], tombstones),
mutePubkeySet
)
const feedNotes = merged.filter((e) => const feedNotes = merged.filter((e) =>
eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111) eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111)
) )
@ -227,6 +238,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
for (const ev of archived) { for (const ev of archived) {
if (!verifyEvent(ev)) continue if (!verifyEvent(ev)) continue
if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue
if (muteSetHas(mutePubkeySet, ev.pubkey)) continue
rootById.set(ev.id.toLowerCase(), ev) rootById.set(ev.id.toLowerCase(), ev)
} }
const stillMissing = missingRootIds.filter((id) => !rootById.has(id)) const stillMissing = missingRootIds.filter((id) => !rootById.has(id))
@ -244,6 +256,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
for (const ev of fetched) { for (const ev of fetched) {
if (!verifyEvent(ev)) continue if (!verifyEvent(ev)) continue
if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue
if (muteSetHas(mutePubkeySet, ev.pubkey)) continue
rootById.set(ev.id.toLowerCase(), ev) rootById.set(ev.id.toLowerCase(), ev)
} }
} }
@ -270,7 +283,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
edges: edges.length edges: edges.length
}) })
return { bubbles, edges } return { bubbles, edges }
}, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111]) }, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false

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

@ -0,0 +1,62 @@
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)
})
it('excludes muted authors from counts and bubble avatars', () => {
const pkA = 'a'.repeat(64)
const pkMuted = 'f'.repeat(64)
const pkB = 'b'.repeat(64)
const bubbles = buildTopicKeywordBubbles(
[
note(pkA, [['t', 'nostr']]),
note(pkMuted, [['t', 'nostr']], 'muted #nostr'),
note(pkB, [['t', 'nostr']])
],
DEFAULT_FEED_SHOW_KINDS,
true,
true,
true,
new Set([pkMuted])
)
const nostr = bubbles.find((b) => b.key === 'nostr')
expect(nostr?.score).toBe(2)
expect(nostr?.pubkeys).not.toContain(pkMuted)
})
})

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

@ -1,9 +1,11 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useMuteList } from '@/contexts/mute-list-context'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingMutedAuthors, muteSetHas } from '@/lib/mute-set'
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 +16,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 +35,93 @@ 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,
mutePubkeySet?: ReadonlySet<string>
): string[] {
return [...hits.entries()]
.filter(([pk]) => !mutePubkeySet || !muteSetHas(mutePubkeySet, pk))
.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,43 +150,59 @@ 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,
mutePubkeySet?: ReadonlySet<string>
): 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 (mutePubkeySet && muteSetHas(mutePubkeySet, ev.pubkey)) continue
if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue
const topics = new Set<string>() const topics = new Set<string>()
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, mutePubkeySet)
})
} }
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)
@ -114,6 +214,7 @@ type Props = {
export default function TopicKeywordHeatMap({ refreshKey }: Props) { export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToHashtag } = useSmartHashtagNavigation()
const { relayList, cacheRelayListEvent } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -185,9 +286,12 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
dedup.set(ev.id.toLowerCase(), ev) dedup.set(ev.id.toLowerCase(), ev)
} }
} }
const clean = filterEventsExcludingTombstones([...dedup.values()], tombstones) const clean = filterEventsExcludingMutedAuthors(
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111) filterEventsExcludingTombstones([...dedup.values()], tombstones),
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111]) mutePubkeySet
)
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet)
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -221,17 +325,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,16 +411,26 @@ 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
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 <span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35" className={cn(
style={{ 'pointer-events-none absolute inset-x-1 bottom-1.5 z-[1] rounded-md px-1 py-0.5',
width: `${22 + intensity * 48}%`, 'text-pretty text-center text-[10px] font-semibold leading-tight text-foreground sm:text-xs',
height: `${22 + intensity * 48}%`, 'bg-background/75 backdrop-blur-[2px] shadow-sm'
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">
{displayLabel(row.key)} {displayLabel(row.key)}
</span> </span>
</button> </button>

25
src/pages/primary/SpellsPage/fauxSpellFeeds.test.ts

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import {
buildNotificationSpellRelayUrls,
FAUX_SPELL_MAX_RELAYS,
notificationMentionIndexRelayUrls
} from './fauxSpellFeeds'
describe('buildNotificationSpellRelayUrls', () => {
it('pins mention index relays even when personal inbox fills the cap', () => {
const personal = Array.from({ length: 12 }, (_, i) => `wss://personal-inbox-${i}.example/`)
const out = buildNotificationSpellRelayUrls(personal)
expect(out.length).toBeLessThanOrEqual(FAUX_SPELL_MAX_RELAYS)
const mentionKeys = new Set(
notificationMentionIndexRelayUrls().map((u) => u.replace(/\/$/, '').toLowerCase())
)
const pinned = out.filter((u) => mentionKeys.has(u.replace(/\/$/, '').toLowerCase()))
expect(pinned.length).toBeGreaterThanOrEqual(3)
})
it('returns mention index relays when personal stack is empty', () => {
const out = buildNotificationSpellRelayUrls([])
expect(out.length).toBeGreaterThan(0)
expect(out.some((u) => u.includes('theforest.nostr1.com') || u.includes('nostr.land'))).toBe(true)
})
})

52
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -14,6 +14,8 @@ import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
PROFILE_MEDIA_TAB_KINDS, PROFILE_MEDIA_TAB_KINDS,
READ_ONLY_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
@ -29,7 +31,7 @@ import {
parseThreadWatchListRefs parseThreadWatchListRefs
} from '@/lib/notification-thread-watch' } from '@/lib/notification-thread-watch'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { pinHttpIndexRelaysInRelayCap } from '@/lib/feed-relay-urls' import { pinHttpIndexRelaysInRelayCap, pinMentionRelaysInRelayCap } from '@/lib/feed-relay-urls'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { type Event, type Filter } from 'nostr-tools' import { type Event, type Filter } from 'nostr-tools'
@ -38,6 +40,54 @@ import { type Event, type Filter } from 'nostr-tools'
export const FAUX_SPELL_MAX_RELAYS = 10 export const FAUX_SPELL_MAX_RELAYS = 10
export const FAUX_SPELL_EVENT_LIMIT = 200 export const FAUX_SPELL_EVENT_LIMIT = 200
/** Minimum global mention/index relays pinned for the notifications spell (`#p` REQ). */
export const NOTIFICATION_MENTION_RELAY_PIN_COUNT = 5
/** Relays that index `#p` mentions and recent social events for the notifications spell. */
export function notificationMentionIndexRelayUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered([
...FAST_READ_RELAY_URLS,
...SEARCHABLE_RELAY_URLS,
...READ_ONLY_RELAY_URLS
])
}
/**
* Notifications need global mention aggregators, not only the viewer's NIP-65 inbox (which may not store `#p`).
* Pins {@link NOTIFICATION_MENTION_RELAY_PIN_COUNT} index relays under {@link FAUX_SPELL_MAX_RELAYS}.
*/
export function buildNotificationSpellRelayUrls(
personalUrls: readonly string[],
blockedRelays: readonly string[] = []
): string[] {
const blocked = new Set(
blockedRelays
.map((b) => (normalizeAnyRelayUrl(b) || b.trim()).toLowerCase())
.filter(Boolean)
)
const allow = (u: string) => !blocked.has((normalizeAnyRelayUrl(u) || u.trim()).toLowerCase())
const mentionIndex = notificationMentionIndexRelayUrls().filter(allow)
const personal = dedupeNormalizeRelayUrlsOrdered([...personalUrls]).filter(allow)
const capped = feedRelayPolicyUrls(
[
{ source: 'search', urls: mentionIndex },
{ source: 'viewer-read', urls: personal }
],
{
operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
)
return pinMentionRelaysInRelayCap(
capped,
mentionIndex,
FAUX_SPELL_MAX_RELAYS,
Math.min(NOTIFICATION_MENTION_RELAY_PIN_COUNT, mentionIndex.length)
)
}
/** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */ /** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */
export const PROFILE_MEDIA_REQ_LIMIT = 200 export const PROFILE_MEDIA_REQ_LIMIT = 200

8
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -41,6 +41,7 @@ import {
MEDIA_SPELL_KINDS, MEDIA_SPELL_KINDS,
NOTIFICATION_SPELL_KINDS, NOTIFICATION_SPELL_KINDS,
applyFauxSpellCapsToSubRequests, applyFauxSpellCapsToSubRequests,
buildNotificationSpellRelayUrls,
ensureFauxSpellRelayStackTouchesFastRead ensureFauxSpellRelayStackTouchesFastRead
} from './fauxSpellFeeds' } from './fauxSpellFeeds'
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service'
@ -414,8 +415,11 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
) )
if (selectedFauxSpell === 'notifications') { if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey || !feedUrls.length) return [] if (!notificationsFeedPubkey) return []
const notificationUrls = appendMoneroNostrRelays(feedUrls) const notificationUrls = appendMoneroNostrRelays(
buildNotificationSpellRelayUrls(feedUrls, blockedRelays)
)
if (!notificationUrls.length) return []
const base = buildNotificationsSpellSubRequests(notificationUrls, notificationsFeedPubkey) const base = buildNotificationsSpellSubRequests(notificationUrls, notificationsFeedPubkey)
const extra = buildNotificationsFollowedThreadSubRequests( const extra = buildNotificationsFollowedThreadSubRequests(
notificationUrls, notificationUrls,

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">

35
src/providers/NostrProvider/index.tsx

@ -39,6 +39,7 @@ import {
mergeHydratedCacheRelayListEvents mergeHydratedCacheRelayListEvents
} from '@/lib/event-metadata' } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { buildAccountSessionNetworkHydrateRelayUrls } from '@/lib/relay-list-builder'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { import {
parseBlockedRelayUrlsFromEvent, parseBlockedRelayUrlsFromEvent,
@ -445,6 +446,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS)) Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS))
if (!skipNetworkHydrate) { if (!skipNetworkHydrate) {
/** Personal-relay policy must be synced before network REQs so profile index relays stay allowed. */
await client.syncViewerPersonalRelayKeys(account.pubkey)
const hydrateNetworkRelays = buildAccountSessionNetworkHydrateRelayUrls({
relayListEvent: storedRelayListEvent,
cacheRelayListEvent: storedCacheRelayListEvent,
httpRelayListEvent: storedHttpRelayListEvent ?? null,
favoriteRelaysEvent: storedFavoriteRelaysEvent,
blockedRelays
})
// Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) // Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour)
const rssFeedListStale = const rssFeedListStale =
!storedRssFeedListEvent || !storedRssFeedListEvent ||
@ -457,7 +468,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
queryService queryService
.fetchEvents(FAST_READ_RELAY_URLS.concat(PROFILE_RELAY_URLS), { .fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.RSS_FEED_LIST], kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -500,15 +511,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([ const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.CACHE_RELAYS], kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(hydrateNetworkRelays, {
kinds: [ExtendedKind.HTTP_RELAY_LIST], kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -550,13 +561,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
setRelayList(mergedRelayList) setRelayList(mergedRelayList)
const normalizedRelays = [ const fetchRelays = buildAccountSessionNetworkHydrateRelayUrls({
...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url), relayListEvent: relayListEvent ?? storedRelayListEvent,
...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url), cacheRelayListEvent: cacheRelayListEvent ?? storedCacheRelayListEvent,
...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), httpRelayListEvent: httpRelayListEventFetched ?? storedHttpRelayListEvent ?? null,
...PROFILE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) favoriteRelaysEvent: storedFavoriteRelaysEvent,
] blockedRelays
const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 16) })
const events = await queryService.fetchEvents(fetchRelays, [ const events = await queryService.fetchEvents(fetchRelays, [
{ {
kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS], kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS],
@ -798,7 +809,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
await replaceableEventService void replaceableEventService
.refreshAuthorPublishedReplaceablesFromRelays(account.pubkey) .refreshAuthorPublishedReplaceablesFromRelays(account.pubkey)
.catch((err) => { .catch((err) => {
logger.debug('[NostrProvider] Author replaceables refresh after hydrate failed', { error: err }) logger.debug('[NostrProvider] Author replaceables refresh after hydrate failed', { error: err })

4
src/services/client-replaceable-events.service.ts

@ -726,7 +726,9 @@ export class ReplaceableEventService {
const queryOpts = { const queryOpts = {
replaceableRace: useReplaceableRace, replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000,
/** Feed avatar batches must not be aborted by feed/search {@link interruptBackgroundQueries}. */
...(kind === kinds.Metadata ? { foreground: true as const } : {})
} }
let events: NEvent[] let events: NEvent[]

109
src/services/client.service.ts

@ -152,6 +152,7 @@ import {
authenticateNip42Relay, authenticateNip42Relay,
isRelayAuthRequiredCloseReason, isRelayAuthRequiredCloseReason,
isRelayAuthRequiredErrorMessage, isRelayAuthRequiredErrorMessage,
isRelayConnectionClosedError,
isRelaySubscriptionClosedByCaller isRelaySubscriptionClosedByCaller
} from '@/lib/relay-nip42-auth' } from '@/lib/relay-nip42-auth'
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
@ -160,7 +161,7 @@ import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/
import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter'
import { getPaymentAttestationTargetId } from '@/lib/superchat' import { getPaymentAttestationTargetId } from '@/lib/superchat'
import { import {
buildPublicMessagePublishRelayUrls, buildPublicMessagePublishRelayUrls,
@ -503,13 +504,15 @@ class ClientService extends EventTarget {
}) })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
const skipStrike =
msg.includes('[metadata-relays-only]') ||
msg.includes('[relay-strike]') ||
msg.includes('[relay-rate-limit]') ||
msg.includes('[offline]') ||
msg.includes('[http-index-relay]')
if ( if (
params?.purpose !== 'write' && !skipStrike &&
!msg.includes('[metadata-relays-only]') && (params?.purpose !== 'write' || isLocalNetworkUrl(url))
!msg.includes('[relay-strike]') &&
!msg.includes('[relay-rate-limit]') &&
!msg.includes('[offline]') &&
!msg.includes('[http-index-relay]')
) { ) {
relaySessionStrikes.recordConnectionFailure(url, msg, 'connection') relaySessionStrikes.recordConnectionFailure(url, msg, 'connection')
} }
@ -1389,11 +1392,7 @@ class ClientService extends EventTarget {
spellRelayList = this.emptyRelayListForPublish() spellRelayList = this.emptyRelayListForPublish()
} }
const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList) const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList)
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => !isReadOnlyRelayUrl(url))
const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => {
const n = normalizeRelayUrlByScheme(url) || url
return !readOnlySet.has(n)
})
return finish( return finish(
this.filterPublishingRelays( this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
@ -1586,12 +1585,11 @@ class ClientService extends EventTarget {
* so they stay in the random-relay pool even if not currently in monitoring data. * so they stay in the random-relay pool even if not currently in monitoring data.
*/ */
getSessionSuccessfulPublishRelayUrlsForRandomPool(): string[] { getSessionSuccessfulPublishRelayUrlsForRandomPool(): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const out: string[] = [] const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) { for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue if (stats.successCount < 1) continue
const n = canonicalRelaySessionKey(url) const n = canonicalRelaySessionKey(url)
if (!n || readOnlySet.has(n)) continue if (!n || isReadOnlyRelayUrl(n)) continue
out.push(n) out.push(n)
} }
out.sort((a, b) => { out.sort((a, b) => {
@ -1667,10 +1665,9 @@ class ClientService extends EventTarget {
* preferring those that have succeeded and been fast this session. Excludes read-only relays. * preferring those that have succeeded and been fast this session. Excludes read-only relays.
*/ */
getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] { getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
const normalizedCandidates = candidateUrls const normalizedCandidates = candidateUrls
.map((u) => normalizeAnyRelayUrl(u) || u) .map((u) => normalizeAnyRelayUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n)) .filter((n) => n && !isReadOnlyRelayUrl(n))
const unique = Array.from(new Set(normalizedCandidates)) const unique = Array.from(new Set(normalizedCandidates))
const preferred: string[] = [] const preferred: string[] = []
const rest: string[] = [] const rest: string[] = []
@ -2026,13 +2023,43 @@ class ClientService extends EventTarget {
) { ) {
logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url }) logger.debug(`[PublishEvent] Auth required, attempting authentication`, { url })
applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) applyRelayNip42AckTimeout(relay as unknown as AbstractRelay)
return authenticateNip42Relay(relay, (authEvt: EventTemplate) => const signAuth = (authEvt: EventTemplate) =>
queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) queueRelayAuthSign(() => that.signer!.signEvent(authEvt))
) const preparePublishRelay = async (): Promise<Relay> => {
.then(() => { const r = await that.pool.ensureRelay(url, ensureOpts)
logger.debug(`[PublishEvent] Auth successful, retrying publish`, { url }) const relayKeyPub = normalizeUrl(url) || url
return relay.publish(event) patchRelayNoticeForFetchFailures(r as unknown as AbstractRelay, relayKeyPub, (u, m) =>
}) that.handleRelayNoticeSession(u, m)
)
applyRelayNip42AckTimeout(r as unknown as AbstractRelay)
return r
}
const publishAfterAuth = async (): Promise<void> => {
await authenticateNip42Relay(relay as unknown as AbstractRelay, signAuth)
let liveRelay = await preparePublishRelay()
for (let authPubAttempt = 0; authPubAttempt < 2; authPubAttempt++) {
try {
await liveRelay.publish(event)
return
} catch (retryErr) {
if (!isRelayConnectionClosedError(retryErr) || authPubAttempt === 1) {
throw retryErr
}
logger.debug('[PublishEvent] Publish after auth on closed socket; reconnecting', {
url
})
try {
that.pool.close([url])
} catch {
/* ignore */
}
await new Promise((r) => setTimeout(r, 350))
liveRelay = await preparePublishRelay()
await authenticateNip42Relay(liveRelay as unknown as AbstractRelay, signAuth)
}
}
}
return publishAfterAuth()
.then(() => { .then(() => {
logger.debug(`[PublishEvent] Successfully published after auth`, { url }) logger.debug(`[PublishEvent] Successfully published after auth`, { url })
that.recordPublishSuccess(url, Date.now() - startMs) that.recordPublishSuccess(url, Date.now() - startMs)
@ -2041,10 +2068,12 @@ class ClientService extends EventTarget {
relayStatuses.push({ url, success: true }) relayStatuses.push({ url, success: true })
}) })
.catch((authError) => { .catch((authError) => {
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) const authMsg =
authError instanceof Error ? authError.message : String(authError)
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authMsg })
errors.push({ url, error: authError }) errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message }) relayStatuses.push({ url, success: false, error: authMsg })
relaySessionStrikes.recordPublishFailure(url, authError.message) relaySessionStrikes.recordPublishFailure(url, authMsg)
}) })
} else { } else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
@ -3989,18 +4018,16 @@ class ClientService extends EventTarget {
): Promise<TProfile[]> { ): Promise<TProfile[]> {
void this.ensureProfileSearchIndexFromIdb() void this.ensureProfileSearchIndexFromIdb()
const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' const searchStr = typeof filter.search === 'string' ? filter.search.trim() : ''
const normalizedAll = dedupeNormalizeRelayUrlsOrdered( const normalizedAll = dedupeNormalizeRelayUrlsOrdered(relayUrls)
relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
) const searchableSet = new Set(
const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered( dedupeNormalizeRelayUrlsOrdered([
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ...SEARCHABLE_RELAY_URLS,
...getViewerNostrLandAggrSearchRelayUrls(),
...nip66Service.getSearchableRelayUrls(),
...PROFILE_RELAY_URLS
])
) )
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
])
let urls = normalizedAll let urls = normalizedAll
if (searchStr.length > 0 && !options?.relaysOnly) { if (searchStr.length > 0 && !options?.relaysOnly) {
const searchCapable = normalizedAll.filter( const searchCapable = normalizedAll.filter(
@ -4042,7 +4069,7 @@ class ClientService extends EventTarget {
options?.globalTimeout ?? options?.globalTimeout ??
(usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000), (usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000),
relayOpSource: 'ClientService.searchProfiles', relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch || usesAuthorsLookup, foreground: usesNip50TextSearch || usesAuthorsLookup || options?.relaysOnly === true,
signal: options?.signal signal: options?.signal
}) })
@ -4065,15 +4092,13 @@ class ClientService extends EventTarget {
} }
private profileRelaySearchUrls(): string[] { private profileRelaySearchUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered( return dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS)
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
} }
private nip50ProfileIndexRelayUrls(): string[] { private nip50ProfileIndexRelayUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered([ return dedupeNormalizeRelayUrlsOrdered([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...SEARCHABLE_RELAY_URLS,
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) ...nip66Service.getSearchableRelayUrls()
]) ])
} }

1
src/services/gif.service.test.ts

@ -1,3 +1,4 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {

7
src/services/nip66.service.ts

@ -6,7 +6,7 @@
* require this data to function; use as a hint only. * require this data to function; use as a hint only.
*/ */
import { normalizeUrl } from '@/lib/url' import { normalizeUrl, looksLikeNostrBech32Identifier, isWebsocketUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TNip66RelayDiscovery } from '@/types' import { TNip66RelayDiscovery } from '@/types'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
@ -22,7 +22,10 @@ function parseEvent(ev: NEvent): TNip66RelayDiscovery | null {
if (ev.kind !== RELAY_DISCOVERY_KIND) return null if (ev.kind !== RELAY_DISCOVERY_KIND) return null
const d = ev.tags.find((t) => t[0] === 'd')?.[1] const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null if (!d) return null
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}` const dTrim = d.trim()
if (!dTrim || looksLikeNostrBech32Identifier(dTrim)) return null
const url = dTrim.startsWith('wss://') || dTrim.startsWith('ws://') ? dTrim : `wss://${dTrim}`
if (!isWebsocketUrl(url)) return null
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n))
const requirements: TNip66RelayDiscovery['requirements'] = {} const requirements: TNip66RelayDiscovery['requirements'] = {}

Loading…
Cancel
Save