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. 42
      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. 23
      src/providers/NostrProvider/index.tsx
  16. 15
      src/providers/ThemeProvider.tsx
  17. 2
      src/routes.tsx
  18. 205
      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 @@ @@ -15,6 +15,7 @@
/>
<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="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)" />

36
public/manifest.webmanifest

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

42
src/components/PostEditor/PostContent.tsx

@ -46,7 +46,8 @@ import { getMediaKindFromFile } from '@/lib/media-kind-detection' @@ -46,7 +46,8 @@ import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -89,7 +90,7 @@ export default function PostContent({ @@ -89,7 +90,7 @@ export default function PostContent({
{ file: File; progress: number; cancel: () => void }[]
>([])
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 [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false)
@ -256,7 +257,7 @@ export default function PostContent({ @@ -256,7 +257,7 @@ export default function PostContent({
relays: []
}
)
setAddClientTag(cachedSettings.addClientTag ?? true) // Default to true
setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag())
}
return
}
@ -845,17 +846,19 @@ export default function PostContent({ @@ -845,17 +846,19 @@ export default function PostContent({
// Remove relayStatuses before storing the event (it's only for UI feedback)
const cleanEvent = { ...newEvent }
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) {
addReplies([cleanEvent])
// Also dispatch the newEvent to ensure ReplyNoteList picks it up
// The event is already dispatched by publish(), but we do it again to ensure it's caught
setTimeout(() => {
client.emitNewEvent(cleanEvent)
}, 100)
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 === cleanEvent.id)) {
discussionFeedCache.setCachedReplies(rootInfo, [...cached, cleanEvent])
}
}
close()
} catch (error) {
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise
@ -887,12 +890,23 @@ export default function PostContent({ @@ -887,12 +890,23 @@ export default function PostContent({
duration: 6000
})
// Handle partial success
// Handle partial success: show reply immediately (event already emitted by NostrProvider)
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 })
if (draftEvent) deleteDraftEventCache(draftEvent)
if (newEvent) addReplies([newEvent])
close()
}
} else {

7
src/components/PostEditor/PostOptions.tsx

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

137
src/components/SessionRelaysTab/index.tsx

@ -0,0 +1,137 @@ @@ -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 = [ @@ -25,6 +25,8 @@ export const RECOMMENDED_BLOSSOM_SERVERS = [
export const StorageKey = {
VERSION: 'version',
THEME_SETTING: 'themeSetting',
/** Resolved theme (light/dark) written by ThemeProvider; stored in IndexedDB. */
THEME: 'theme',
FONT_SIZE: 'fontSize',
RELAY_SETS: 'relaySets',
ACCOUNTS: 'accounts',
@ -106,6 +108,12 @@ export const BOOKSTR_RELAY_URLS = [ @@ -106,6 +108,12 @@ export const BOOKSTR_RELAY_URLS = [
'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)
export const FAST_READ_RELAY_URLS = [
'wss://theforest.nostr1.com',

16
src/i18n/locales/de.ts

@ -340,6 +340,22 @@ export default { @@ -340,6 +340,22 @@ export default {
'relayType_relay_set': 'Relay-Set',
'relayType_contextual': 'Antwort/PN',
'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':
'Füge Medien-Dateien ein oder ziehe sie hierher, um sie hochzuladen',
Preview: 'Vorschau',

12
src/i18n/locales/en.ts

@ -402,6 +402,18 @@ export default { @@ -402,6 +402,18 @@ export default {
'relayType_relay_set': 'Relay set',
'relayType_contextual': 'Reply/PM',
'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',
Preview: 'Preview',
'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' @@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toCacheSettings = () => '/settings/cache'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`

8
src/main.tsx

@ -25,6 +25,8 @@ window.addEventListener('resize', setVh) @@ -25,6 +25,8 @@ window.addEventListener('resize', setVh)
window.addEventListener('orientationchange', setVh)
setVh()
const SESSION_STORAGE_KEY = 'jumble:session'
async function bootstrap() {
try {
const r = await fetch('/config.json')
@ -35,6 +37,12 @@ async function bootstrap() { @@ -35,6 +37,12 @@ async function bootstrap() {
} catch {
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()
publishMonitorAnnouncementOnce()
createRoot(document.getElementById('root')!).render(

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

@ -0,0 +1,23 @@ @@ -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 @@ @@ -1,6 +1,6 @@
import MailboxSetting from '@/components/MailboxSetting'
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 SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react'
@ -15,8 +15,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -15,8 +15,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
case '#mailbox':
setTabValue('mailbox')
break
case '#cache-relays':
setTabValue('cache-relays')
case '#session-relays':
setTabValue('session-relays')
break
case '#favorite-relays':
setTabValue('favorite-relays')
@ -30,7 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -30,7 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<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="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>
<TabsContent value="favorite-relays">
<FavoriteRelaysSetting />
@ -38,8 +38,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -38,8 +38,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsContent value="mailbox">
<MailboxSetting />
</TabsContent>
<TabsContent value="cache-relays">
<CacheRelaysSetting />
<TabsContent value="session-relays">
<SessionRelaysTab />
</TabsContent>
</Tabs>
</SecondaryPageLayout>

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

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

23
src/providers/NostrProvider/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
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 {
buildAltTag,
buildClientTag,
@ -875,7 +875,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -875,7 +875,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const addClientTag =
typeof options.addClientTag === 'boolean'
? options.addClientTag
: (typeof window !== 'undefined' && window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) !== 'false')
: (typeof window !== 'undefined' && storage.getAddClientTag())
if (addClientTag) {
draft.tags = draft.tags ?? []
draft.tags.push(buildClientTag(), buildAltTag())
@ -919,6 +919,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -919,6 +919,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// This metadata is only for logging/feedback, not part of the actual event
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 (!publishResult.success) {
logger.error('[Publish] Publishing failed to all relays!', {
@ -934,26 +940,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -934,26 +940,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
'Failed to publish to any relay'
)
;(error as any).relayStatuses = publishResult.relayStatuses
if (publishResult.successCount >= 1) (error as any).event = event
throw error
}
logger.debug('[Publish] Publishing successful, attaching relayStatuses to event')
// 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) {
(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(() => {
delete (event as any).relayStatuses
}, 100)
}
// Emit newEvent immediately after publishing so UI components can react
// This ensures replies appear immediately in the note view
client.emitNewEvent(event)
// Cache and emit already done above when successCount >= 1
logger.debug('[Publish] Returning event', { eventId: event.id?.substring(0, 8), hasRelayStatuses: !!relayStatuses })
return event
} catch (error) {

15
src/providers/ThemeProvider.tsx

@ -21,9 +21,9 @@ const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undef @@ -21,9 +21,9 @@ const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undef
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
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(() => {
const init = async () => {
@ -54,13 +54,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) { @@ -54,13 +54,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
}, [themeSetting])
useEffect(() => {
const updateTheme = async () => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}
updateTheme()
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
storage.setTheme(theme)
}, [theme])
return (

2
src/routes.tsx

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

205
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -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. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
@ -54,6 +54,8 @@ class ClientService extends EventTarget { @@ -54,6 +54,8 @@ class ClientService extends EventTarget {
| 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 eventDataLoader = new DataLoader<string, NEvent | undefined>(
(ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
@ -72,6 +74,13 @@ class ClientService extends EventTarget { @@ -72,6 +74,13 @@ class ClientService extends EventTarget {
private activeSubCountByRelay = new Map<string, number>()
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() {
super()
this.pool = new SimplePool()
@ -359,17 +368,131 @@ class ClientService extends EventTarget { @@ -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
}
/** 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) {
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', {
eventId: event.id?.substring(0, 8),
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) {
logger.info('[PublishEvent] Publishing event to relays', {
eventId: event.id?.substring(0, 8),
@ -383,6 +506,8 @@ class ClientService extends EventTarget { @@ -383,6 +506,8 @@ class ClientService extends EventTarget {
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) => {
let successCount = 0
let finishedCount = 0
@ -418,6 +543,7 @@ class ClientService extends EventTarget { @@ -418,6 +543,7 @@ class ClientService extends EventTarget {
// Ensure we resolve even if not all relays finished
if (!hasResolved) {
hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving due to timeout', {
success: successCount >= uniqueRelayUrls.length / 3,
successCount,
@ -436,6 +562,7 @@ class ClientService extends EventTarget { @@ -436,6 +562,7 @@ class ClientService extends EventTarget {
logger.debug('[PublishEvent] Starting Promise.allSettled for all relays')
Promise.allSettled(
uniqueRelayUrls.map(async (url, index) => {
const startMs = Date.now()
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url })
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
@ -480,6 +607,7 @@ class ClientService extends EventTarget { @@ -480,6 +607,7 @@ class ClientService extends EventTarget {
.publish(event)
.then(() => {
logger.debug(`[PublishEvent] Successfully published to relay`, { url })
that.recordPublishSuccess(url, Date.now() - startMs)
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
@ -500,6 +628,7 @@ class ClientService extends EventTarget { @@ -500,6 +628,7 @@ class ClientService extends EventTarget {
})
.then(() => {
logger.debug(`[PublishEvent] Successfully published after auth`, { url })
that.recordPublishSuccess(url, Date.now() - startMs)
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
@ -548,6 +677,7 @@ class ClientService extends EventTarget { @@ -548,6 +677,7 @@ class ClientService extends EventTarget {
}
if (currentFinished >= uniqueRelayUrls.length && !hasResolved) {
hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] All relays finished, resolving', {
success: successCount >= uniqueRelayUrls.length / 3,
successCount,
@ -570,6 +700,7 @@ class ClientService extends EventTarget { @@ -570,6 +700,7 @@ class ClientService extends EventTarget {
setTimeout(() => {
if (!hasResolved) {
hasResolved = true
client.recordPublishFailures(relayStatuses)
logger.debug('[PublishEvent] Resolving early with enough successes', {
success: true,
successCount,
@ -765,9 +896,15 @@ class ClientService extends EventTarget { @@ -765,9 +896,15 @@ class ClientService extends EventTarget {
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 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
const that = this
const _knownIds = new Set<string>()
@ -1318,9 +1455,16 @@ class ClientService extends EventTarget { @@ -1318,9 +1455,16 @@ class ClientService extends EventTarget {
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(
relays.length > 0 ? relays : BIG_RELAY_URLS,
relays,
filter,
onevent,
{ eoseTimeout, globalTimeout }
@ -1334,27 +1478,29 @@ class ClientService extends EventTarget { @@ -1334,27 +1478,29 @@ class ClientService extends EventTarget {
}
async fetchEvent(id: string): Promise<NEvent | undefined> {
if (!/^[0-9a-f]{64}$/.test(id)) {
let eventId: string | undefined
let hexId: string | undefined
if (/^[0-9a-f]{64}$/.test(id)) {
hexId = id
} else {
const { type, data } = nip19.decode(id)
switch (type) {
case 'note':
eventId = data
hexId = data
break
case 'nevent':
eventId = data.id
hexId = data.id
break
case 'naddr':
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) {
@ -1362,6 +1508,7 @@ class ClientService extends EventTarget { @@ -1362,6 +1508,7 @@ class ClientService extends EventTarget {
const cleanEvent = { ...event } as NEvent
delete (cleanEvent as any).relayStatuses
this.sessionEventCache.set(cleanEvent.id, cleanEvent)
this.eventDataLoader.prime(cleanEvent.id, Promise.resolve(cleanEvent))
// Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere
}
@ -1815,6 +1962,7 @@ class ClientService extends EventTarget { @@ -1815,6 +1962,7 @@ class ClientService extends EventTarget {
clearInMemoryCaches(): void {
this.relayListRequestCache.clear()
this.eventDataLoader.clearAll()
this.sessionEventCache.clear()
this.replaceableEventFromBigRelaysDataloader.clearAll()
this.followingFavoriteRelaysCache?.clear()
logger.info('[ClientService] In-memory caches cleared')
@ -2057,7 +2205,26 @@ class ClientService extends EventTarget { @@ -2057,7 +2205,26 @@ class ClientService extends EventTarget {
const eventsMap = new Map<string, NEvent>()
await Promise.allSettled(
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,
kinds: [kind]
})

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

@ -19,6 +19,7 @@ import { @@ -19,6 +19,7 @@ import {
TNoteListMode,
TNotificationStyle,
TRelaySet,
TTheme,
TThemeSetting,
} from '@/types'
import indexedDb from './indexed-db.service'
@ -27,6 +28,8 @@ import indexedDb from './indexed-db.service' @@ -27,6 +28,8 @@ import indexedDb from './indexed-db.service'
const SETTINGS_KEYS = [
StorageKey.RELAY_SETS,
StorageKey.THEME_SETTING,
StorageKey.THEME,
StorageKey.ADD_CLIENT_TAG,
StorageKey.FONT_SIZE,
StorageKey.NOTE_LIST_MODE,
StorageKey.ACCOUNTS,
@ -70,6 +73,8 @@ class LocalStorageService { @@ -70,6 +73,8 @@ class LocalStorageService {
private relaySets: TRelaySet[] = []
private themeSetting: TThemeSetting = 'system'
private theme: TTheme = 'light'
private addClientTag: boolean = true
private fontSize: TFontSize = 'medium'
private accounts: TAccount[] = []
private currentAccount: TAccount | null = null
@ -118,6 +123,10 @@ class LocalStorageService { @@ -118,6 +123,10 @@ class LocalStorageService {
init() {
this.themeSetting =
(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 =
(window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? 'medium'
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
@ -389,10 +398,13 @@ class LocalStorageService { @@ -389,10 +398,13 @@ class LocalStorageService {
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 {
if ((SETTINGS_KEYS as readonly string[]).includes(key)) {
indexedDb.setSetting(key, value).catch(() => {})
return
}
window.localStorage.setItem(key, value)
indexedDb.setSetting(key, value).catch(() => {})
}
private initPromise: Promise<void> | null = null
@ -411,10 +423,18 @@ class LocalStorageService { @@ -411,10 +423,18 @@ class LocalStorageService {
} else {
await this.migrateToIdb()
}
this.clearSettingsFromLocalStorage()
})()
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> {
for (const key of SETTINGS_KEYS) {
const value = window.localStorage.getItem(key)
@ -427,6 +447,10 @@ class LocalStorageService { @@ -427,6 +447,10 @@ class LocalStorageService {
if (get(StorageKey.THEME_SETTING) != null) {
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) {
this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize
}
@ -527,6 +551,24 @@ class LocalStorageService { @@ -527,6 +551,24 @@ class LocalStorageService {
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() {
return this.fontSize
}

1
src/services/navigation.service.ts

@ -220,6 +220,7 @@ export class NavigationService { @@ -220,6 +220,7 @@ export class NavigationService {
if (viewType === 'settings-sub') {
if (pathname.includes('/general')) return 'General 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('/posts')) return 'Post Settings'
if (pathname.includes('/translation')) return 'Translation Settings'

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

@ -140,7 +140,7 @@ class RelaySelectionService { @@ -140,7 +140,7 @@ class RelaySelectionService {
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[] = []
if (typeof window !== 'undefined') {
try {
@ -150,8 +150,8 @@ class RelaySelectionService { @@ -150,8 +150,8 @@ class RelaySelectionService {
const n = normalizeUrl(u) || u
return !existing.has(n)
})
const shuffled = candidates.slice().sort(() => Math.random() - 0.5)
shuffled.slice(0, 3).forEach((url) => {
const preferred = client.getPreferredRelaysForRandom(candidates, 3)
preferred.forEach((url) => {
const normalized = normalizeUrl(url) || url
addRelay(normalized, 'randomly_selected')
randomRelayUrls.push(normalized)

Loading…
Cancel
Save