Compare commits

..

No commits in common. '2eeb5ee79748fcff445bf27e0aa034134ab6acd3' and '87adf1638ed6be953e0905e80c556f4084302f46' have entirely different histories.

  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. 4
      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. 35
      src/lib/url.ts
  43. 35
      src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts
  44. 54
      src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx
  45. 21
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  46. 62
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts
  47. 169
      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. 105
      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.2", "version": "23.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.17.2", "version": "23.17.0",
"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.2", "version": "23.17.0",
"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,34 +451,9 @@ 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 min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation" className="flex h-full items-center gap-0.5 px-1.5 text-muted-foreground enabled:hover:text-primary"
title={t('Like')} title={t('Like')}
disabled={liking} disabled={liking}
onClick={openReactionPicker} onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 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-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation" className="flex h-full shrink-0 items-center px-1.5 sm:px-2 enabled:hover:text-primary"
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-5 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 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.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation', 'flex gap-1 items-center enabled:hover:text-blue-400 px-1.5 h-full',
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-2 touch-manipulation', 'flex h-full items-center enabled:hover:text-lime-500 px-1.5',
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-5 shrink-0 rounded-full" aria-hidden /> : <Repeat />} {reposting ? <Skeleton className="size-4 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-2 touch-manipulation', 'group flex h-full items-center px-1.5',
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-6 gap-y-2 sm:gap-x-5', 'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-3 gap-y-1 sm:gap-x-4',
'[&_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', '[&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
loading ? 'animate-pulse' : '', loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}

