diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 794f79ad..edf33ae9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -2,6 +2,7 @@ // making it incompatible with Vite's Fast Refresh auto-detection. Opting into explicit // full-reload mode to suppress the "incompatible export" HMR warning. // @refresh reset +import storage from '@/services/local-storage.service' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -13,7 +14,6 @@ import { NavigationService } from '@/services/navigation.service' import { ImwaldBrandBar } from '@/assets/Logo' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import NoteDrawer from '@/components/NoteDrawer' -import storage from '@/services/local-storage.service' import client from '@/services/client.service' import { navigationEventStore } from '@/services/navigation-event-store' import type { Event } from 'nostr-tools' diff --git a/src/components/CreateWalletGuideToast/index.tsx b/src/components/CreateWalletGuideToast/index.tsx index 7d485f58..14b30a85 100644 --- a/src/components/CreateWalletGuideToast/index.tsx +++ b/src/components/CreateWalletGuideToast/index.tsx @@ -1,7 +1,7 @@ +import storage from '@/services/local-storage.service' import { toWallet } from '@/lib/link' import { useSecondaryPage } from '@/contexts/secondary-page-context' import { useNostr } from '@/providers/NostrProvider' -import storage from '@/services/local-storage.service' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' diff --git a/src/components/FormattedTimestamp/index.tsx b/src/components/FormattedTimestamp/index.tsx index ebaef09a..6caea133 100644 --- a/src/components/FormattedTimestamp/index.tsx +++ b/src/components/FormattedTimestamp/index.tsx @@ -25,6 +25,9 @@ function FormattedTimestampContent({ short?: boolean }) { const { t } = useTranslation() + if (!Number.isFinite(timestamp)) { + return '\u2014' + } const time = dayjs(timestamp * 1000) const now = dayjs() diff --git a/src/components/LiveActivitiesStrip.tsx b/src/components/LiveActivitiesStrip.tsx index 1b763e28..51deb9a4 100644 --- a/src/components/LiveActivitiesStrip.tsx +++ b/src/components/LiveActivitiesStrip.tsx @@ -1,10 +1,10 @@ +import storage from '@/services/local-storage.service' import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { useSmartNoteNavigation } from '@/PageManager' import { useLiveActivitiesOptional } from '@/providers/useLiveActivities' import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider' -import storage from '@/services/local-storage.service' import { ExternalLink } from 'lucide-react' import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index c763b34f..6b1e6603 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -1,9 +1,9 @@ +import storage from '@/services/local-storage.service' import NoteList, { TNoteListRef } from '@/components/NoteList' import { RefreshButton } from '@/components/RefreshButton' import Tabs, { TabDefinition } from '@/components/Tabs' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/contexts/user-trust-context' -import storage from '@/services/local-storage.service' import { PROFILE_MEDIA_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants' import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import type { TPrimaryPageName } from '@/PageManager' diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index 327dfcea..830f986f 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { Card } from '@/components/ui/card' import { Dialog, @@ -39,7 +40,6 @@ import { } from '@/lib/publishing-feedback' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import storage from '@/services/local-storage.service' import postEditorCache from '@/services/post-editor-cache.service' import type { TDraftEvent } from '@/types' import dayjs from 'dayjs' diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 9b8d5b6f..8ed5b330 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { DropdownMenu, @@ -26,7 +27,6 @@ import { useUserTrust } from '@/contexts/user-trust-context' import { eventService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import type { TNoteStats } from '@/services/note-stats.service' -import storage from '@/services/local-storage.service' import { TEmoji } from '@/types' import { SmilePlus } from 'lucide-react' import { Event } from 'nostr-tools' diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index e936835d..223182ca 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { Skeleton } from '@/components/ui/skeleton' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' @@ -9,7 +10,6 @@ import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import noteStatsService from '@/services/note-stats.service' import type { TNoteStats } from '@/services/note-stats.service' -import storage from '@/services/local-storage.service' import { TEmoji } from '@/types' import { Event } from 'nostr-tools' import { useMemo, useRef, useState } from 'react' diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index f1c81ee9..5bcbba98 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' @@ -17,7 +18,6 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' import noteStatsService from '@/services/note-stats.service' import type { TNoteStats } from '@/services/note-stats.service' -import storage from '@/services/local-storage.service' import { PencilLine, Repeat } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo, useState } from 'react' diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 93555f0b..439d639a 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' @@ -51,7 +52,6 @@ import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' import logger from '@/lib/logger' import { LoginRequiredError } from '@/lib/nostr-errors' import postEditorCache from '@/services/post-editor-cache.service' -import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { Book, diff --git a/src/components/Sidebar/RssButton.tsx b/src/components/Sidebar/RssButton.tsx index 7d50e409..f39edccc 100644 --- a/src/components/Sidebar/RssButton.tsx +++ b/src/components/Sidebar/RssButton.tsx @@ -1,9 +1,9 @@ +import storage from '@/services/local-storage.service' import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { Rss } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' -import storage from '@/services/local-storage.service' export default function RssButton() { const { t } = useTranslation() diff --git a/src/components/TooManyRelaysAlertDialog/index.tsx b/src/components/TooManyRelaysAlertDialog/index.tsx index 7d3672c2..9c78e8c1 100644 --- a/src/components/TooManyRelaysAlertDialog/index.tsx +++ b/src/components/TooManyRelaysAlertDialog/index.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { AlertDialog, AlertDialogContent, @@ -19,7 +20,6 @@ import { toRelaySettings } from '@/lib/link' import { useSecondaryPage } from '@/contexts/secondary-page-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import storage from '@/services/local-storage.service' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/i18n/index.ts b/src/i18n/index.ts index dfb05835..42371346 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -65,6 +65,10 @@ export function initI18n(): Promise { }) i18n.services.formatter?.add('date', (timestamp, lng) => { + const n = Number(timestamp) + if (!Number.isFinite(n)) { + return '\u2014' + } switch (lng) { case 'zh': return dayjs(timestamp).format('YYYY年MM月DD日') diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts index 72cc4f28..a57e6be2 100644 --- a/src/lib/calendar-event.ts +++ b/src/lib/calendar-event.ts @@ -240,10 +240,24 @@ export function stripCalendarEventRedundantTopicHashtagLines( const CALENDAR_DISPLAY_LOCALE = 'en-US' +/** Safe fallback when `Date` is invalid — avoids `Intl.DateTimeFormat#formatToParts` throwing. */ +const INVALID_DATE_PARTS: Record = new Proxy( + {} as Record, + { + get(_target, prop: string | symbol) { + if (typeof prop !== 'string') return '\u2014' + if (prop === 'literal') return '' + if (prop === 'dayPeriod' || prop === 'timeZoneName') return '' + return '\u2014' + } + } +) + function readFormatParts( d: Date, opts: Intl.DateTimeFormatOptions ): Record { + if (!Number.isFinite(d.getTime())) return INVALID_DATE_PARTS const out: Partial> = {} for (const p of new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, opts).formatToParts(d)) { if (p.type !== 'literal') out[p.type] = p.value @@ -256,7 +270,11 @@ function readFormatParts( * (e.g. `May 13, 2025 10:30 am EST`) in the viewer's local zone — avoids DD/MM vs MM/DD ambiguity. */ export function formatCalendarTime(ts: number): string { - const d = new Date(ts * 1000) + if (!Number.isFinite(ts)) return '\u2014' + const ms = ts * 1000 + if (!Number.isFinite(ms)) return '\u2014' + const d = new Date(ms) + if (!Number.isFinite(d.getTime())) return '\u2014' const p = readFormatParts(d, { month: 'long', day: 'numeric', @@ -313,6 +331,7 @@ export function formatCalendarTimeRange(start: number, end: number | undefined): export function formatCalendarDate(dateStr: string): string { if (!dateStr) return '' const d = new Date(dateStr + 'T12:00:00') + if (!Number.isFinite(d.getTime())) return '' const p = readFormatParts(d, { month: 'long', day: 'numeric', year: 'numeric' }) return `${p.month} ${p.day}, ${p.year}` } @@ -331,7 +350,13 @@ const NIP52_SECONDS_PER_DAY = 86400 function nip52DayIndexToUtcCalendarParts(dayIndex: number): { month: string; day: string; year: string } { const ms = dayIndex * NIP52_SECONDS_PER_DAY * 1000 + if (!Number.isFinite(ms)) { + return { month: '\u2014', day: '\u2014', year: '\u2014' } + } const d = new Date(ms) + if (!Number.isFinite(d.getTime())) { + return { month: '\u2014', day: '\u2014', year: '\u2014' } + } const parts = new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, { month: 'long', day: 'numeric', @@ -507,8 +532,10 @@ function toYmdLocal(d: Date): string { /** Compact week banner for sidebar (en-US month names). */ export function formatSidebarWeekLabel(weekStartMs: number, weekEndExclusiveMs: number): string { + if (!Number.isFinite(weekStartMs) || !Number.isFinite(weekEndExclusiveMs)) return '' const start = new Date(weekStartMs) const last = new Date(weekEndExclusiveMs) + if (!Number.isFinite(start.getTime()) || !Number.isFinite(last.getTime())) return '' last.setDate(last.getDate() - 1) const y1 = start.getFullYear() const y2 = last.getFullYear() @@ -542,8 +569,9 @@ export function formatCalendarSidebarRow(event: Event): string { } return a } - if (m.start == null || Number.isNaN(m.start)) return '' + if (m.start == null || Number.isNaN(m.start) || !Number.isFinite(m.start)) return '' const d = new Date(m.start * 1000) + if (!Number.isFinite(d.getTime())) return '' const p = readFormatParts(d, { month: 'short', day: 'numeric', @@ -554,8 +582,9 @@ export function formatCalendarSidebarRow(event: Event): string { }) const ap = (p.dayPeriod ?? '').toLowerCase() const base = `${p.month} ${p.day} · ${p.hour}:${p.minute} ${ap} ${p.timeZoneName ?? ''}`.trim() - if (m.end != null && !Number.isNaN(m.end) && m.end > m.start) { + if (m.end != null && !Number.isNaN(m.end) && Number.isFinite(m.end) && m.end > m.start) { const d2 = new Date(m.end * 1000) + if (!Number.isFinite(d2.getTime())) return base const p2 = readFormatParts(d2, { hour: 'numeric', minute: '2-digit', diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index 334cf125..ba4e098b 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -1,9 +1,9 @@ +import storage from '@/services/local-storage.service' import RelayStatusDisplay from '@/components/RelayStatusDisplay' import { CheckCircle2 } from 'lucide-react' import type { ReactNode } from 'react' import { useContext } from 'react' import { FavoriteRelaysContext } from '@/providers/favorite-relays-context' -import storage from '@/services/local-storage.service' import { toast } from 'sonner' export type PublishSuccessSubtleDetail = { message?: string } diff --git a/src/main.tsx b/src/main.tsx index b7ee6b8b..99879214 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import './index.css' import './polyfill' import './lib/error-suppression' +import storage from './services/local-storage.service' import './services/lightning.service' import './lib/debug-utils' import { fetchWithTimeout } from './lib/fetch-with-timeout' @@ -10,7 +11,6 @@ import { createRoot } from 'react-dom/client' import App from './App.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { initI18n } from './i18n' -import storage from './services/local-storage.service' import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service' import { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery' diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index 86a82087..8c90a911 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { calendarOccurrenceOverlapsRange, @@ -20,7 +21,6 @@ import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import storage from '@/services/local-storage.service' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { TPageRef } from '@/types' import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index b458d006..c02dd251 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { Event } from 'nostr-tools' import { kinds as nostrKinds } from 'nostr-tools' +import storage from '@/services/local-storage.service' import { ExtendedKind, DEFAULT_FEED_SHOW_KINDS } from '@/constants' import { getPubkeysFromPTags } from '@/lib/tag' import { normalizeUrl } from '@/lib/url' @@ -36,7 +37,6 @@ import { import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' import type { TFeedSubRequest } from '@/types' import { isFollowFeedFauxSpellId } from './fauxSpellConfig' -import storage from '@/services/local-storage.service' /** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */ const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000 diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index 4e4199af..df088c0e 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' @@ -11,7 +12,6 @@ import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' -import storage from '@/services/local-storage.service' import { createRssFeedListDraftEvent } from '@/lib/draft-event' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { CloudUpload, Trash2, Plus, Download, Upload } from 'lucide-react' diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index ff0c18f4..7845e227 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import logger from '@/lib/logger' import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' @@ -12,7 +13,6 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { queryService, replaceableEventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import storage from '@/services/local-storage.service' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index a20050e7..e3a9d770 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -1,4 +1,5 @@ import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants' +import storage from '@/services/local-storage.service' import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event' import { getReplaceableEventIdentifier } from '@/lib/event' import { getRelaySetFromEvent } from '@/lib/event-metadata' @@ -6,7 +7,6 @@ import { randomString } from '@/lib/random' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import storage from '@/services/local-storage.service' import { TRelaySet } from '@/types' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index af3b09b8..672e0ca4 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import { buildLiveActivitiesRelayUrls, filterLiveActivityItemsByReachableMedia, @@ -11,7 +12,6 @@ import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import storage from '@/services/local-storage.service' import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { LiveActivitiesContext } from './live-activities-context' diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index a84f2257..2838f456 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,3 +1,4 @@ +import storage from '@/services/local-storage.service' import LoginDialog from '@/components/LoginDialog' import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' import { @@ -34,7 +35,6 @@ import { queryService, replaceableEventService } from '@/services/client.service import customEmojiService from '@/services/custom-emoji.service' import indexedDb from '@/services/indexed-db.service' import postEditorCache from '@/services/post-editor-cache.service' -import storage from '@/services/local-storage.service' import noteStatsService from '@/services/note-stats.service' import { ISigner, diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index e3328461..8f7d6ee4 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -1,7 +1,5 @@ -import { replaceableEventService } from '@/services/client.service' -import { getPubkeysFromPTags } from '@/lib/tag' -import { kinds } from 'nostr-tools' import storage from '@/services/local-storage.service' +import { replaceableEventService } from '@/services/client.service' import { UserTrustContext } from '@/contexts/user-trust-context' import { type ReactNode, useCallback, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index ac48d585..69ccffa7 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -29,7 +29,13 @@ import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' import logger from '@/lib/logger' import { isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' -import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' +import { + RelaySubscribeOpBatch, + compactFilterForRelayLog, + humanizeSubscribeTerminalDetail, + relayHostForSubscribeLog, + type RelayOpTerminalRow +} from '@/services/relay-operation-log.service' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure' import type { Filter, Event as NEvent } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' @@ -47,6 +53,74 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { const HEX_EVENT_ID_RE = /^[0-9a-f]{64}$/i +let queryReqSeq = 0 + +function logQueryReqConsolidatedEnd( + reqId: number, + source: string, + inputRelays: string[], + httpBases: string[], + events: NEvent[], + terminals: RelayOpTerminalRow[], + getSeenForEvent: (eventId: string) => string[] +): void { + const kindHistogram: Record = {} + for (const e of events) { + const k = String(e.kind) + kindHistogram[k] = (kindHistogram[k] ?? 0) + 1 + } + + const norm = (u: string) => normalizeUrl(u) || u + type Row = { + url: string + host: string + terminal?: RelayOpTerminalRow['outcome'] + detail?: string + eventsReturned: number + } + const byKey = new Map() + + const rowFor = (url: string): Row => { + const key = norm(url) + let r = byKey.get(key) + if (!r) { + r = { url: key, host: relayHostForSubscribeLog(key), eventsReturned: 0 } + byKey.set(key, r) + } + return r + } + + for (const t of terminals) { + const r = rowFor(t.relayUrl) + r.terminal = t.outcome + r.detail = humanizeSubscribeTerminalDetail(t.outcome, t.detail) + } + + for (const e of events) { + const seen = getSeenForEvent(e.id) + for (const u of seen) { + rowFor(u).eventsReturned += 1 + } + } + + for (const u of inputRelays) { + rowFor(u) + } + for (const b of httpBases) { + rowFor(b) + } + + const perRelay = [...byKey.values()].sort((a, b) => a.host.localeCompare(b.host)) + + logger.debug('[QueryService] req_end', { + reqId, + source, + eventCount: events.length, + kindHistogram, + perRelay + }) +} + function decodeEventRefForETagFilter(raw: string): string | null { const trimmed = raw.trim() if (!trimmed) return null @@ -314,20 +388,7 @@ export class QueryService { const replaceableRace = options?.replaceableRace ?? false const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS const immediateReturn = options?.immediateReturn ?? false - const isExternalSearch = eoseTimeout > 1000 - - if (isExternalSearch) { - logger.debug('query: Starting external relay search', { - relayCount: urls.length, - relays: urls, - eoseTimeout, - globalTimeout, - replaceableRace, - immediateReturn, - filter: sanitizedFilters - }) - } - + const filtersForGrace = sanitizedFilters const maxLimitForGrace = Math.max(...filtersForGrace.map((f) => (f.limit ?? 0) as number), 0) const isSingleEventFetchForGrace = maxLimitForGrace === 1 @@ -352,6 +413,22 @@ export class QueryService { ) const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u)) + const reqId = ++queryReqSeq + const source = options?.relayOpSource ?? 'QueryService.query' + const inputRelaysOrdered = Array.from(new Set(urls.map((u) => normalizeUrl(u) || u).filter(Boolean))) + const wsRelayCandidates = Array.from(new Set(wsQueryUrls.map((u) => normalizeUrl(u) || u).filter(Boolean))) + + logger.debug('[QueryService] req_begin', { + reqId, + source, + relays: inputRelaysOrdered, + httpRelayBases, + wsRelayCandidates, + filters: sanitizedFilters.map(compactFilterForRelayLog), + eoseTimeout, + globalTimeout + }) + return await new Promise((resolve) => { const events: NEvent[] = [] const abortHttp = new AbortController() @@ -364,6 +441,27 @@ export class QueryService { let firstResultTime: number | null = null let globalTimeoutId: ReturnType | null = null let queryFinalizing = false + let resolvedSnapshot: NEvent[] = [] + let reqEndLogged = false + let fallbackReqEndTimer: ReturnType | null = null + + const emitReqEnd = (terminals: RelayOpTerminalRow[], snapshot: NEvent[]) => { + if (reqEndLogged) return + reqEndLogged = true + if (fallbackReqEndTimer != null) { + clearTimeout(fallbackReqEndTimer) + fallbackReqEndTimer = null + } + logQueryReqConsolidatedEnd( + reqId, + source, + inputRelaysOrdered, + httpRelayBases, + snapshot, + terminals, + (id) => this.getSeenEventRelayUrls(id) + ) + } const httpInflight = httpRelayBases.length === 0 @@ -388,9 +486,7 @@ export class QueryService { } catch (e) { if ((e as Error).name === 'AbortError') return relaySessionStrikes.recordReadFailure(base, 'http') - if (isIndexRelayTransportFailure(e)) { - logger.debug('[QueryService] HTTP index relay unreachable', { base, error: e }) - } else { + if (!isIndexRelayTransportFailure(e)) { logger.warn('[QueryService] HTTP index relay query failed', { base, error: e }) } } @@ -443,10 +539,20 @@ export class QueryService { if (replaceableRaceTimeoutId) clearTimeout(replaceableRaceTimeoutId) if (globalTimeoutId) clearTimeout(globalTimeoutId) - sub.close() - const resolvedList = replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events + resolvedSnapshot = resolvedList + + if (wsQueryUrls.length === 0) { + emitReqEnd([], resolvedList) + } else { + fallbackReqEndTimer = setTimeout(() => { + emitReqEnd([], resolvedList) + }, Math.min(globalTimeout + 2500, 45_000)) + } + + sub.close() + resolve(resolvedList) } @@ -557,7 +663,7 @@ export class QueryService { } } }, - { source: options?.relayOpSource ?? 'QueryService.query', logLevel: 'debug' } + { source: options?.relayOpSource ?? 'QueryService.query', logLevel: 'debug', quiet: true, onBatchEnd: (rows) => emitReqEnd(rows, resolvedSnapshot) } ) const sub = { @@ -578,7 +684,13 @@ export class QueryService { urls: string[], filter: Filter | Filter[], callbacks: SubscribeCallbacks, - relayOpMeta?: { source: string; logLevel?: 'info' | 'debug' } + relayOpMeta?: { + source: string + logLevel?: 'info' | 'debug' + /** When true, suppress `[RelayOp] batch_begin` / `batch_end` (used by {@link QueryService.query}). */ + quiet?: boolean + onBatchEnd?: (rows: RelayOpTerminalRow[]) => void + } ): { close: () => void } { const filters = sanitizeFiltersBeforeReq(filter) if (filters.length === 0) { @@ -645,7 +757,11 @@ export class QueryService { const opSource = relayOpMeta?.source ?? 'QueryService.subscribe' const opBatch = groupedRequests.length > 0 - ? new RelaySubscribeOpBatch(opSource, groupedRequests, { logLevel: relayOpMeta?.logLevel }) + ? new RelaySubscribeOpBatch(opSource, groupedRequests, { + logLevel: relayOpMeta?.logLevel, + quiet: relayOpMeta?.quiet, + onBatchEnd: relayOpMeta?.onBatchEnd + }) : null opBatch?.logBegin() diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 4db2ba09..76cf577b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3398,27 +3398,6 @@ class ClientService extends EventTarget { relayOpSource: 'ClientService.searchProfiles' }) - /** Which relays actually delivered each kind-0 id (for tuning SEARCHABLE_RELAY_URLS). DEBUG only. */ - if (searchStr.length > 0) { - const relayHitCounts = new Map() - for (const e of events) { - if (e.kind !== kinds.Metadata) continue - for (const u of this.queryService.getSeenEventRelayUrls(e.id)) { - const n = (normalizeUrl(u) || u).trim() - if (!n) continue - relayHitCounts.set(n, (relayHitCounts.get(n) ?? 0) + 1) - } - } - if (relayHitCounts.size > 0) { - const relayHits = [...relayHitCounts.entries()].sort((a, b) => b[1] - a[1]) - logger.debug('[ClientService.searchProfiles] kind=0 deliveries by relay URL (count = events relay sent for this query)', { - searchPreview: searchStr.slice(0, 80), - totalKind0Events: events.filter((e) => e.kind === kinds.Metadata).length, - relayHits: Object.fromEntries(relayHits) - }) - } - } - const byPk = new Map() for (const e of events) { if (e.kind !== kinds.Metadata) continue diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index 1748d8b3..2f7feba1 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -1,4 +1,5 @@ -/** Compression runs entirely in-app before upload (`compress-upload-media`). */ +/** Compression runs entirely in-app before upload (`compress-upload-media`). Load `local-storage` before `./client.service` (that graph can re-enter here; constructor reads storage). */ +import storage from './local-storage.service' import { compressMediaForUpload } from '@/lib/compress-upload-media' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import logger from '@/lib/logger' @@ -12,8 +13,6 @@ import { simplifyUrl } from '@/lib/url' import { TDraftEvent, TMediaUploadServiceConfig } from '@/types' import { BlossomClient } from 'blossom-client-sdk' import { z } from 'zod' -/** Must run before `./client.service` — that graph can synchronously re-enter this module; `storage` must be bound first (constructor reads it at module bottom). */ -import storage from './local-storage.service' import client from './client.service' type UploadOptions = { diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index 00a3582e..36b48ca3 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -1,7 +1,7 @@ +import storage from '@/services/local-storage.service' import { StorageKey } from '@/constants' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { parseEditorJsonToText } from '@/lib/tiptap' -import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { Content } from '@tiptap/react' import { Event } from 'nostr-tools' diff --git a/src/services/relay-operation-log.service.ts b/src/services/relay-operation-log.service.ts index ac73f273..36c632b2 100644 --- a/src/services/relay-operation-log.service.ts +++ b/src/services/relay-operation-log.service.ts @@ -31,7 +31,11 @@ export function compactFilterForRelayLog(f: Filter): Record { if (f['#p']?.length) out.pTagCount = f['#p'].length if (f['#e']?.length) out.eTagCount = f['#e'].length if (f['#t']?.length) out.tTagCount = f['#t'].length - if (f.search) out.search = true + if (typeof f.search === 'string' && f.search.length > 0) { + out.searchPreview = f.search.length > 120 ? `${f.search.slice(0, 117)}…` : f.search + } else if (f.search) { + out.search = true + } return out } @@ -49,7 +53,7 @@ export interface RelayOpTerminalRow { type GroupedRelayRow = { url: string; filters: Filter[] } /** Short host label for subscribe REQ logs (same as publish). */ -function relayHostForSubscribeLog(url: string): string { +export function relayHostForSubscribeLog(url: string): string { return relayHostForPublishLog(url) } @@ -137,6 +141,8 @@ function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record void } @@ -181,6 +187,7 @@ export class RelaySubscribeOpBatch { private readonly source: string private readonly grouped: GroupedRelayRow[] private readonly logLevel: 'info' | 'debug' + private readonly quiet: boolean private readonly onBatchEnd?: (rows: RelayOpTerminalRow[]) => void private readonly terminal = new Map() private endLogged = false @@ -191,6 +198,7 @@ export class RelaySubscribeOpBatch { this.source = source this.grouped = grouped this.logLevel = options?.logLevel ?? 'debug' + this.quiet = options?.quiet ?? false this.onBatchEnd = options?.onBatchEnd } @@ -203,6 +211,7 @@ export class RelaySubscribeOpBatch { } logBegin(): void { + if (this.quiet) return const uniqueRelays = [...new Set(this.grouped.map((g) => g.url))] this.logLine('[RelayOp] batch_begin', { batchId: this.batchId, @@ -285,15 +294,17 @@ export class RelaySubscribeOpBatch { timeoutCount: nTimeout } - if (this.logLevel === 'debug') { - this.logLine('[RelayOp] batch_end', { - ...compact, - readableSummary, - byOutcome: groupTerminalsByOutcome(rows), - terminals: rows - }) - } else { - logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact) + if (!this.quiet) { + if (this.logLevel === 'debug') { + this.logLine('[RelayOp] batch_end', { + ...compact, + readableSummary, + byOutcome: groupTerminalsByOutcome(rows), + terminals: rows + }) + } else { + logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact) + } } this.onBatchEnd?.(rows) diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index d00d240d..0d9dcba4 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -1,5 +1,6 @@ import { Event, kinds } from 'nostr-tools' import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT, READ_ONLY_RELAY_URLS } from '@/constants' +import storage from '@/services/local-storage.service' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' import { eventService } from '@/services/client.service' @@ -10,7 +11,6 @@ import indexedDb from '@/services/indexed-db.service' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import nip66Service from '@/services/nip66.service' -import storage from '@/services/local-storage.service' export interface RelaySelectionContext { // User's own relays