Browse Source

clear strikes on empty relay pool

imwald
Silberengel 1 month ago
parent
commit
aaf4d89835
  1. 19
      src/components/NoteList/index.tsx
  2. 32
      src/components/RefreshButton/index.tsx
  3. 54
      src/hooks/use-long-press-action.ts
  4. 1
      src/i18n/locales/en.ts
  5. 2
      src/main.tsx
  6. 49
      src/services/client.service.ts
  7. 67
      src/services/session-feed-snapshot.service.ts

19
src/components/NoteList/index.tsx

@ -26,6 +26,7 @@ import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { import {
getSessionFeedSnapshot, getSessionFeedSnapshot,
hardReloadPreservingFeedSnapshots,
setSessionFeedSnapshot setSessionFeedSnapshot
} from '@/services/session-feed-snapshot.service' } from '@/services/session-feed-snapshot.service'
import type { TFeedSubRequest, TSubRequestFilter } from '@/types' import type { TFeedSubRequest, TSubRequestFilter } from '@/types'
@ -48,6 +49,7 @@ import {
useState useState
} from 'react' } from 'react'
import { CircleAlert } from 'lucide-react' import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -655,6 +657,8 @@ const NoteList = forwardRef(
}, 500) }, 500)
}, [scrollToTop]) }, [scrollToTop])
const emptyFeedHardReloadLongPress = useLongPressAction(hardReloadPreservingFeedSnapshots)
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => { useEffect(() => {
@ -1707,7 +1711,20 @@ const NoteList = forwardRef(
role="status" role="status"
> >
<p>{t('No posts loaded for this feed. Try refreshing.')}</p> <p>{t('No posts loaded for this feed. Try refreshing.')}</p>
<Button type="button" variant="outline" size="sm" onClick={() => refresh()}> <Button
type="button"
variant="outline"
size="sm"
title={t('refresh.longPressHardReload')}
onPointerDown={emptyFeedHardReloadLongPress.onPointerDown}
onPointerUp={emptyFeedHardReloadLongPress.onPointerUp}
onPointerLeave={emptyFeedHardReloadLongPress.onPointerLeave}
onPointerCancel={emptyFeedHardReloadLongPress.onPointerCancel}
onClick={() => {
if (emptyFeedHardReloadLongPress.consumeIfLongPress()) return
refresh()
}}
>
{t('Refresh')} {t('Refresh')}
</Button> </Button>
</div> </div>

32
src/components/RefreshButton/index.tsx

@ -1,17 +1,47 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { hardReloadPreservingFeedSnapshots } from '@/services/session-feed-snapshot.service'
import { RefreshCcw } from 'lucide-react' import { RefreshCcw } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export function RefreshButton({ onClick }: { onClick: () => void }) { export function RefreshButton({
onClick,
/**
* Long-press (~650ms). Default: full page reload while restoring session feed snapshots.
* Pass `null` to disable long-press hard reload.
*/
onLongPress
}: {
onClick: () => void
onLongPress?: (() => void) | null
}) {
const { t } = useTranslation()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const longPressEnabled = onLongPress !== null
const longPressFn = onLongPress === null ? () => {} : (onLongPress ?? hardReloadPreservingFeedSnapshots)
const { onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, consumeIfLongPress } =
useLongPressAction(longPressFn, { enabled: longPressEnabled })
const longPressTitle = onLongPress === null ? undefined : t('refresh.longPressHardReload')
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
disabled={refreshing} disabled={refreshing}
title={longPressTitle}
{...(longPressEnabled
? {
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel
}
: {})}
onClick={() => { onClick={() => {
if (consumeIfLongPress()) return
setRefreshing(true) setRefreshing(true)
onClick() onClick()
setTimeout(() => setRefreshing(false), 500) setTimeout(() => setRefreshing(false), 500)

54
src/hooks/use-long-press-action.ts

@ -0,0 +1,54 @@
import { useCallback, useRef } from 'react'
const DEFAULT_MS = 650
/**
* Pointer long-press: fires `onLongPress` after `ms`. Use `consumeIfLongPress()` in `onClick` to ignore the click that follows a long-press.
*/
export function useLongPressAction(
onLongPress: () => void,
options?: { ms?: number; enabled?: boolean }
) {
const ms = options?.ms ?? DEFAULT_MS
const enabled = options?.enabled ?? true
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const firedRef = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const onPointerDown = useCallback(() => {
if (!enabled) return
firedRef.current = false
clearTimer()
timerRef.current = setTimeout(() => {
timerRef.current = null
firedRef.current = true
onLongPress()
}, ms)
}, [clearTimer, enabled, ms, onLongPress])
const onPointerEnd = useCallback(() => {
clearTimer()
}, [clearTimer])
const consumeIfLongPress = useCallback(() => {
if (firedRef.current) {
firedRef.current = false
return true
}
return false
}, [])
return {
onPointerDown,
onPointerUp: onPointerEnd,
onPointerLeave: onPointerEnd,
onPointerCancel: onPointerEnd,
consumeIfLongPress
}
}

1
src/i18n/locales/en.ts

@ -26,6 +26,7 @@ export default {
'Account menu': 'Account menu', 'Account menu': 'Account menu',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
Refresh: 'Refresh', Refresh: 'Refresh',
'refresh.longPressHardReload': 'Long-press: reload app and restore feed cache',
Profile: 'Profile', Profile: 'Profile',
Logout: 'Logout', Logout: 'Logout',
Following: 'Following', Following: 'Following',

2
src/main.tsx

@ -10,6 +10,7 @@ import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import storage from './services/local-storage.service' import storage from './services/local-storage.service'
import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service'
declare global { declare global {
interface Window { interface Window {
@ -47,6 +48,7 @@ async function bootstrap() {
})() })()
]) ])
console.info('[jumble] Boot: mounting React (UI shell will appear; Nostr session restores next)') console.info('[jumble] Boot: mounting React (UI shell will appear; Nostr session restores next)')
restoreSessionFeedSnapshotsAfterHardRefresh()
// Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it. // Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it.
try { try {
sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now())) sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now()))

49
src/services/client.service.ts

@ -827,6 +827,38 @@ class ClientService extends EventTarget {
}) })
} }
/**
* If every URL was session-striked, clear strikes once so reads/publishes can retry (mobile WebSocket churn).
*/
clearSessionRelayStrikes(): void {
if (this.publishStrikeCount.size === 0) return
logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size })
this.publishStrikeCount.clear()
}
/**
* Apply strike filter; if that removes all candidates while some were provided, clear strikes **for those URLs
* only** and retry once. (A global clear here caused storms: e.g. NIP-65 outbox retry with 2 relays wiped strikes
* for every relay in the tab session.)
*/
private relayUrlsAfterStrikesOrRecover(urls: string[]): string[] {
const unique = Array.from(new Set(urls))
const filtered = this.filterSessionStrikedRelays(unique)
if (filtered.length === 0 && unique.length > 0) {
let cleared = 0
for (const u of unique) {
const n = normalizeUrl(u) || u
if (n && this.publishStrikeCount.delete(n)) cleared += 1
}
logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', {
batchUrlCount: unique.length,
strikeEntriesCleared: cleared
})
return this.filterSessionStrikedRelays(unique)
}
return filtered
}
/** Record a successful publish and its latency for session-based preference when selecting random relays. */ /** Record a successful publish and its latency for session-based preference when selecting random relays. */
recordPublishSuccess(url: string, latencyMs: number) { recordPublishSuccess(url: string, latencyMs: number) {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
@ -957,11 +989,10 @@ class ClientService extends EventTarget {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false if (readOnlySet.has(n)) return false
if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
const strikes = this.publishStrikeCount.get(n) ?? 0
if (strikes >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) return false
return true return true
}) })
filtered = Array.from(new Set(filtered)) filtered = Array.from(new Set(filtered))
filtered = this.relayUrlsAfterStrikesOrRecover(filtered)
const countAfterFiltersBeforeCap = filtered.length const countAfterFiltersBeforeCap = filtered.length
filtered = await this.capPublishRelayUrlsForPublish( filtered = await this.capPublishRelayUrlsForPublish(
filtered, filtered,
@ -1583,7 +1614,7 @@ class ClientService extends EventTarget {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
} }
relays = this.filterSessionStrikedRelays(relays) relays = this.relayUrlsAfterStrikesOrRecover(relays)
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
@ -2206,7 +2237,7 @@ class ClientService extends EventTarget {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
} }
relays = this.filterSessionStrikedRelays(relays) relays = this.relayUrlsAfterStrikesOrRecover(relays)
const events = await this.queryService.query(relays, filter, onevent, { const events = await this.queryService.query(relays, filter, onevent, {
eoseTimeout, eoseTimeout,
globalTimeout, globalTimeout,
@ -2234,18 +2265,20 @@ class ClientService extends EventTarget {
if (!normalized) { if (!normalized) {
return { events: [], connectionError: 'Invalid relay URL' } return { events: [], connectionError: 'Invalid relay URL' }
} }
if (this.filterSessionStrikedRelays([normalized]).length === 0) { const usableAfterStrikes = this.relayUrlsAfterStrikesOrRecover([normalized])
if (usableAfterStrikes.length === 0) {
return { events: [], connectionError: 'Relay skipped this session (repeated failures)' } return { events: [], connectionError: 'Relay skipped this session (repeated failures)' }
} }
const relayForConn = usableAfterStrikes[0]!
try { try {
await this.pool.ensureRelay(normalized, { connectionTimeout: 12_000 }) await this.pool.ensureRelay(relayForConn, { connectionTimeout: 12_000 })
} catch (e) { } catch (e) {
this.recordSessionRelayFailure(normalized) this.recordSessionRelayFailure(relayForConn)
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
return { events: [], connectionError: msg } return { events: [], connectionError: msg }
} }
try { try {
const events = await this.queryService.query([normalized], filter, undefined, { const events = await this.queryService.query([relayForConn], filter, undefined, {
globalTimeout: options?.globalTimeout ?? 25_000 globalTimeout: options?.globalTimeout ?? 25_000
}) })
return { events, connectionError: undefined } return { events, connectionError: undefined }

67
src/services/session-feed-snapshot.service.ts

@ -1,10 +1,13 @@
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import logger from '@/lib/logger'
/** Max events stored per feed key (matches typical initial timeline cap). */ /** Max events stored per feed key (matches typical initial timeline cap). */
const MAX_EVENTS_PER_FEED = 120 const MAX_EVENTS_PER_FEED = 120
/** Max distinct feeds kept in memory for the tab session. */ /** Max distinct feeds kept in memory for the tab session. */
const MAX_FEED_KEYS = 48 const MAX_FEED_KEYS = 48
const HARD_REFRESH_SESSION_KEY = 'jumble:hardRefreshFeedSnapshots'
const snapshots = new Map<string, Event[]>() const snapshots = new Map<string, Event[]>()
const accessOrder: string[] = [] const accessOrder: string[] = []
@ -36,3 +39,67 @@ export function setSessionFeedSnapshot(key: string, events: readonly Event[]): v
snapshots.set(key, capped) snapshots.set(key, capped)
bumpAccess(key) bumpAccess(key)
} }
/**
* Persist in-memory feed snapshots to sessionStorage, then call {@link window.location.reload}.
* {@link restoreSessionFeedSnapshotsAfterHardRefresh} runs on next boot (see `main.tsx`).
*/
export function hardReloadPreservingFeedSnapshots(): void {
persistSessionFeedSnapshotsForHardRefresh()
window.location.reload()
}
export function persistSessionFeedSnapshotsForHardRefresh(): void {
try {
if (snapshots.size === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
return
}
const payload: Record<string, Event[]> = {}
for (const [k, rows] of snapshots) {
if (rows?.length) {
payload[k] = rows.map((e) => ({ ...e }))
}
}
if (Object.keys(payload).length === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
return
}
sessionStorage.setItem(HARD_REFRESH_SESSION_KEY, JSON.stringify(payload))
logger.info('[feed-snapshot] Persisted for hard reload', { feedKeys: Object.keys(payload).length })
} catch (e) {
logger.warn('[feed-snapshot] Could not persist for hard reload', { error: e })
}
}
export function restoreSessionFeedSnapshotsAfterHardRefresh(): void {
try {
const raw = sessionStorage.getItem(HARD_REFRESH_SESSION_KEY)
if (!raw) return
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
const payload = JSON.parse(raw) as Record<string, unknown>
if (!payload || typeof payload !== 'object') return
let restored = 0
for (const [k, rows] of Object.entries(payload)) {
if (!k || !Array.isArray(rows) || rows.length === 0) continue
const capped = rows
.filter((e): e is Event => e != null && typeof (e as Event).id === 'string')
.slice(0, MAX_EVENTS_PER_FEED)
.map((e) => ({ ...e }))
if (capped.length > 0) {
setSessionFeedSnapshot(k, capped)
restored++
}
}
if (restored > 0) {
logger.info('[feed-snapshot] Restored after hard reload', { feeds: restored })
}
} catch (e) {
logger.warn('[feed-snapshot] Could not restore after hard reload', { error: e })
try {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
} catch {
// ignore
}
}
}

Loading…
Cancel
Save