Browse Source

make relay selection and suppression more efficient

reveal relay scores to the user
move cache to its own page
get rid of local storage, add session cache
speed everything up
imwald
Silberengel 1 month ago
parent
commit
0c2eb5067e
  1. 1
      index.html
  2. 36
      public/manifest.webmanifest
  3. 4
      src/PageManager.tsx
  4. 38
      src/components/PostEditor/PostContent.tsx
  5. 7
      src/components/PostEditor/PostOptions.tsx
  6. 137
      src/components/SessionRelaysTab/index.tsx
  7. 8
      src/constants.ts
  8. 16
      src/i18n/locales/de.ts
  9. 12
      src/i18n/locales/en.ts
  10. 1
      src/lib/link.ts
  11. 8
      src/main.tsx
  12. 23
      src/pages/secondary/CacheSettingsPage/index.tsx
  13. 12
      src/pages/secondary/RelaySettingsPage/index.tsx
  14. 9
      src/pages/secondary/SettingsPage/index.tsx
  15. 21
      src/providers/NostrProvider/index.tsx
  16. 15
      src/providers/ThemeProvider.tsx
  17. 2
      src/routes.tsx
  18. 203
      src/services/client.service.ts
  19. 46
      src/services/local-storage.service.ts
  20. 1
      src/services/navigation.service.ts
  21. 6
      src/services/relay-selection.service.ts

1
index.html

@ -15,6 +15,7 @@
/> />
<meta name="apple-mobile-web-app-title" content="Jumble" /> <meta name="apple-mobile-web-app-title" content="Jumble" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 font-size=%2290%22%3E🌲%3C/text%3E%3C/svg%3E" type="image/svg+xml" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 font-size=%2290%22%3E🌲%3C/text%3E%3C/svg%3E" type="image/svg+xml" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />

36
public/manifest.webmanifest

