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

4
package-lock.json generated

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

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"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",
"private": true,
"type": "module",

25
src/components/NoteList/index.tsx

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

8
src/components/NoteStats/LikeButton.tsx

@ -244,13 +244,13 @@ export function LikeButtonWithStats({ @@ -244,13 +244,13 @@ export function LikeButtonWithStats({
const likeIconButton = (
<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')}
disabled={liking}
onClick={openReactionPicker}
>
{liking ? (
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji && !useIconOnlyLikeTrigger ? (
<Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
) : (
@ -288,7 +288,7 @@ export function LikeButtonWithStats({ @@ -288,7 +288,7 @@ export function LikeButtonWithStats({
>
<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')}
disabled={liking}
onClick={() => {
@ -296,7 +296,7 @@ export function LikeButtonWithStats({ @@ -296,7 +296,7 @@ export function LikeButtonWithStats({
}}
>
{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>
{arrow}

2
src/components/NoteStats/ReplyButton.tsx

@ -40,7 +40,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re @@ -40,7 +40,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
<>
<button
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'
)}
onClick={(e) => {

4
src/components/NoteStats/RepostButton.tsx

@ -108,7 +108,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R @@ -108,7 +108,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
<button
type="button"
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'
)}
title={t('Boost')}
@ -118,7 +118,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R @@ -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>
)

2
src/components/NoteStats/ZapButton.tsx

@ -225,7 +225,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -225,7 +225,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
<button
type="button"
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'
)}
title={zapButtonTitle}

4
src/components/NoteStats/index.tsx

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

24
src/components/PostEditor/PostEditorFormatToolbar.tsx

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

6
src/components/ProfileListBySearch/index.tsx

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import { useSecondaryPage } from '@/PageManager'
import { PROFILE_RELAY_URLS } from '@/constants'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import dayjs from 'dayjs'
@ -13,7 +13,9 @@ import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSea @@ -13,7 +13,9 @@ import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSea
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({
search,

8
src/components/SearchBar/index.tsx

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

3
src/components/SearchResult/index.tsx

@ -33,8 +33,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -33,8 +33,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
) {
return
}
/** Yield pool capacity to search REQs without closing in-flight NIP-50 sockets (that zeroed results). */
client.interruptBackgroundQueries()
client.interruptBackgroundQueries({ closePooledRelayConnections: true })
}, [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. */

1
src/components/TooManyRelaysAlertDialog/index.tsx

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

2
src/constants.ts

@ -471,7 +471,7 @@ export const READ_ONLY_RELAY_URLS = [ @@ -471,7 +471,7 @@ export const READ_ONLY_RELAY_URLS = [
'wss://filter.nostr.wine',
'wss://primus.nostr1.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 { @@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1092,10 +1092,10 @@ export default { @@ -1092,10 +1092,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

18
src/i18n/locales/de.ts

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

8
src/i18n/locales/en.ts

@ -1140,7 +1140,7 @@ export default { @@ -1140,7 +1140,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1148,10 +1148,10 @@ export default { @@ -1148,10 +1148,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/es.ts

@ -1088,7 +1088,7 @@ export default { @@ -1088,7 +1088,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1096,10 +1096,10 @@ export default { @@ -1096,10 +1096,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/fr.ts

@ -1089,7 +1089,7 @@ export default { @@ -1089,7 +1089,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1097,10 +1097,10 @@ export default { @@ -1097,10 +1097,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/nl.ts

@ -1084,7 +1084,7 @@ export default { @@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1092,10 +1092,10 @@ export default { @@ -1092,10 +1092,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/pl.ts

@ -1086,7 +1086,7 @@ export default { @@ -1086,7 +1086,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1094,10 +1094,10 @@ export default { @@ -1094,10 +1094,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/ru.ts

@ -1087,7 +1087,7 @@ export default { @@ -1087,7 +1087,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1095,10 +1095,10 @@ export default { @@ -1095,10 +1095,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/tr.ts

@ -1084,7 +1084,7 @@ export default { @@ -1084,7 +1084,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1092,10 +1092,10 @@ export default { @@ -1092,10 +1092,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

8
src/i18n/locales/zh.ts

@ -1081,7 +1081,7 @@ export default { @@ -1081,7 +1081,7 @@ export default {
'incoming interactions': '{{count}} toward this profile',
'Topic map': 'Topic map',
topicMapDescription:
'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:
'No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).',
topicMapLoading: 'Merging session cache, archive, and relays…',
@ -1089,10 +1089,10 @@ export default { @@ -1089,10 +1089,10 @@ export default {
'No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.',
topicMapFetchError: 'Could not build the topic map from your sources.',
topicMapRescan: 'Rescan',
topicMapBubbleCounts: '{{topic}} with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open topic feed',
topicMapBubbleCounts: '{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text',
topicMapOpenMergedFeed: 'Open merged topic and keyword feed',
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',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',

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

@ -1,77 +0,0 @@ @@ -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[] { @@ -84,53 +84,6 @@ export function extractHashtagsFromContent(content: string): string[] {
return hashtags
}
/** True when the event carries `topic` as a normalized `t` tag or `#topic` in note content. */
export function eventMatchesTopicOrContentHashtag(event: NostrEvent, topic: string): boolean {
const key = normalizeTopic(topic)
if (!key) return false
for (const row of event.tags) {
if (row[0] === 't' && row[1] && normalizeTopic(row[1]) === key) return true
}
return extractHashtagsFromContent(event.content ?? '').includes(key)
}
/** Normalized topic/hashtag keys suitable for topic-map bubbles and `#t` feeds. */
export function isValidNormalizedTopicKey(key: string): boolean {
const k = key.trim()
if (!k || /^[0-9]+$/.test(k)) return false
return /^[a-z0-9][a-z0-9_-]*$/.test(k)
}
/** True when `#${key}` matches the content {@link HASHTAG_REGEX} (ASCII keys after normalization). */
export function normalizedKeyMatchesHashtagPattern(key: string): boolean {
if (!isValidNormalizedTopicKey(key)) return false
return /^#[a-z0-9_-]+$/i.test(`#${key}`)
}
/** Topic-map bubble label: readable words, no `#` prefix. */
export function formatTopicMapBubbleLabel(key: string): string {
return key.replace(/-/g, ' ')
}
/**
* `#t` filter values for relay REQs when opening a normalized topic-map key.
* Map keys singularize (e.g. `jesus` `jesu`); relays often still store the unsingularized t-tag.
*/
export function relayTopicTagFilterValues(normalizedKey: string): string[] {
const k = normalizeTopic(normalizedKey) || normalizedKey.trim().toLowerCase()
if (!k) return []
const out = new Set<string>([k])
if (!k.endsWith('s')) {
out.add(`${k}s`)
if (k.endsWith('y')) out.add(`${k.slice(0, -1)}ies`)
else out.add(`${k}es`)
}
if (k.endsWith('s') && k.length > 2 && !k.endsWith('ss')) {
out.add(k.slice(0, -1))
}
return [...out]
}
/**
* Extract h-tag (group ID) from event tags
*/

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

@ -65,53 +65,3 @@ export function pinHttpIndexRelaysInRelayCap( @@ -65,53 +65,3 @@ export function pinHttpIndexRelaysInRelayCap(
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 @@ @@ -1,7 +1,6 @@
import {
BOOKSTR_RELAY_URLS,
DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS,
FOLLOWS_HISTORY_RELAY_URLS,
GIF_RELAY_URLS,
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS,
@ -47,7 +46,6 @@ function relayKeyForCuratedSet(url: string): string { @@ -47,7 +46,6 @@ function relayKeyForCuratedSet(url: string): string {
/** 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[])[] = [
...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS,
FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS,
READ_ONLY_RELAY_URLS,
NIP66_DISCOVERY_RELAY_URLS

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

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

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

@ -10,7 +10,6 @@ @@ -10,7 +10,6 @@
*/
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 { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
@ -34,57 +33,6 @@ import type { Event } from 'nostr-tools' @@ -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). */
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 {
return canonicalRelaySessionKey(url)
}

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

@ -19,16 +19,6 @@ export function isRelayAuthRequiredErrorMessage(message: string): boolean { @@ -19,16 +19,6 @@ export function isRelayAuthRequiredErrorMessage(message: string): boolean {
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. */
export function isRelaySubscriptionClosedByCaller(reason: string): boolean {
return reason.trim() === 'closed by caller'

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

@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import {
filterContextAuthorReadRelaysForPublish,
filterRelaysForEventPublish,
isReadOnlyRelayUrl,
isRelayPublishPolicyRejection,
relayAllowsPublishKind
} from './relay-publish-filter'
@ -33,17 +32,6 @@ describe('relay-publish-filter', () => { @@ -33,17 +32,6 @@ describe('relay-publish-filter', () => {
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', () => {
const out = filterContextAuthorReadRelaysForPublish([
'wss://profiles.nostrver.se/',

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

@ -16,20 +16,6 @@ export const PROFILE_INDEX_ONLY_RELAY_URLS = [ @@ -16,20 +16,6 @@ export const PROFILE_INDEX_ONLY_RELAY_URLS = [
'wss://indexer.coracle.social/'
] 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(
PROFILE_INDEX_ONLY_RELAY_URLS.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
)
@ -38,30 +24,20 @@ const readOnlyKeySet = new Set( @@ -38,30 +24,20 @@ const readOnlyKeySet = new Set(
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)
/** True when `url` matches a known entry exactly or shares its hostname (e.g. filter.nostr.wine/npub… paths). */
function relayMatchesHostOrExact(url: string, keySet: ReadonlySet<string>, hostSet: ReadonlySet<string>): boolean {
const key = relayKey(url)
if (key.length > 0 && keySet.has(key)) return true
const host = relayHostname(url)
return host != null && hostSet.has(host)
function relayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
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 {
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). */

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

@ -33,21 +33,6 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => { @@ -33,21 +33,6 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => {
expect(relaySessionStrikes.isReadHttpSkipped(fast)).toBe(false)
})
it('does not session-park read-only index relays (e.g. aggr.nostr.land)', () => {
const aggr = 'wss://aggr.nostr.land/'
const fast = 'wss://fast.example.com/'
relaySessionStrikes.observeSubscribeBatch([
row(fast, 'eose', 400),
row(aggr, 'eose', 12_000)
])
relaySessionStrikes.observeSubscribeBatch([
row(fast, 'eose', 500),
row(aggr, 'timeout', 10_000)
])
expect(relaySessionStrikes.isReadHttpSkipped(aggr)).toBe(false)
})
it('clears slow parking on fast EOSE via recordReadSuccess', () => {
const url = 'wss://recover.example.com/'
relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 15_000)])

12
src/lib/relay-strikes.ts

@ -7,7 +7,7 @@ import { @@ -7,7 +7,7 @@ import {
import type { Event } from 'nostr-tools'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { isReadOnlyRelayUrl, isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter'
import { isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter'
import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url'
import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service'
@ -287,11 +287,9 @@ class RelaySessionStrikes { @@ -287,11 +287,9 @@ class RelaySessionStrikes {
const fastEose = row.outcome === 'eose' && row.msFromBatchStart < slowThresholdMs * 0.6
if (timedOut || slowEose) {
const parked = this.recordSlowSignalKey(key, now, row.relayUrl)
const parked = this.recordSlowSignalKey(key, now)
if (parked) socketsToClose.push(row.relayUrl)
if (timedOut && !isReadOnlyRelayUrl(row.relayUrl)) {
this.recordReadFailureKey(key, 'connection', row.relayUrl)
}
if (timedOut) this.recordReadFailureKey(key, 'connection', row.relayUrl)
continue
}
@ -306,11 +304,9 @@ class RelaySessionStrikes { @@ -306,11 +304,9 @@ class RelaySessionStrikes {
return socketsToClose
}
private recordSlowSignalKey(key: string, now: number, url?: string): boolean {
private recordSlowSignalKey(key: string, now: number): boolean {
const e = this.getEntry(key)
if (this.cacheRelayKeys.has(key)) return false
// Read-only index relays (aggr.nostr.land, search.nos.today, …) are intentionally slower than inbox relays.
if (url && isReadOnlyRelayUrl(url)) return false
e.slowSignals += 1
if (e.slowSignals < RELAY_SLOW_PARK_SIGNALS_THRESHOLD) return false
e.slowParkUntil = Math.max(e.slowParkUntil, now + RELAY_SLOW_PARK_COOLDOWN_MS)

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

@ -26,9 +26,7 @@ export function relayThreadHeatMapSettingKey( @@ -26,9 +26,7 @@ export function relayThreadHeatMapSettingKey(
relayUrls: readonly string[],
followPubkeys: readonly string[],
/** Serialized home kind-picker state so cache invalidates when feed filters change. */
feedFilterKey: string,
/** Sorted mute pubkeys so cache invalidates when mutes change. */
muteFingerprint: string
feedFilterKey: string
): string {
const pk = pubkey.trim().toLowerCase()
const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n'))
@ -40,8 +38,7 @@ export function relayThreadHeatMapSettingKey( @@ -40,8 +38,7 @@ export function relayThreadHeatMapSettingKey(
.join('\n')
)
const feedKey = digestHeatMapKeyPart(feedFilterKey)
const muteKey = digestHeatMapKeyPart(muteFingerprint)
return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}:${muteKey}`
return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}`
}
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/ @@ -8,22 +8,6 @@ import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
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', () => {
it('drops loopback, LAN, .onion, and profile/index mirrors; keeps public relays', () => {
const out = filterContextAuthorReadRelaysForPublish([

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

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

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

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

37
src/lib/url.ts

@ -33,39 +33,6 @@ export function isKind10243HttpRelayTagUrl(url: string): boolean { @@ -33,39 +33,6 @@ export function isKind10243HttpRelayTagUrl(url: string): boolean {
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. */
export function isHttpRelayUrl(url: string): boolean {
return isKind10243HttpRelayTagUrl(url)
@ -249,9 +216,7 @@ export function normalizeUrl(url: string): string { @@ -249,9 +216,7 @@ export function normalizeUrl(url: string): string {
const trimmed = url.trim()
if (!trimmed) return ''
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 ''
}

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

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

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

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

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

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

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

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

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

@ -1,11 +1,9 @@ @@ -1,11 +1,9 @@
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ExtendedKind } from '@/constants'
import { useMuteList } from '@/contexts/mute-list-context'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingMutedAuthors, muteSetHas } from '@/lib/mute-set'
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 { toNoteList } from '@/lib/link'
import logger from '@/lib/logger'
@ -16,7 +14,6 @@ import { useNostr } from '@/providers/NostrProvider' @@ -16,7 +14,6 @@ import { useNostr } from '@/providers/NostrProvider'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { cn } from '@/lib/utils'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { kinds, verifyEvent } from 'nostr-tools'
@ -35,93 +32,12 @@ const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const @@ -35,93 +32,12 @@ const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const
const ARCHIVE_SCAN_TIMEOUT_MS = 22_000
const RELAY_FETCH_TIMEOUT_MS = 26_000
const TOMBSTONES_TIMEOUT_MS = 8_000
/** Max profile avatars shown around each topic bubble (by tag usage count). */
const MAX_BUBBLE_AVATARS = 7
export type TTopicKeywordBubble = {
key: string
score: number
topicNoteCount: number
keywordNoteCount: number
pubkeys: string[]
}
type TopicKeyAccum = {
topicNoteCount: number
keywordNoteCount: number
pubkeyHits: Map<string, number>
}
function topPubkeysForTopic(
hits: Map<string, number>,
limit: number,
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> {
@ -150,59 +66,43 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: @@ -150,59 +66,43 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label:
})
}
export function buildTopicKeywordBubbles(
function buildTopicKeywordBubbles(
events: Event[],
showKinds: readonly number[],
showKind1OPs: boolean,
showKind1Replies: boolean,
showKind1111: boolean,
mutePubkeySet?: ReadonlySet<string>
showKind1111: boolean
): TTopicKeywordBubble[] {
const accum = new Map<string, TopicKeyAccum>()
const bump = (key: string, ev: Event, viaTopicTag: boolean) => {
if (!isValidNormalizedTopicKey(key)) return
let row = accum.get(key)
if (!row) {
row = { topicNoteCount: 0, keywordNoteCount: 0, pubkeyHits: new Map() }
accum.set(key, row)
}
if (viaTopicTag) row.topicNoteCount += 1
else row.keywordNoteCount += 1
const pk = ev.pubkey.trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(pk)) {
row.pubkeyHits.set(pk, (row.pubkeyHits.get(pk) ?? 0) + 1)
}
}
const topicHits = new Map<string, number>()
const kwHits = new Map<string, number>()
for (const ev of events) {
if (mutePubkeySet && muteSetHas(mutePubkeySet, ev.pubkey)) continue
if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue
const topics = new Set<string>()
for (const row of ev.tags) {
if (row[0] === 't' && 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 ?? ''))
for (const k of topics) bump(k, ev, true)
for (const k of kws) bump(k, ev, false)
for (const k of topics) {
topicHits.set(k, (topicHits.get(k) ?? 0) + 1)
}
for (const k of kws) {
kwHits.set(k, (kwHits.get(k) ?? 0) + 1)
}
}
const keys = new Set<string>([...topicHits.keys(), ...kwHits.keys()])
const out: TTopicKeywordBubble[] = []
for (const [key, row] of accum) {
if (!isValidNormalizedTopicKey(key)) continue
const score = row.topicNoteCount + row.keywordNoteCount
for (const key of keys) {
const a = topicHits.get(key) ?? 0
const b = kwHits.get(key) ?? 0
const score = a + b
if (score <= 0) continue
out.push({
key,
score,
topicNoteCount: row.topicNoteCount,
keywordNoteCount: row.keywordNoteCount,
pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS, mutePubkeySet)
})
out.push({ key, score, topicNoteCount: a, keywordNoteCount: b })
}
out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key))
return out.slice(0, MAX_BUBBLES)
@ -214,7 +114,6 @@ type Props = { @@ -214,7 +114,6 @@ type Props = {
export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -286,12 +185,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { @@ -286,12 +185,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
dedup.set(ev.id.toLowerCase(), ev)
}
}
const clean = filterEventsExcludingMutedAuthors(
filterEventsExcludingTombstones([...dedup.values()], tombstones),
mutePubkeySet
)
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet)
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet])
const clean = filterEventsExcludingTombstones([...dedup.values()], tombstones)
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111)
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111])
useEffect(() => {
let cancelled = false
@ -325,22 +221,17 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { @@ -325,22 +221,17 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
}
}, [mergeData, refreshKey, rescanTick, t])
useEffect(() => {
const pubkeys = [...new Set(rows.flatMap((r) => r.pubkeys))].slice(0, 48)
if (pubkeys.length === 0) return
void Promise.allSettled(pubkeys.map((pk) => client.fetchProfileEvent(pk).catch(() => {})))
}, [rows])
const maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows])
const openMergedFeed = useCallback(
(key: string) => {
navigateToHashtag(toNoteList({ hashtag: key }))
const searchPhrase = key.replace(/-/g, ' ')
navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase }))
},
[navigateToHashtag]
)
const displayLabel = (key: string) => formatTopicMapBubbleLabel(key)
const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}`
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
@ -411,26 +302,16 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { @@ -411,26 +302,16 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
onClick={() => openMergedFeed(row.key)}
aria-label={ariaLabel}
>
{row.pubkeys.length > 0 ? (
<TopicBubbleAvatarRing pubkeys={row.pubkeys} bubbleSizePx={size} />
) : (
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
)}
<span
className={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'
)}
>
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
<span className="pointer-events-none absolute inset-2 flex items-center justify-center text-pretty text-xs font-semibold leading-tight text-foreground drop-shadow-sm sm:text-sm">
{displayLabel(row.key)}
</span>
</button>

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

@ -1,25 +0,0 @@ @@ -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 { @@ -14,8 +14,6 @@ import {
ExtendedKind,
FAST_READ_RELAY_URLS,
PROFILE_MEDIA_TAB_KINDS,
READ_ONLY_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
@ -31,7 +29,7 @@ import { @@ -31,7 +29,7 @@ import {
parseThreadWatchListRefs
} from '@/lib/notification-thread-watch'
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 type { TFeedSubRequest } from '@/types'
import { type Event, type Filter } from 'nostr-tools'
@ -40,54 +38,6 @@ 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_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). */
export const PROFILE_MEDIA_REQ_LIMIT = 200

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

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

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

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

35
src/providers/NostrProvider/index.tsx

@ -39,7 +39,6 @@ import { @@ -39,7 +39,6 @@ import {
mergeHydratedCacheRelayListEvents
} from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { buildAccountSessionNetworkHydrateRelayUrls } from '@/lib/relay-list-builder'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import {
parseBlockedRelayUrlsFromEvent,
@ -446,16 +445,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -446,16 +445,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS))
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)
const rssFeedListStale =
!storedRssFeedListEvent ||
@ -468,7 +457,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -468,7 +457,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
queryService
.fetchEvents(hydrateNetworkRelays, {
.fetchEvents(FAST_READ_RELAY_URLS.concat(PROFILE_RELAY_URLS), {
kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey],
limit: 1
@ -511,15 +500,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -511,15 +500,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const [relayListEvents, cacheRelayListEvents, httpRelayListEvents] = await Promise.all([
queryService.fetchEvents(hydrateNetworkRelays, {
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [kinds.RelayList],
authors: [account.pubkey]
}, hydrateFetchOpts),
queryService.fetchEvents(hydrateNetworkRelays, {
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey]
}, hydrateFetchOpts),
queryService.fetchEvents(hydrateNetworkRelays, {
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey],
limit: 1
@ -561,13 +550,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -561,13 +550,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
setRelayList(mergedRelayList)
const fetchRelays = buildAccountSessionNetworkHydrateRelayUrls({
relayListEvent: relayListEvent ?? storedRelayListEvent,
cacheRelayListEvent: cacheRelayListEvent ?? storedCacheRelayListEvent,
httpRelayListEvent: httpRelayListEventFetched ?? storedHttpRelayListEvent ?? null,
favoriteRelaysEvent: storedFavoriteRelaysEvent,
blockedRelays
})
const normalizedRelays = [
...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url),
...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url),
...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url),
...PROFILE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)
]
const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 16)
const events = await queryService.fetchEvents(fetchRelays, [
{
kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS],
@ -809,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -809,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
void replaceableEventService
await replaceableEventService
.refreshAuthorPublishedReplaceablesFromRelays(account.pubkey)
.catch((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 { @@ -726,9 +726,7 @@ export class ReplaceableEventService {
const queryOpts = {
replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
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 } : {})
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
}
let events: NEvent[]

109
src/services/client.service.ts

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

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

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

7
src/services/nip66.service.ts

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
* 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 { TNip66RelayDiscovery } from '@/types'
import { Event as NEvent } from 'nostr-tools'
@ -22,10 +22,7 @@ function parseEvent(ev: NEvent): TNip66RelayDiscovery | null { @@ -22,10 +22,7 @@ function parseEvent(ev: NEvent): TNip66RelayDiscovery | null {
if (ev.kind !== RELAY_DISCOVERY_KIND) return null
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null
const dTrim = d.trim()
if (!dTrim || looksLikeNostrBech32Identifier(dTrim)) return null
const url = dTrim.startsWith('wss://') || dTrim.startsWith('ws://') ? dTrim : `wss://${dTrim}`
if (!isWebsocketUrl(url)) return null
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}`
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n))
const requirements: TNip66RelayDiscovery['requirements'] = {}

Loading…
Cancel
Save