4
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 } from '@/lib/utils' import { cn, isTouchDevice } 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,6 +85,7 @@ 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
@ -95,6 +96,7 @@ export function PostEditorFormatToolbar({
<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,7 +13,9 @@ import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSea
const LIMIT = 50 const LIMIT = 50
const PROFILE_SEARCH_RELAY_URLS = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS) const PROFILE_SEARCH_RELAY_URLS = Array.from(
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, looksLikeNostrBech32Identifier, looksLikeRelayUrlInput, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' import { isKind10243HttpRelayTagUrl, isWebsocketUrl, 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,12 +55,8 @@ 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(trimmed) || normalizeHttpRelayUrl(trimmed) const n = normalizeAnyRelayUrl(input) || normalizeHttpRelayUrl(input)
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,8 +33,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
) { ) {
return return
} }
/** Yield pool capacity to search REQs without closing in-flight NIP-50 sockets (that zeroed results). */ client.interruptBackgroundQueries({ closePooledRelayConnections: true })
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,7 +17,6 @@ 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://spatia-arcana.com' 'wss://feeds.nostrarchives.com/notes/trending/reactions/today'
] ]
/** /**

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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
topicMapLocalOnlyBanner: topicMapLocalOnlyBanner:
'Kein Lese-Relay-Stack — nur Sitzungs-Cache und Geräte-Archiv (Relays in den Einstellungen für Live-Daten).', '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: 'Sitzungs-Cache, Archiv und Relays werden zusammengeführt…', topicMapLoading: 'Merging session cache, archive, and relays…',
topicMapEmpty: topicMapEmpty:
'Keine Themen- oder Hashtag-Signale im Scan-Fenster. Feeds lesen oder nach Sync erneut scannen.', 'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Topic Map konnte aus deinen Quellen nicht aufgebaut werden.', topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Erneut scannen', topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} mit ·t·-Tag · {{kw}} mit #hashtag im Text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Themen-Feed öffnen', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Öffnet Notizen mit diesem ·t·-Tag oder diesem #hashtag im Text.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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:
'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.', '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.',
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}} with ·t· tag · {{kw}} with #hashtag in text', topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed', topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
topicMapClickHint: topicMapClickHint:
'Opens notes that carry this ·t· tag or this #hashtag in the body.', 'Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.',
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

@ -1,77 +0,0 @@
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,53 +84,6 @@ 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,53 +65,3 @@ 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,7 +1,6 @@
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,
@ -47,7 +46,6 @@ 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

@ -1,17 +0,0 @@
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,24 +1,7 @@
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: ReadonlySet<string>, pubkey: string | undefined | null): boolean { export function muteSetHas(mutePubkeySet: Set<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,31 +1,5 @@
import { PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import { buildReplyReadRelayList } from '@/lib/relay-list-builder'
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,7 +10,6 @@
*/ */
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'
@ -34,57 +33,6 @@ 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,16 +19,6 @@ 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,7 +3,6 @@ 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'
@ -33,17 +32,6 @@ 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,20 +16,6 @@ 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)
) )
@ -38,30 +24,20 @@ 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)
/** True when `url` matches a known entry exactly or shares its hostname (e.g. filter.nostr.wine/npub… paths). */ function relayKey(url: string): string {
function relayMatchesHostOrExact(url: string, keySet: ReadonlySet<string>, hostSet: ReadonlySet<string>): boolean { return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
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 {
return relayMatchesHostOrExact(url, profileIndexOnlyKeySet, profileIndexOnlyHostSet) const key = relayKey(url)
return key.length > 0 && profileIndexOnlyKeySet.has(key)
} }
export function isReadOnlyRelayUrl(url: string): boolean { export function isReadOnlyRelayUrl(url: string): boolean {
return relayMatchesHostOrExact(url, readOnlyKeySet, readOnlyHostSet) const key = relayKey(url)
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,21 +33,6 @@ 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 { isReadOnlyRelayUrl, isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter' import { 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,11 +287,9 @@ 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, row.relayUrl) const parked = this.recordSlowSignalKey(key, now)
if (parked) socketsToClose.push(row.relayUrl) if (parked) socketsToClose.push(row.relayUrl)
if (timedOut && !isReadOnlyRelayUrl(row.relayUrl)) { if (timedOut) this.recordReadFailureKey(key, 'connection', row.relayUrl)
this.recordReadFailureKey(key, 'connection', row.relayUrl)
}
continue continue
} }
@ -306,11 +304,9 @@ class RelaySessionStrikes {
return socketsToClose return socketsToClose
} }
private recordSlowSignalKey(key: string, now: number, url?: string): boolean { private recordSlowSignalKey(key: string, now: number): 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,9 +26,7 @@ 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'))
@ -40,8 +38,7 @@ export function relayThreadHeatMapSettingKey(
.join('\n') .join('\n')
) )
const feedKey = digestHeatMapKeyPart(feedFilterKey) const feedKey = digestHeatMapKeyPart(feedFilterKey)
const muteKey = digestHeatMapKeyPart(muteFingerprint) return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}`
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,22 +8,6 @@ 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,7 +7,6 @@ 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
@ -19,8 +18,7 @@ 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) {
if (!isValidRelayFetchUrl(u)) continue const n = normalizeRelayUrlByScheme(u) || u.trim()
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

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

35
src/lib/url.ts

@ -33,39 +33,6 @@ 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)
@ -249,9 +216,7 @@ 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('://')) {
if (!looksLikeNostrBech32Identifier(trimmed) && looksLikeRelayUrlInput(trimmed)) {
logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed }) logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed })
}
return '' return ''
} }

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

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

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

@ -4,9 +4,7 @@ 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'
@ -59,18 +57,12 @@ function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[]
] ]
} }
export function mergeInteractionEvents( function mergeInteractionEvents(targetPubkey: string, events: Event[]): InteractionCard[] {
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 = {
@ -120,7 +112,6 @@ 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)
@ -177,12 +168,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, mutePubkeySet)) setCards(mergeInteractionEvents(pubkey, local))
setLoading(false) setLoading(false)
const all = await load(true) const all = await load(true)
if (cancelled) return if (cancelled) return
setCards(mergeInteractionEvents(pubkey, all, mutePubkeySet)) setCards(mergeInteractionEvents(pubkey, all))
} 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))
@ -196,7 +187,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [pubkey, refreshKey, load, mutePubkeySet]) }, [pubkey, refreshKey, load])
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">
@ -212,7 +203,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, mutePubkeySet))) .then((rows) => setCards(mergeInteractionEvents(pubkey, rows)))
.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))
}} }}
@ -230,7 +221,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-44 rounded-xl" /> <Skeleton key={i} className="h-24 rounded-xl" />
))} ))}
</div> </div>
) : error && cards.length === 0 ? ( ) : error && cards.length === 0 ? (
@ -243,7 +234,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-3 min-[720px]:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-2 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}
@ -253,39 +244,38 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
> >
<Card <Card
className={cn( className={cn(
'flex h-full min-w-0 flex-col overflow-hidden p-3 transition-colors hover:bg-accent/70', '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',
index < 3 && 'border-primary/40 bg-primary/5' index < 3 && 'border-primary/40 bg-primary/5'
)} )}
> >
<div className="flex min-w-0 items-start justify-between gap-2"> <div className="relative shrink-0">
<div className="min-w-0 flex-1"> <UserAvatar userId={card.pubkey} size="semiBig" className="min-[720px]:h-16 min-[720px]:w-16" />
<Username userId={card.pubkey} className="block truncate text-sm font-semibold" /> <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">
<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">
<div className="mt-2 flex min-w-0 flex-wrap gap-1.5 text-[11px] text-muted-foreground"> <Username userId={card.pubkey} className="block truncate text-sm font-semibold" />
<span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5 font-medium text-foreground"> <div className="truncate text-xs text-muted-foreground">{formatPubkey(card.pubkey)}</div>
<UserRound className="mr-1 inline size-3 shrink-0" aria-hidden /> <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">
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-foreground">
<UserRound className="mr-1 inline size-3" aria-hidden />
<span className="min-[720px]:hidden">{compactCount(card.score)}</span>
<span className="hidden min-[720px]:inline">
{t('n interactions', { count: card.score, formattedCount: compactCount(card.score) })} {t('n interactions', { count: card.score, formattedCount: compactCount(card.score) })}
</span> </span>
</span>
{card.authoredByProfile > 0 ? ( {card.authoredByProfile > 0 ? (
<span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5"> <span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline">
{t('outgoing interactions', { count: card.authoredByProfile })} {t('outgoing interactions', { count: card.authoredByProfile })}
</span> </span>
) : null} ) : null}
{card.mentionsProfile > 0 ? ( {card.mentionsProfile > 0 ? (
<span className="max-w-full truncate rounded-full bg-muted px-2 py-0.5"> <span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline">
{t('incoming interactions', { count: card.mentionsProfile })} {t('incoming interactions', { count: card.mentionsProfile })}
</span> </span>
) : null} ) : null}
</div> </div>
<div className="mt-3 flex justify-center">
<UserAvatar userId={card.pubkey} size="big" className="size-16 shrink-0 sm:size-20" />
</div> </div>
</Card> </Card>
</button> </button>

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

@ -4,7 +4,6 @@ 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'
@ -23,7 +22,6 @@ 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'
@ -103,7 +101,6 @@ 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()
@ -145,14 +142,9 @@ 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 [pubkey, relayUrls, followPubkeys, feedFilterKey]
? 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<{
@ -212,10 +204,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
dedup.set(ev.id.toLowerCase(), ev) dedup.set(ev.id.toLowerCase(), ev)
} }
} }
const merged = filterEventsExcludingMutedAuthors( const merged = filterEventsExcludingTombstones([...dedup.values()], tombstones)
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)
) )
@ -238,7 +227,6 @@ 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))
@ -256,7 +244,6 @@ 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)
} }
} }
@ -283,7 +270,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, mutePubkeySet]) }, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false

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

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

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

@ -1,11 +1,9 @@
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, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' import { extractHashtagsFromContent, 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'
@ -16,7 +14,6 @@ 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'
@ -35,93 +32,12 @@ 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> {
@ -150,59 +66,43 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label:
}) })
} }
export function buildTopicKeywordBubbles( 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 accum = new Map<string, TopicKeyAccum>() const topicHits = new Map<string, number>()
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 && isValidNormalizedTopicKey(n)) topics.add(n) if (n) topics.add(n)
} }
} }
const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) const kws = new Set(extractHashtagsFromContent(ev.content ?? ''))
for (const k of topics) bump(k, ev, true) for (const k of topics) {
for (const k of kws) bump(k, ev, false) topicHits.set(k, (topicHits.get(k) ?? 0) + 1)
}
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, row] of accum) { for (const key of keys) {
if (!isValidNormalizedTopicKey(key)) continue const a = topicHits.get(key) ?? 0
const score = row.topicNoteCount + row.keywordNoteCount const b = kwHits.get(key) ?? 0
const score = a + b
if (score <= 0) continue if (score <= 0) continue
out.push({ out.push({ key, score, topicNoteCount: a, keywordNoteCount: b })
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)
@ -214,7 +114,6 @@ 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()
@ -286,12 +185,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
dedup.set(ev.id.toLowerCase(), ev) dedup.set(ev.id.toLowerCase(), ev)
} }
} }
const clean = filterEventsExcludingMutedAuthors( const clean = filterEventsExcludingTombstones([...dedup.values()], tombstones)
filterEventsExcludingTombstones([...dedup.values()], tombstones), return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111)
mutePubkeySet }, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111])
)
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet)
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -325,22 +221,17 @@ 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) => {
navigateToHashtag(toNoteList({ hashtag: key })) const searchPhrase = key.replace(/-/g, ' ')
navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase }))
}, },
[navigateToHashtag] [navigateToHashtag]
) )
const displayLabel = (key: string) => formatTopicMapBubbleLabel(key) const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}`
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">
@ -411,9 +302,6 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
onClick={() => openMergedFeed(row.key)} onClick={() => openMergedFeed(row.key)}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{row.pubkeys.length > 0 ? (
<TopicBubbleAvatarRing pubkeys={row.pubkeys} bubbleSizePx={size} />
) : (
<span <span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35" className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{ style={{
@ -423,14 +311,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
}} }}
aria-hidden aria-hidden
/> />
)} <span className="pointer-events-none absolute inset-2 flex items-center justify-center text-pretty text-xs font-semibold leading-tight text-foreground drop-shadow-sm sm:text-sm">
<span
className={cn(
'pointer-events-none absolute inset-x-1 bottom-1.5 z-[1] rounded-md px-1 py-0.5',
'text-pretty text-center text-[10px] font-semibold leading-tight text-foreground sm:text-xs',
'bg-background/75 backdrop-blur-[2px] shadow-sm'
)}
>
{displayLabel(row.key)} {displayLabel(row.key)}
</span> </span>
</button> </button>

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