@ -0,0 +1,36 @@
{
"name": "Jumble Imwald Edition",
"author": "Silberengel",
"short_name": "Jumble",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery",
"start_url": "/",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#FFFFFF",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/pwa-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

4
src/PageManager.tsx

@ -13,6 +13,7 @@ import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage' import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import CacheSettingsPage from '@/pages/secondary/CacheSettingsPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage' import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NoteDrawer from '@/components/NoteDrawer' import NoteDrawer from '@/components/NoteDrawer'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage' import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
@ -422,6 +423,9 @@ export function useSmartSettingsNavigation() {
} else if (url.startsWith('/settings/relays')) { } else if (url.startsWith('/settings/relays')) {
window.history.pushState(null, '', url) window.history.pushState(null, '', url)
setPrimaryNoteView(<RelaySettingsPage key="relay-settings" index={0} hideTitlebar={true} />, 'settings-sub') setPrimaryNoteView(<RelaySettingsPage key="relay-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/cache') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<CacheSettingsPage key="cache-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/wallet') { } else if (url === '/settings/wallet') {
window.history.pushState(null, '', url) window.history.pushState(null, '', url)
setPrimaryNoteView(<WalletPage key="wallet" index={0} hideTitlebar={true} />, 'settings-sub') setPrimaryNoteView(<WalletPage key="wallet" index={0} hideTitlebar={true} />, 'settings-sub')

38
src/components/PostEditor/PostContent.tsx

@ -46,7 +46,8 @@ import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import client from '@/services/client.service' import client from '@/services/client.service'
import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event' import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -89,7 +90,7 @@ export default function PostContent({
{ file: File; progress: number; cancel: () => void }[] { file: File; progress: number; cancel: () => void }[]
>([]) >([])
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(true) // Default to true to always add client tag const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag())
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false) const [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false) const [isPoll, setIsPoll] = useState(false)
@ -256,7 +257,7 @@ export default function PostContent({
relays: [] relays: []
} }
) )
setAddClientTag(cachedSettings.addClientTag ?? true) // Default to true setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag())
} }
return return
} }
@ -846,14 +847,16 @@ export default function PostContent({
const cleanEvent = { ...newEvent } const cleanEvent = { ...newEvent }
delete (cleanEvent as any).relayStatuses delete (cleanEvent as any).relayStatuses
// Add reply immediately so it appears in the thread // Reply: add to UI and cache immediately so it shows without tabbing away (publish already emitted via NostrProvider)
if (parentEvent) { if (parentEvent) {
addReplies([cleanEvent]) addReplies([cleanEvent])
// Also dispatch the newEvent to ensure ReplyNoteList picks it up const rootInfo = !isReplaceableEvent(parentEvent.kind)
// The event is already dispatched by publish(), but we do it again to ensure it's caught ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey }
setTimeout(() => { : { type: 'A' as const, id: getReplaceableCoordinateFromEvent(parentEvent), eventId: parentEvent.id, pubkey: parentEvent.pubkey, relay: client.getEventHint(parentEvent.id) }
client.emitNewEvent(cleanEvent) const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? []
}, 100) if (!cached.some((r) => r.id === cleanEvent.id)) {
discussionFeedCache.setCachedReplies(rootInfo, [...cached, cleanEvent])
}
} }
close() close()
@ -887,12 +890,23 @@ export default function PostContent({
duration: 6000 duration: 6000
}) })
// Handle partial success // Handle partial success: show reply immediately (event already emitted by NostrProvider)
if (successCount > 0) { if (successCount > 0) {
// Clean up and close on partial success const partialEvent = (error as any).event ?? newEvent
if (parentEvent && partialEvent) {
const clean = { ...partialEvent }
delete (clean as any).relayStatuses
addReplies([clean])
const rootInfo = !isReplaceableEvent(parentEvent.kind)
? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey }
: { type: 'A' as const, id: getReplaceableCoordinateFromEvent(parentEvent), eventId: parentEvent.id, pubkey: parentEvent.pubkey, relay: client.getEventHint(parentEvent.id) }
const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? []
if (!cached.some((r) => r.id === clean.id)) {
discussionFeedCache.setCachedReplies(rootInfo, [...cached, clean])
}
}
postEditorCache.clearPostCache({ defaultContent, parentEvent }) postEditorCache.clearPostCache({ defaultContent, parentEvent })
if (draftEvent) deleteDraftEventCache(draftEvent) if (draftEvent) deleteDraftEventCache(draftEvent)
if (newEvent) addReplies([newEvent])
close() close()
} }
} else { } else {

7
src/components/PostEditor/PostOptions.tsx

@ -1,7 +1,7 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { StorageKey } from '@/constants' import storage from '@/services/local-storage.service'
import { Dispatch, SetStateAction, useEffect } from 'react' import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -27,15 +27,14 @@ export default function PostOptions({
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) setAddClientTag(storage.getAddClientTag())
setAddClientTag(stored === null ? true : stored === 'true') // Default to true if not set
}, []) }, [])
if (!show) return null if (!show) return null
const onAddClientTagChange = (checked: boolean) => { const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked)
setAddClientTag(checked) setAddClientTag(checked)
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
} }
const onNsfwChange = (checked: boolean) => { const onNsfwChange = (checked: boolean) => {

137
src/components/SessionRelaysTab/index.tsx

@ -0,0 +1,137 @@
import client from '@/services/client.service'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button'
type SessionDebug = {
strikedUrls: string[]
scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[]
presetWorking: string[]
presetStriked: string[]
}
function loadDebug(): SessionDebug {
return client.getSessionRelayDebug()
}
export default function SessionRelaysTab() {
const { t } = useTranslation()
const [debug, setDebug] = useState<SessionDebug | null>(null)
const refresh = useCallback(() => {
setDebug(loadDebug())
}, [])
useEffect(() => {
refresh()
}, [refresh])
if (debug === null) return null
const formatUrl = (url: string) => {
try {
const u = new URL(url)
return u.hostname || url
} catch {
return url
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{t('Session relays tab description')}
</p>
<Button variant="outline" size="sm" onClick={refresh} className="shrink-0">
<RefreshCw className="h-4 w-4 mr-1" />
{t('Refresh')}
</Button>
</div>
<section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-500" />
{t('Session relays preset working')}
</h3>
<p className="text-muted-foreground text-xs">
{t('Session relays preset working hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono">
{debug.presetWorking.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li>
) : (
debug.presetWorking.map((url) => (
<li key={url} className="truncate" title={url}>
{formatUrl(url)}
</li>
))
)}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2">
<XCircle className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Session relays preset striked')}
</h3>
<p className="text-muted-foreground text-xs">
{t('Session relays preset striked hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono">
{debug.presetStriked.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li>
) : (
debug.presetStriked.map((url) => (
<li key={url} className="truncate" title={url}>
{formatUrl(url)}
</li>
))
)}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" />
{t('Session relays scored random')}
</h3>
<p className="text-muted-foreground text-xs">
{t('Session relays scored random hint')}
</p>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.scoredRelays.length === 0 ? (
<li className="text-muted-foreground">{t('None')}</li>
) : (
debug.scoredRelays.map(({ url, successCount, avgLatencyMs }) => (
<li key={url} className="flex justify-between items-center gap-2 font-mono">
<span className="truncate min-w-0" title={url}>
{formatUrl(url)}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{successCount} {t('successes')} · ~{avgLatencyMs} ms
</span>
</li>
))
)}
</ul>
</section>
{debug.strikedUrls.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
{t('Session relays all striked')}
</h3>
<ul className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm font-mono text-muted-foreground">
{debug.strikedUrls.map((url) => (
<li key={url} className="truncate" title={url}>
{formatUrl(url)}
</li>
))}
</ul>
</section>
)}
</div>
)
}

8
src/constants.ts

@ -25,6 +25,8 @@ export const RECOMMENDED_BLOSSOM_SERVERS = [
export const StorageKey = { export const StorageKey = {
VERSION: 'version', VERSION: 'version',
THEME_SETTING: 'themeSetting', THEME_SETTING: 'themeSetting',
/** Resolved theme (light/dark) written by ThemeProvider; stored in IndexedDB. */
THEME: 'theme',
FONT_SIZE: 'fontSize', FONT_SIZE: 'fontSize',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
ACCOUNTS: 'accounts', ACCOUNTS: 'accounts',
@ -106,6 +108,12 @@ export const BOOKSTR_RELAY_URLS = [
'wss://orly-relay.imwald.eu' 'wss://orly-relay.imwald.eu'
] ]
/** Relays that must never be used for publishing (read-only aggregators, etc.). */
export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land']
/** Relays that block kind 1 (microblogging); skip for kind 1 read and write. */
export const KIND_1_BLOCKED_RELAY_URLS = ['wss://thecitadel.nostr1.com']
// Optimized relay list for read operations (includes aggregator) // Optimized relay list for read operations (includes aggregator)
export const FAST_READ_RELAY_URLS = [ export const FAST_READ_RELAY_URLS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',

16
src/i18n/locales/de.ts

@ -340,6 +340,22 @@ export default {
'relayType_relay_set': 'Relay-Set', 'relayType_relay_set': 'Relay-Set',
'relayType_contextual': 'Antwort/PN', 'relayType_contextual': 'Antwort/PN',
'relayType_randomly_selected': 'Zufällig (optional)', 'relayType_randomly_selected': 'Zufällig (optional)',
'Session relays': 'Session-Relays',
'Session relays tab description':
'Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays (bevorzugt schnellere, bewährte Relays beim Hinzufügen von Zufallsrelays).',
'Session relays preset working': 'Funktionierende Preset-Relays',
'Session relays preset working hint':
'Preset-Relays (App-Standard), die in dieser Session keine 3 Publish-Fehler erreicht haben.',
'Session relays preset striked': 'Gestrichene Preset-Relays',
'Session relays preset striked hint':
'Preset-Relays mit 3 Publish-Fehlern in dieser Session; werden für den Rest der Session übersprungen.',
'Session relays scored random': 'Bewertete Zufallsrelays',
'Session relays scored random hint':
'Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.',
'Session relays all striked': 'Alle gestrichenen Relays (alle Quellen)',
successes: 'Erfolge',
None: 'Keine',
'Cache & offline storage': 'Cache & Offline-Speicher',
'Paste or drop media files to upload': 'Paste or drop media files to upload':
'Füge Medien-Dateien ein oder ziehe sie hierher, um sie hochzuladen', 'Füge Medien-Dateien ein oder ziehe sie hierher, um sie hochzuladen',
Preview: 'Vorschau', Preview: 'Vorschau',

12
src/i18n/locales/en.ts

@ -402,6 +402,18 @@ export default {
'relayType_relay_set': 'Relay set', 'relayType_relay_set': 'Relay set',
'relayType_contextual': 'Reply/PM', 'relayType_contextual': 'Reply/PM',
'relayType_randomly_selected': 'Random (optional)', 'relayType_randomly_selected': 'Random (optional)',
'Session relays': 'Session relays',
'Session relays tab description': 'Relay logic for this session: working and striked preset relays, and scored random relays (used to prefer faster, proven relays when adding random relays to publish).',
'Session relays preset working': 'Working preset relays',
'Session relays preset working hint': 'Preset relays (from app defaults) that have not reached 3 publish failures this session.',
'Session relays preset striked': 'Striked preset relays',
'Session relays preset striked hint': 'Preset relays that have reached 3 publish failures this session and are skipped for the rest of the session.',
'Session relays scored random': 'Scored random relays',
'Session relays scored random hint': 'Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.',
'Session relays all striked': 'All striked relays (any source)',
successes: 'successes',
None: 'None',
'Cache & offline storage': 'Cache & offline storage',
'Paste or drop media files to upload': 'Paste or drop media files to upload', 'Paste or drop media files to upload': 'Paste or drop media files to upload',
Preview: 'Preview', Preview: 'Preview',
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?': 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':

1
src/lib/link.ts

@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general' export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation' export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds' export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toCacheSettings = () => '/settings/cache'
export const toProfileEditor = () => '/profile-editor' export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`

8
src/main.tsx

@ -25,6 +25,8 @@ window.addEventListener('resize', setVh)
window.addEventListener('orientationchange', setVh) window.addEventListener('orientationchange', setVh)
setVh() setVh()
const SESSION_STORAGE_KEY = 'jumble:session'
async function bootstrap() { async function bootstrap() {
try { try {
const r = await fetch('/config.json') const r = await fetch('/config.json')
@ -35,6 +37,12 @@ async function bootstrap() {
} catch { } catch {
window.__RUNTIME_CONFIG__ = {} window.__RUNTIME_CONFIG__ = {}
} }
// Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it.
try {
sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now()))
} catch {
// ignore quota or private browsing
}
await storage.initAsync() await storage.initAsync()
publishMonitorAnnouncementOnce() publishMonitorAnnouncementOnce()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

23
src/pages/secondary/CacheSettingsPage/index.tsx

@ -0,0 +1,23 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const CacheSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Cache & offline storage')}
>
<div className="px-4 py-3">
<CacheRelaysSetting />
</div>
</SecondaryPageLayout>
)
}
)
CacheSettingsPage.displayName = 'CacheSettingsPage'
export default CacheSettingsPage

12
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,6 +1,6 @@
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import CacheRelaysSetting from '@/components/CacheRelaysSetting' import SessionRelaysTab from '@/components/SessionRelaysTab'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
@ -15,8 +15,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
case '#mailbox': case '#mailbox':
setTabValue('mailbox') setTabValue('mailbox')
break break
case '#cache-relays': case '#session-relays':
setTabValue('cache-relays') setTabValue('session-relays')
break break
case '#favorite-relays': case '#favorite-relays':
setTabValue('favorite-relays') setTabValue('favorite-relays')
@ -30,7 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsList className="flex-col sm:flex-row h-auto sm:h-9"> <TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger> <TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache')}</TabsTrigger> <TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="favorite-relays"> <TabsContent value="favorite-relays">
<FavoriteRelaysSetting /> <FavoriteRelaysSetting />
@ -38,8 +38,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsContent value="mailbox"> <TabsContent value="mailbox">
<MailboxSetting /> <MailboxSetting />
</TabsContent> </TabsContent>
<TabsContent value="cache-relays"> <TabsContent value="session-relays">
<CacheRelaysSetting /> <SessionRelaysTab />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</SecondaryPageLayout> </SecondaryPageLayout>

9
src/pages/secondary/SettingsPage/index.tsx

@ -4,6 +4,7 @@ import {
toGeneralSettings, toGeneralSettings,
toPostSettings, toPostSettings,
toRelaySettings, toRelaySettings,
toCacheSettings,
toTranslation, toTranslation,
toWallet, toWallet,
toRssFeedSettings toRssFeedSettings
@ -15,6 +16,7 @@ import {
Check, Check,
ChevronRight, ChevronRight,
Copy, Copy,
Database,
Info, Info,
KeyRound, KeyRound,
Languages, Languages,
@ -50,6 +52,13 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
</div> </div>
<ChevronRight /> <ChevronRight />
</SettingItem> </SettingItem>
<SettingItem className="clickable" onClick={() => navigateToSettings(toCacheSettings())}>
<div className="flex items-center gap-4">
<Database />
<div>{t('Cache & offline storage')}</div>
</div>
<ChevronRight />
</SettingItem>
{!!pubkey && ( {!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toTranslation())}> <SettingItem className="clickable" onClick={() => navigateToSettings(toTranslation())}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

21
src/providers/NostrProvider/index.tsx

@ -1,5 +1,5 @@
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, StorageKey } from '@/constants' import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { import {
buildAltTag, buildAltTag,
buildClientTag, buildClientTag,
@ -875,7 +875,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const addClientTag = const addClientTag =
typeof options.addClientTag === 'boolean' typeof options.addClientTag === 'boolean'
? options.addClientTag ? options.addClientTag
: (typeof window !== 'undefined' && window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) !== 'false') : (typeof window !== 'undefined' && storage.getAddClientTag())
if (addClientTag) { if (addClientTag) {
draft.tags = draft.tags ?? [] draft.tags = draft.tags ?? []
draft.tags.push(buildClientTag(), buildAltTag()) draft.tags.push(buildClientTag(), buildAltTag())
@ -919,6 +919,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// This metadata is only for logging/feedback, not part of the actual event // This metadata is only for logging/feedback, not part of the actual event
const relayStatuses = publishResult.relayStatuses.length > 0 ? publishResult.relayStatuses : undefined const relayStatuses = publishResult.relayStatuses.length > 0 ? publishResult.relayStatuses : undefined
// If at least one relay accepted, cache and emit immediately so UI shows the event without waiting
if (publishResult.successCount >= 1) {
client.addEventToCache(event)
client.emitNewEvent(event)
}
// If publishing failed completely, throw an error so the form doesn't close // If publishing failed completely, throw an error so the form doesn't close
if (!publishResult.success) { if (!publishResult.success) {
logger.error('[Publish] Publishing failed to all relays!', { logger.error('[Publish] Publishing failed to all relays!', {
@ -934,26 +940,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
'Failed to publish to any relay' 'Failed to publish to any relay'
) )
;(error as any).relayStatuses = publishResult.relayStatuses ;(error as any).relayStatuses = publishResult.relayStatuses
if (publishResult.successCount >= 1) (error as any).event = event
throw error throw error
} }
logger.debug('[Publish] Publishing successful, attaching relayStatuses to event') logger.debug('[Publish] Publishing successful, attaching relayStatuses to event')
// Attach relayStatuses only temporarily for UI feedback, then remove it // Attach relayStatuses only temporarily for UI feedback, then remove it
// This prevents it from being included in the event when serialized
// Use a longer delay to ensure UI components can read it before deletion
if (relayStatuses) { if (relayStatuses) {
(event as any).relayStatuses = relayStatuses (event as any).relayStatuses = relayStatuses
// Remove it after a delay to allow UI components to read it
// Components should read it immediately after publish() returns
setTimeout(() => { setTimeout(() => {
delete (event as any).relayStatuses delete (event as any).relayStatuses
}, 100) }, 100)
} }
// Cache and emit already done above when successCount >= 1
// Emit newEvent immediately after publishing so UI components can react
// This ensures replies appear immediately in the note view
client.emitNewEvent(event)
logger.debug('[Publish] Returning event', { eventId: event.id?.substring(0, 8), hasRelayStatuses: !!relayStatuses }) logger.debug('[Publish] Returning event', { eventId: event.id?.substring(0, 8), hasRelayStatuses: !!relayStatuses })
return event return event
} catch (error) { } catch (error) {

15
src/providers/ThemeProvider.tsx

@ -21,9 +21,9 @@ const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undef
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [themeSetting, setThemeSetting] = useState<TThemeSetting>( const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
(localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system' () => storage.getThemeSetting()
) )
const [theme, setTheme] = useState<TTheme>('light') const [theme, setTheme] = useState<TTheme>(() => storage.getTheme())
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -54,13 +54,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
}, [themeSetting]) }, [themeSetting])
useEffect(() => { useEffect(() => {
const updateTheme = async () => { const root = window.document.documentElement
const root = window.document.documentElement root.classList.remove('light', 'dark')
root.classList.remove('light', 'dark') root.classList.add(theme)
root.classList.add(theme) storage.setTheme(theme)
localStorage.setItem('theme', theme)
}
updateTheme()
}, [theme]) }, [theme])
return ( return (

2
src/routes.tsx

@ -13,6 +13,7 @@ import ProfilePage from './pages/secondary/ProfilePage'
import RelayPage from './pages/secondary/RelayPage' import RelayPage from './pages/secondary/RelayPage'
import RelayReviewsPage from './pages/secondary/RelayReviewsPage' import RelayReviewsPage from './pages/secondary/RelayReviewsPage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
import CacheSettingsPage from './pages/secondary/CacheSettingsPage'
import RssFeedSettingsPage from './pages/secondary/RssFeedSettingsPage' import RssFeedSettingsPage from './pages/secondary/RssFeedSettingsPage'
import SearchPage from './pages/secondary/SearchPage' import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage' import SettingsPage from './pages/secondary/SettingsPage'
@ -40,6 +41,7 @@ const ROUTES = [
{ path: '/search', element: <SearchPage /> }, { path: '/search', element: <SearchPage /> },
{ path: '/settings', element: <SettingsPage /> }, { path: '/settings', element: <SettingsPage /> },
{ path: '/settings/relays', element: <RelaySettingsPage /> }, { path: '/settings/relays', element: <RelaySettingsPage /> },
{ path: '/settings/cache', element: <CacheSettingsPage /> },
{ path: '/settings/wallet', element: <WalletPage /> }, { path: '/settings/wallet', element: <WalletPage /> },
{ path: '/settings/posts', element: <PostSettingsPage /> }, { path: '/settings/posts', element: <PostSettingsPage /> },
{ path: '/settings/general', element: <GeneralSettingsPage /> }, { path: '/settings/general', element: <GeneralSettingsPage /> },

203
src/services/client.service.ts

@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, KIND_1_BLOCKED_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, READ_ONLY_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
@ -54,6 +54,8 @@ class ClientService extends EventTarget {
| undefined | undefined
> = {} > = {}
private eventCacheMap = new Map<string, Promise<NEvent | undefined>>() private eventCacheMap = new Map<string, Promise<NEvent | undefined>>()
/** Session-only: recently seen events (e.g. from feed) so back-navigation doesn't re-query. Bounded size, keyed by hex id. */
private sessionEventCache = new LRUCache<string, NEvent>({ max: 500, ttl: 1000 * 60 * 30 })
private relayListRequestCache = new Map<string, Promise<TRelayList>>() // Cache in-flight relay list requests private relayListRequestCache = new Map<string, Promise<TRelayList>>() // Cache in-flight relay list requests
private eventDataLoader = new DataLoader<string, NEvent | undefined>( private eventDataLoader = new DataLoader<string, NEvent | undefined>(
(ids) => Promise.all(ids.map((id) => this._fetchEvent(id))), (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
@ -72,6 +74,13 @@ class ClientService extends EventTarget {
private activeSubCountByRelay = new Map<string, number>() private activeSubCountByRelay = new Map<string, number>()
private subSlotWaitQueueByRelay = new Map<string, Array<() => void>>() private subSlotWaitQueueByRelay = new Map<string, Array<() => void>>()
/** Session-only: relay URL -> publish failure count; after 3 strikes we skip that relay for the rest of the session. */
private publishStrikeCount = new Map<string, number>()
private static readonly PUBLISH_STRIKES_THRESHOLD = 3
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
constructor() { constructor() {
super() super()
this.pool = new SimplePool() this.pool = new SimplePool()
@ -359,17 +368,131 @@ class ClientService extends EventTarget {
}) })
} }
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => {
const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false
if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false
return true
})
return relays return relays
} }
/** Record publish failures for 3-strikes session policy (skip relay for rest of session after 3 rejections). */
private recordPublishFailures(relayStatuses: { url: string; success: boolean; error?: string }[]) {
relayStatuses.filter((s) => !s.success).forEach((s) => {
const n = normalizeUrl(s.url) || s.url
const count = (this.publishStrikeCount.get(n) ?? 0) + 1
this.publishStrikeCount.set(n, count)
if (count >= ClientService.PUBLISH_STRIKES_THRESHOLD) {
logger.debug('[PublishEvent] Relay reached 3 strikes, skipping for session', { url: n })
}
})
}
/** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess(url: string, latencyMs: number) {
const n = normalizeUrl(url) || url
const cur = this.sessionRelayPublishStats.get(n)
if (cur) {
cur.successCount += 1
cur.sumLatencyMs += latencyMs
} else {
this.sessionRelayPublishStats.set(n, { successCount: 1, sumLatencyMs: latencyMs })
}
}
/**
* Session-only debug info for the Session Relays settings tab: working/striked preset relays and scored random relays.
*/
getSessionRelayDebug(): {
strikedUrls: string[]
scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[]
presetWorking: string[]
presetStriked: string[]
} {
const presetSet = new Set<string>()
for (const u of [...FAST_WRITE_RELAY_URLS, ...BIG_RELAY_URLS]) {
const n = normalizeUrl(u) || u
if (n) presetSet.add(n)
}
const preset = Array.from(presetSet)
const strikedUrls = Array.from(this.publishStrikeCount.entries())
.filter(([, count]) => count >= ClientService.PUBLISH_STRIKES_THRESHOLD)
.map(([url]) => url)
const presetStriked = preset.filter((url) => (this.publishStrikeCount.get(url) ?? 0) >= ClientService.PUBLISH_STRIKES_THRESHOLD)
const presetWorking = preset.filter((url) => (this.publishStrikeCount.get(url) ?? 0) < ClientService.PUBLISH_STRIKES_THRESHOLD)
const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({
url,
successCount: s.successCount,
avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount)
}))
scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs)
return { strikedUrls, scoredRelays, presetWorking, presetStriked }
}
/**
* From a list of candidate relay URLs (e.g. public lively), return up to `count` relays,
* preferring those that have succeeded and been fast this session. Excludes 3-strike and read-only relays.
*/
getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const normalizedCandidates = candidateUrls
.map((u) => normalizeUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n))
const unique = Array.from(new Set(normalizedCandidates))
const notStruckOut = unique.filter((n) => (this.publishStrikeCount.get(n) ?? 0) < ClientService.PUBLISH_STRIKES_THRESHOLD)
const preferred: string[] = []
const rest: string[] = []
for (const url of notStruckOut) {
const stats = this.sessionRelayPublishStats.get(url)
if (stats && stats.successCount >= 1) preferred.push(url)
else rest.push(url)
}
preferred.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(a)!
const sb = this.sessionRelayPublishStats.get(b)!
const avgA = sa.sumLatencyMs / sa.successCount
const avgB = sb.sumLatencyMs / sb.successCount
return avgA - avgB
})
const result: string[] = []
let pi = 0
let ri = 0
const shuffledRest = rest.slice().sort(() => Math.random() - 0.5)
while (result.length < count && (pi < preferred.length || ri < shuffledRest.length)) {
if (pi < preferred.length) {
result.push(preferred[pi++])
} else if (ri < shuffledRest.length) {
result.push(shuffledRest[ri++])
}
}
return result.slice(0, count)
}
async publishEvent(relayUrls: string[], event: NEvent) { async publishEvent(relayUrls: string[], event: NEvent) {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
let filtered = relayUrls.filter((url) => {
const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false
if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false
const strikes = this.publishStrikeCount.get(n) ?? 0
if (strikes >= ClientService.PUBLISH_STRIKES_THRESHOLD) return false
return true
})
filtered = Array.from(new Set(filtered))
logger.debug('[PublishEvent] Starting publishEvent', { logger.debug('[PublishEvent] Starting publishEvent', {
eventId: event.id?.substring(0, 8), eventId: event.id?.substring(0, 8),
kind: event.kind, kind: event.kind,
relayCount: relayUrls.length relayCount: filtered.length,
skippedStrikes: relayUrls.length - filtered.length
}) })
const uniqueRelayUrls = Array.from(new Set(relayUrls)) const uniqueRelayUrls = filtered
if (event.kind === kinds.RelayList || event.kind === ExtendedKind.FAVORITE_RELAYS) { if (event.kind === kinds.RelayList || event.kind === ExtendedKind.FAVORITE_RELAYS) {
logger.info('[PublishEvent] Publishing event to relays', { logger.info('[PublishEvent] Publishing event to relays', {
eventId: event.id?.substring(0, 8), eventId: event.id?.substring(0, 8),
@ -383,6 +506,8 @@ class ClientService extends EventTarget {
const relayStatuses: { url: string; success: boolean; error?: string }[] = [] const relayStatuses: { url: string; success: boolean; error?: string }[] = []
// eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this
return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => { return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => {
let successCount = 0 let successCount = 0
let finishedCount = 0 let finishedCount = 0
@ -418,6 +543,7 @@ class ClientService extends EventTarget {
// Ensure we resolve even if not all relays finished // Ensure we resolve even if not all relays finished
if (!hasResolved) { if (!hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving due to timeout', { logger.debug('[PublishEvent] Resolving due to timeout', {
success: successCount >= uniqueRelayUrls.length / 3, success: successCount >= uniqueRelayUrls.length / 3,
successCount, successCount,
@ -436,6 +562,7 @@ class ClientService extends EventTarget {
logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') logger.debug('[PublishEvent] Starting Promise.allSettled for all relays')
Promise.allSettled( Promise.allSettled(
uniqueRelayUrls.map(async (url, index) => { uniqueRelayUrls.map(async (url, index) => {
const startMs = Date.now()
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url })
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
@ -480,6 +607,7 @@ class ClientService extends EventTarget {
.publish(event) .publish(event)
.then(() => { .then(() => {
logger.debug(`[PublishEvent] Successfully published to relay`, { url }) logger.debug(`[PublishEvent] Successfully published to relay`, { url })
that.recordPublishSuccess(url, Date.now() - startMs)
this.trackEventSeenOn(event.id, relay) this.trackEventSeenOn(event.id, relay)
successCount++ successCount++
relayStatuses.push({ url, success: true }) relayStatuses.push({ url, success: true })
@ -500,6 +628,7 @@ class ClientService extends EventTarget {
}) })
.then(() => { .then(() => {
logger.debug(`[PublishEvent] Successfully published after auth`, { url }) logger.debug(`[PublishEvent] Successfully published after auth`, { url })
that.recordPublishSuccess(url, Date.now() - startMs)
this.trackEventSeenOn(event.id, relay) this.trackEventSeenOn(event.id, relay)
successCount++ successCount++
relayStatuses.push({ url, success: true }) relayStatuses.push({ url, success: true })
@ -548,6 +677,7 @@ class ClientService extends EventTarget {
} }
if (currentFinished >= uniqueRelayUrls.length && !hasResolved) { if (currentFinished >= uniqueRelayUrls.length && !hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] All relays finished, resolving', { logger.debug('[PublishEvent] All relays finished, resolving', {
success: successCount >= uniqueRelayUrls.length / 3, success: successCount >= uniqueRelayUrls.length / 3,
successCount, successCount,
@ -570,6 +700,7 @@ class ClientService extends EventTarget {
setTimeout(() => { setTimeout(() => {
if (!hasResolved) { if (!hasResolved) {
hasResolved = true hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving early with enough successes', { logger.debug('[PublishEvent] Resolving early with enough successes', {
success: true, success: true,
successCount, successCount,
@ -765,9 +896,15 @@ class ClientService extends EventTarget {
onAllClose?: (reasons: string[]) => void onAllClose?: (reasons: string[]) => void
} }
) { ) {
const relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1))
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
}
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
const _knownIds = new Set<string>() const _knownIds = new Set<string>()
@ -1318,9 +1455,16 @@ class ClientService extends EventTarget {
globalTimeout?: number globalTimeout?: number
} = {} } = {}
) { ) {
const relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
if (relays.length === 0) relays = [...BIG_RELAY_URLS]
const filters = Array.isArray(filter) ? filter : [filter]
const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1))
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
}
const events = await this.query( const events = await this.query(
relays.length > 0 ? relays : BIG_RELAY_URLS, relays,
filter, filter,
onevent, onevent,
{ eoseTimeout, globalTimeout } { eoseTimeout, globalTimeout }
@ -1334,27 +1478,29 @@ class ClientService extends EventTarget {
} }
async fetchEvent(id: string): Promise<NEvent | undefined> { async fetchEvent(id: string): Promise<NEvent | undefined> {
if (!/^[0-9a-f]{64}$/.test(id)) { let hexId: string | undefined
let eventId: string | undefined if (/^[0-9a-f]{64}$/.test(id)) {
hexId = id
} else {
const { type, data } = nip19.decode(id) const { type, data } = nip19.decode(id)
switch (type) { switch (type) {
case 'note': case 'note':
eventId = data hexId = data
break break
case 'nevent': case 'nevent':
eventId = data.id hexId = data.id
break break
case 'naddr': case 'naddr':
break break
} }
if (eventId) {
const cache = this.eventCacheMap.get(eventId)
if (cache) {
return cache
}
}
} }
return this.eventDataLoader.load(id) if (hexId) {
const fromSession = this.sessionEventCache.get(hexId)
if (fromSession) return fromSession
const cachedPromise = this.eventCacheMap.get(hexId)
if (cachedPromise) return cachedPromise
}
return this.eventDataLoader.load(hexId ?? id)
} }
addEventToCache(event: NEvent) { addEventToCache(event: NEvent) {
@ -1362,6 +1508,7 @@ class ClientService extends EventTarget {
const cleanEvent = { ...event } as NEvent const cleanEvent = { ...event } as NEvent
delete (cleanEvent as any).relayStatuses delete (cleanEvent as any).relayStatuses
this.sessionEventCache.set(cleanEvent.id, cleanEvent)
this.eventDataLoader.prime(cleanEvent.id, Promise.resolve(cleanEvent)) this.eventDataLoader.prime(cleanEvent.id, Promise.resolve(cleanEvent))
// Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere // Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere
} }
@ -1815,6 +1962,7 @@ class ClientService extends EventTarget {
clearInMemoryCaches(): void { clearInMemoryCaches(): void {
this.relayListRequestCache.clear() this.relayListRequestCache.clear()
this.eventDataLoader.clearAll() this.eventDataLoader.clearAll()
this.sessionEventCache.clear()
this.replaceableEventFromBigRelaysDataloader.clearAll() this.replaceableEventFromBigRelaysDataloader.clearAll()
this.followingFavoriteRelaysCache?.clear() this.followingFavoriteRelaysCache?.clear()
logger.info('[ClientService] In-memory caches cleared') logger.info('[ClientService] In-memory caches cleared')
@ -2057,7 +2205,26 @@ class ClientService extends EventTarget {
const eventsMap = new Map<string, NEvent>() const eventsMap = new Map<string, NEvent>()
await Promise.allSettled( await Promise.allSettled(
Array.from(groups.entries()).map(async ([kind, pubkeys]) => { Array.from(groups.entries()).map(async ([kind, pubkeys]) => {
const events = await this.query(BIG_RELAY_URLS, { // Profiles (kind 0) and relay lists (10002): use broader relay set + current user's inboxes if logged in
let relayUrls: string[]
if (kind === kinds.Metadata || kind === kinds.RelayList) {
const base = Array.from(new Set([...BIG_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS]))
if (this.pubkey) {
const userRelayEvent = await indexedDb.getReplaceableEvent(this.pubkey, kinds.RelayList)
if (userRelayEvent) {
const list = getRelayListFromEvent(userRelayEvent)
const read = (list?.read ?? []).map((u) => normalizeUrl(u)).filter(Boolean) as string[]
relayUrls = Array.from(new Set([...base, ...read]))
} else {
relayUrls = base
}
} else {
relayUrls = base
}
} else {
relayUrls = BIG_RELAY_URLS
}
const events = await this.query(relayUrls, {
authors: pubkeys, authors: pubkeys,
kinds: [kind] kinds: [kind]
}) })

46
src/services/local-storage.service.ts

@ -19,6 +19,7 @@ import {
TNoteListMode, TNoteListMode,
TNotificationStyle, TNotificationStyle,
TRelaySet, TRelaySet,
TTheme,
TThemeSetting, TThemeSetting,
} from '@/types' } from '@/types'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
@ -27,6 +28,8 @@ import indexedDb from './indexed-db.service'
const SETTINGS_KEYS = [ const SETTINGS_KEYS = [
StorageKey.RELAY_SETS, StorageKey.RELAY_SETS,
StorageKey.THEME_SETTING, StorageKey.THEME_SETTING,
StorageKey.THEME,
StorageKey.ADD_CLIENT_TAG,
StorageKey.FONT_SIZE, StorageKey.FONT_SIZE,
StorageKey.NOTE_LIST_MODE, StorageKey.NOTE_LIST_MODE,
StorageKey.ACCOUNTS, StorageKey.ACCOUNTS,
@ -70,6 +73,8 @@ class LocalStorageService {
private relaySets: TRelaySet[] = [] private relaySets: TRelaySet[] = []
private themeSetting: TThemeSetting = 'system' private themeSetting: TThemeSetting = 'system'
private theme: TTheme = 'light'
private addClientTag: boolean = true
private fontSize: TFontSize = 'medium' private fontSize: TFontSize = 'medium'
private accounts: TAccount[] = [] private accounts: TAccount[] = []
private currentAccount: TAccount | null = null private currentAccount: TAccount | null = null
@ -118,6 +123,10 @@ class LocalStorageService {
init() { init() {
this.themeSetting = this.themeSetting =
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system' (window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
const themeStr = window.localStorage.getItem(StorageKey.THEME) as TTheme | null
this.theme = themeStr === 'dark' || themeStr === 'light' ? themeStr : 'light'
const addClientTagStr = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG)
this.addClientTag = addClientTagStr === null ? true : addClientTagStr === 'true'
this.fontSize = this.fontSize =
(window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? 'medium' (window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? 'medium'
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS) const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
@ -389,10 +398,13 @@ class LocalStorageService {
window.localStorage.removeItem(StorageKey.FEED_TYPE) window.localStorage.removeItem(StorageKey.FEED_TYPE)
} }
/** Persist a setting to both localStorage and IndexedDB (source of truth is IndexedDB). */ /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */
private persistSetting(key: string, value: string): void { private persistSetting(key: string, value: string): void {
if ((SETTINGS_KEYS as readonly string[]).includes(key)) {
indexedDb.setSetting(key, value).catch(() => {})
return
}
window.localStorage.setItem(key, value) window.localStorage.setItem(key, value)
indexedDb.setSetting(key, value).catch(() => {})
} }
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null
@ -411,10 +423,18 @@ class LocalStorageService {
} else { } else {
await this.migrateToIdb() await this.migrateToIdb()
} }
this.clearSettingsFromLocalStorage()
})() })()
return this.initPromise return this.initPromise
} }
/** Remove SETTINGS_KEYS from localStorage so we don't duplicate; source of truth is IndexedDB. */
private clearSettingsFromLocalStorage(): void {
for (const key of SETTINGS_KEYS) {
window.localStorage.removeItem(key)
}
}
private async migrateToIdb(): Promise<void> { private async migrateToIdb(): Promise<void> {
for (const key of SETTINGS_KEYS) { for (const key of SETTINGS_KEYS) {
const value = window.localStorage.getItem(key) const value = window.localStorage.getItem(key)
@ -427,6 +447,10 @@ class LocalStorageService {
if (get(StorageKey.THEME_SETTING) != null) { if (get(StorageKey.THEME_SETTING) != null) {
this.themeSetting = (get(StorageKey.THEME_SETTING) as TThemeSetting) ?? this.themeSetting this.themeSetting = (get(StorageKey.THEME_SETTING) as TThemeSetting) ?? this.themeSetting
} }
const themeStr = get(StorageKey.THEME)
if (themeStr === 'dark' || themeStr === 'light') this.theme = themeStr
const addClientTagStr = get(StorageKey.ADD_CLIENT_TAG)
if (addClientTagStr != null) this.addClientTag = addClientTagStr === 'true'
if (get(StorageKey.FONT_SIZE) != null) { if (get(StorageKey.FONT_SIZE) != null) {
this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize
} }
@ -527,6 +551,24 @@ class LocalStorageService {
this.themeSetting = themeSetting this.themeSetting = themeSetting
} }
getTheme(): TTheme {
return this.theme
}
setTheme(theme: TTheme) {
this.theme = theme
this.persistSetting(StorageKey.THEME, theme)
}
getAddClientTag(): boolean {
return this.addClientTag
}
setAddClientTag(value: boolean) {
this.addClientTag = value
this.persistSetting(StorageKey.ADD_CLIENT_TAG, value.toString())
}
getFontSize() { getFontSize() {
return this.fontSize return this.fontSize
} }

1
src/services/navigation.service.ts

@ -220,6 +220,7 @@ export class NavigationService {
if (viewType === 'settings-sub') { if (viewType === 'settings-sub') {
if (pathname.includes('/general')) return 'General Settings' if (pathname.includes('/general')) return 'General Settings'
if (pathname.includes('/relays')) return 'Relays and Storage Settings' if (pathname.includes('/relays')) return 'Relays and Storage Settings'
if (pathname.includes('/cache')) return 'Cache & offline storage'
if (pathname.includes('/wallet')) return 'Wallet Settings' if (pathname.includes('/wallet')) return 'Wallet Settings'
if (pathname.includes('/posts')) return 'Post Settings' if (pathname.includes('/posts')) return 'Post Settings'
if (pathname.includes('/translation')) return 'Translation Settings' if (pathname.includes('/translation')) return 'Translation Settings'

6
src/services/relay-selection.service.ts

@ -140,7 +140,7 @@ class RelaySelectionService {
openFrom.forEach((url) => addRelay(url, 'open_from')) openFrom.forEach((url) => addRelay(url, 'open_from'))
} }
// Random relays: always add 3 random public lively relays to the list; selected by default only when setting is ON // Random relays: prefer session-proven fast relays, then fill with random from rest (selection only random between sessions)
const randomRelayUrls: string[] = [] const randomRelayUrls: string[] = []
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
@ -150,8 +150,8 @@ class RelaySelectionService {
const n = normalizeUrl(u) || u const n = normalizeUrl(u) || u
return !existing.has(n) return !existing.has(n)
}) })
const shuffled = candidates.slice().sort(() => Math.random() - 0.5) const preferred = client.getPreferredRelaysForRandom(candidates, 3)
shuffled.slice(0, 3).forEach((url) => { preferred.forEach((url) => {
const normalized = normalizeUrl(url) || url const normalized = normalizeUrl(url) || url
addRelay(normalized, 'randomly_selected') addRelay(normalized, 'randomly_selected')
randomRelayUrls.push(normalized) randomRelayUrls.push(normalized)

Loading…
Cancel
Save