diff --git a/index.html b/index.html index 82a3979b..8f7e2934 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@ /> + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 00000000..8075e4dc --- /dev/null +++ b/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" + } + ] +} diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b73e782f..fdaaf3d0 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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() { } else if (url.startsWith('/settings/relays')) { window.history.pushState(null, '', url) setPrimaryNoteView(, 'settings-sub') + } else if (url === '/settings/cache') { + window.history.pushState(null, '', url) + setPrimaryNoteView(, 'settings-sub') } else if (url === '/settings/wallet') { window.history.pushState(null, '', url) setPrimaryNoteView(, 'settings-sub') diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 7a548dac..b1667cff 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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({ { 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([]) const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) @@ -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({ // 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({ 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 { diff --git a/src/components/PostEditor/PostOptions.tsx b/src/components/PostEditor/PostOptions.tsx index a826ca14..e6a093dd 100644 --- a/src/components/PostEditor/PostOptions.tsx +++ b/src/components/PostEditor/PostOptions.tsx @@ -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({ 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) => { diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx new file mode 100644 index 00000000..fdbdf9ed --- /dev/null +++ b/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(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 ( +
+
+

+ {t('Session relays tab description')} +

+ +
+ +
+

+ + {t('Session relays preset working')} +

+

+ {t('Session relays preset working hint')} +

+
    + {debug.presetWorking.length === 0 ? ( +
  • {t('None')}
  • + ) : ( + debug.presetWorking.map((url) => ( +
  • + {formatUrl(url)} +
  • + )) + )} +
+
+ +
+

+ + {t('Session relays preset striked')} +

+

+ {t('Session relays preset striked hint')} +

+
    + {debug.presetStriked.length === 0 ? ( +
  • {t('None')}
  • + ) : ( + debug.presetStriked.map((url) => ( +
  • + {formatUrl(url)} +
  • + )) + )} +
+
+ +
+

+ + {t('Session relays scored random')} +

+

+ {t('Session relays scored random hint')} +

+
    + {debug.scoredRelays.length === 0 ? ( +
  • {t('None')}
  • + ) : ( + debug.scoredRelays.map(({ url, successCount, avgLatencyMs }) => ( +
  • + + {formatUrl(url)} + + + {successCount} {t('successes')} · ~{avgLatencyMs} ms + +
  • + )) + )} +
+
+ + {debug.strikedUrls.length > 0 && ( +
+

+ {t('Session relays all striked')} +

+
    + {debug.strikedUrls.map((url) => ( +
  • + {formatUrl(url)} +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/src/constants.ts b/src/constants.ts index 1573c204..c09ea088 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = [ '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', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0ee0c646..a18e7c6d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f229e64f..fe0cad2f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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?': diff --git a/src/lib/link.ts b/src/lib/link.ts index 23053be8..5f6820e0 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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` diff --git a/src/main.tsx b/src/main.tsx index ba79658f..f46c4ff2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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() { } 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( diff --git a/src/pages/secondary/CacheSettingsPage/index.tsx b/src/pages/secondary/CacheSettingsPage/index.tsx new file mode 100644 index 00000000..08adedc1 --- /dev/null +++ b/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 ( + +
+ +
+
+ ) + } +) +CacheSettingsPage.displayName = 'CacheSettingsPage' +export default CacheSettingsPage diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 66200b1b..cb6944b3 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -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?: 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?: {t('Favorite Relays')} {t('Read & Write Relays')} - {t('Cache')} + {t('Session relays')} @@ -38,8 +38,8 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: - - + + diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index 1b076082..8e9ab892 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -4,6 +4,7 @@ import { toGeneralSettings, toPostSettings, toRelaySettings, + toCacheSettings, toTranslation, toWallet, toRssFeedSettings @@ -15,6 +16,7 @@ import { Check, ChevronRight, Copy, + Database, Info, KeyRound, Languages, @@ -50,6 +52,13 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb + navigateToSettings(toCacheSettings())}> +
+ +
{t('Cache & offline storage')}
+
+ +
{!!pubkey && ( navigateToSettings(toTranslation())}>
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 43980157..d2c5e8fe 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 }) { 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 }) { // 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 }) { '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) { diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx index f9faa581..2039268b 100644 --- a/src/providers/ThemeProvider.tsx +++ b/src/providers/ThemeProvider.tsx @@ -21,9 +21,9 @@ const ThemeProviderContext = createContext(undef export function ThemeProvider({ children, ...props }: ThemeProviderProps) { const [themeSetting, setThemeSetting] = useState( - (localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system' + () => storage.getThemeSetting() ) - const [theme, setTheme] = useState('light') + const [theme, setTheme] = useState(() => storage.getTheme()) useEffect(() => { const init = async () => { @@ -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 ( diff --git a/src/routes.tsx b/src/routes.tsx index 6a9aad44..78460f7d 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { path: '/search', element: }, { path: '/settings', element: }, { path: '/settings/relays', element: }, + { path: '/settings/cache', element: }, { path: '/settings/wallet', element: }, { path: '/settings/posts', element: }, { path: '/settings/general', element: }, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 94bc2d38..87b44cb5 100644 --- a/src/services/client.service.ts +++ b/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. */ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { @@ -54,6 +54,8 @@ class ClientService extends EventTarget { | undefined > = {} private eventCacheMap = new Map>() + /** 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({ max: 500, ttl: 1000 * 60 * 30 }) private relayListRequestCache = new Map>() // Cache in-flight relay list requests private eventDataLoader = new DataLoader( (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))), @@ -72,6 +74,13 @@ class ClientService extends EventTarget { private activeSubCountByRelay = new Map() private subSlotWaitQueueByRelay = new Map 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() + 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() + constructor() { super() 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 } + /** 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() + 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 { 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 { // 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 { 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 { .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 { }) .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 { } 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 { 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 { 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() @@ -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 { } async fetchEvent(id: string): Promise { - 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 { 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 { 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 { const eventsMap = new Map() 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] }) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 69a0840f..a60970b6 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -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' 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 { 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 { 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 { 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 | null = null @@ -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 { for (const key of SETTINGS_KEYS) { const value = window.localStorage.getItem(key) @@ -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 { 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 } diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index e574205c..7a747529 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -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' diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index b332951a..106d28e4 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -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 { 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)