@ -1,25 +0,0 @@
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,8 +14,6 @@ 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'
@ -31,7 +29,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, pinMentionRelaysInRelayCap } from '@/lib/feed-relay-urls' import { pinHttpIndexRelaysInRelayCap } 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'
@ -40,54 +38,6 @@ 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,7 +41,6 @@ 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'
@ -415,11 +414,8 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
) )
if (selectedFauxSpell === 'notifications') { if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey) return [] if (!notificationsFeedPubkey || !feedUrls.length) return []
const notificationUrls = appendMoneroNostrRelays( const notificationUrls = appendMoneroNostrRelays(feedUrls)
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,6 +7,7 @@ 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 {
@ -23,7 +24,6 @@ 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' | 'search' | 'externalContent' | 'dtag' type: 'hashtag' | 'hashtagSearch' | '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') { if (data.type === 'hashtag' || data.type === 'hashtagSearch') {
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,28 +83,13 @@ 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') { if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') {
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
@ -133,16 +118,52 @@ 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: { filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
'#t': relayTopicTagFilterValues(topicKey),
...(kinds.length > 0 ? { kinds } : {})
},
urls: getRelayUrlsWithFavoritesFastReadAndInbox( urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
@ -151,6 +172,7 @@ 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(
@ -163,8 +185,6 @@ 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
} }
@ -312,7 +332,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' && pubkey) { if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) {
setControls( setControls(
<Button <Button
variant="ghost" variant="ghost"
@ -329,7 +349,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
useEffect(() => { useEffect(() => {
const inlineHeader = const inlineHeader =
hideTitlebar && hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'dtag') (data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag')
if (!hideTitlebar || inlineHeader) { if (!hideTitlebar || inlineHeader) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
return return
@ -356,16 +376,6 @@ 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} />
) )
@ -389,7 +399,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
displayScrollToTopButton displayScrollToTopButton
> >
{hideTitlebar && {hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'dtag') ? ( (data?.type === 'hashtag' || data?.type === 'hashtagSearch' || 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,7 +39,6 @@ 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,
@ -446,16 +445,6 @@ 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 ||
@ -468,7 +457,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
queryService queryService
.fetchEvents(hydrateNetworkRelays, { .fetchEvents(FAST_READ_RELAY_URLS.concat(PROFILE_RELAY_URLS), {
kinds: [ExtendedKind.RSS_FEED_LIST], kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -511,15 +500,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([ const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([
queryService.fetchEvents(hydrateNetworkRelays, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(hydrateNetworkRelays, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.CACHE_RELAYS], kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey] authors: [account.pubkey]
}, hydrateFetchOpts), }, hydrateFetchOpts),
queryService.fetchEvents(hydrateNetworkRelays, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.HTTP_RELAY_LIST], kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
@ -561,13 +550,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
setRelayList(mergedRelayList) setRelayList(mergedRelayList)
const fetchRelays = buildAccountSessionNetworkHydrateRelayUrls({ const normalizedRelays = [
relayListEvent: relayListEvent ?? storedRelayListEvent, ...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url),
cacheRelayListEvent: cacheRelayListEvent ?? storedCacheRelayListEvent, ...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url),
httpRelayListEvent: httpRelayListEventFetched ?? storedHttpRelayListEvent ?? null, ...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url),
favoriteRelaysEvent: storedFavoriteRelaysEvent, ...PROFILE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)
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],
@ -809,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
void replaceableEventService await 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,9 +726,7 @@ 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[]

105
src/services/client.service.ts

@ -152,7 +152,6 @@ 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'
@ -161,7 +160,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, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { getPaymentAttestationTargetId } from '@/lib/superchat' import { getPaymentAttestationTargetId } from '@/lib/superchat'
import { import {
buildPublicMessagePublishRelayUrls, buildPublicMessagePublishRelayUrls,
@ -504,15 +503,13 @@ 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 (
!skipStrike && params?.purpose !== 'write' &&
(params?.purpose !== 'write' || isLocalNetworkUrl(url)) !msg.includes('[metadata-relays-only]') &&
!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')
} }
@ -1392,7 +1389,11 @@ 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 spellWriteFiltered = spellWriteFilteredRaw.filter((url) => !isReadOnlyRelayUrl(url)) const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => {
const n = normalizeRelayUrlByScheme(url) || url
return !readOnlySet.has(n)
})
return finish( return finish(
this.filterPublishingRelays( this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
@ -1585,11 +1586,12 @@ 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 || isReadOnlyRelayUrl(n)) continue if (!n || readOnlySet.has(n)) continue
out.push(n) out.push(n)
} }
out.sort((a, b) => { out.sort((a, b) => {
@ -1665,9 +1667,10 @@ 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 && !isReadOnlyRelayUrl(n)) .filter((n) => n && !readOnlySet.has(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[] = []
@ -2023,43 +2026,13 @@ 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)
const signAuth = (authEvt: EventTemplate) => return authenticateNip42Relay(relay, (authEvt: EventTemplate) =>
queueRelayAuthSign(() => that.signer!.signEvent(authEvt)) queueRelayAuthSign(() => that.signer!.signEvent(authEvt))
const preparePublishRelay = async (): Promise<Relay> => {
const r = await that.pool.ensureRelay(url, ensureOpts)
const relayKeyPub = normalizeUrl(url) || url
patchRelayNoticeForFetchFailures(r as unknown as AbstractRelay, relayKeyPub, (u, m) =>
that.handleRelayNoticeSession(u, m)
) )
applyRelayNip42AckTimeout(r as unknown as AbstractRelay) .then(() => {
return r logger.debug(`[PublishEvent] Auth successful, retrying publish`, { url })
} return relay.publish(event)
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)
@ -2068,12 +2041,10 @@ class ClientService extends EventTarget {
relayStatuses.push({ url, success: true }) relayStatuses.push({ url, success: true })
}) })
.catch((authError) => { .catch((authError) => {
const authMsg = logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message })
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: authMsg }) relayStatuses.push({ url, success: false, error: authError.message })
relaySessionStrikes.recordPublishFailure(url, authMsg) relaySessionStrikes.recordPublishFailure(url, authError.message)
}) })
} else { } else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
@ -4018,16 +3989,18 @@ 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(relayUrls) const normalizedAll = dedupeNormalizeRelayUrlsOrdered(
const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS) relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
const searchableSet = new Set(
dedupeNormalizeRelayUrlsOrdered([
...SEARCHABLE_RELAY_URLS,
...getViewerNostrLandAggrSearchRelayUrls(),
...nip66Service.getSearchableRelayUrls(),
...PROFILE_RELAY_URLS
])
) )
const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered(
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
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(
@ -4069,7 +4042,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 || options?.relaysOnly === true, foreground: usesNip50TextSearch || usesAuthorsLookup,
signal: options?.signal signal: options?.signal
}) })
@ -4092,13 +4065,15 @@ class ClientService extends EventTarget {
} }
private profileRelaySearchUrls(): string[] { private profileRelaySearchUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered(PROFILE_RELAY_URLS) return dedupeNormalizeRelayUrlsOrdered(
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
} }
private nip50ProfileIndexRelayUrls(): string[] { private nip50ProfileIndexRelayUrls(): string[] {
return dedupeNormalizeRelayUrlsOrdered([ return dedupeNormalizeRelayUrlsOrdered([
...SEARCHABLE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls() ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
]) ])
} }

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

@ -1,4 +1,3 @@
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, looksLikeNostrBech32Identifier, isWebsocketUrl } from '@/lib/url' import { normalizeUrl } 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,10 +22,7 @@ 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 dTrim = d.trim() const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}`
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