From 69cf5a50316cd6bbec3c708dd95cb6fe14ff840f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 31 May 2026 16:28:06 +0200 Subject: [PATCH] bug-fixes --- src/PageManager.tsx | 44 +++++++++++- src/components/HelpAndAccountMenu.tsx | 17 +++-- src/components/LogoutDialog/index.tsx | 3 - src/components/Profile/index.tsx | 8 +-- src/components/ui/avatar.tsx | 16 ++++- src/constants.ts | 3 + src/lib/new-user-template-broadcast.ts | 70 ++++++++++++++++++- src/lib/new-user-template.test.ts | 31 +++++--- src/lib/new-user-template.ts | 13 ++++ src/lib/relay-strikes.test.ts | 9 +++ src/lib/relay-strikes.ts | 10 ++- .../secondary/ProfileEditorPage/index.tsx | 8 +-- src/providers/NostrProvider/index.tsx | 8 +++ src/services/client.service.ts | 8 ++- 14 files changed, 209 insertions(+), 39 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index caff0bb5..bbfdabfe 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -19,7 +19,7 @@ import { NavigationService } from '@/services/navigation.service' import { ImwaldBrandBar } from '@/assets/Logo' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import NoteDrawer from '@/components/NoteDrawer' -import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants' +import { APP_RESET_TO_LANDING_EVENT, PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants' import { extendProfileNetworkDeferral } from '@/lib/profile-batch-coordinator' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' @@ -2160,6 +2160,48 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { restorePrimaryTabAfterSecondaryClose() } + /** Logout / session clear: drop note overlays and replace the current URL (e.g. `/feed/notes/…`) with `/`. */ + const resetToLandingPage = () => { + ignorePopStateRef.current = true + pendingDrawerCloseUrlRef.current = '/' + + setSavedPrimaryPage(null) + savedPrimaryPagePropsRef.current = undefined + setPrimaryNoteViewState(null) + setPrimaryViewType(null) + + noteStatsService.setBackgroundStatsPaused(false) + + if (drawerOpenRef.current) { + setDrawerOpen(false) + } + setSinglePaneSheetOpen(false) + secondaryStackRef.current = [] + setSecondaryStack([]) + + setPrimaryPages((prev) => { + if (prev.some((p) => p.name === 'feed')) return prev + return [...prev, { name: 'feed', element: getPrimaryPageMap().feed }] + }) + setCurrentPrimaryPage('feed') + + window.history.replaceState(null, '', '/') + + window.setTimeout(() => { + setDrawerNoteId(null) + setDrawerInitialEvent(null) + pendingDrawerCloseUrlRef.current = null + }, 400) + } + + const resetToLandingPageStable = useEventCallback(resetToLandingPage) + + useEffect(() => { + const onReset = () => resetToLandingPageStable() + window.addEventListener(APP_RESET_TO_LANDING_EVENT, onReset) + return () => window.removeEventListener(APP_RESET_TO_LANDING_EVENT, onReset) + }, [resetToLandingPageStable]) + let lastPopSecondaryPageAt = 0 const POP_SECONDARY_PAGE_DEBOUNCE_MS = 400 diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index d77503d5..0488e4c8 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -1,7 +1,7 @@ import LoginDialog from '@/components/LoginDialog' import LogoutDialog from '@/components/LogoutDialog' import SidebarItem from '@/components/Sidebar/SidebarItem' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -115,9 +115,9 @@ function SidebarAccountMenu({ ) : ( - - - + + + )} @@ -173,9 +173,12 @@ function TitlebarAccountMenu({ ) : ( - - - + + + ) diff --git a/src/components/LogoutDialog/index.tsx b/src/components/LogoutDialog/index.tsx index 774ce4e2..236f9773 100644 --- a/src/components/LogoutDialog/index.tsx +++ b/src/components/LogoutDialog/index.tsx @@ -17,7 +17,6 @@ import { DrawerHeader, DrawerTitle } from '@/components/ui/drawer' -import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useTranslation } from 'react-i18next' @@ -32,12 +31,10 @@ export default function LogoutDialog({ const { t } = useTranslation() const { isSmallScreen = false } = useScreenSizeOptional() ?? {} const { account, switchAccount } = useNostr() - const { navigate } = usePrimaryPage() const handleLogout = () => { setOpen(false) void switchAccount(null) - navigate('feed') } if (isSmallScreen) { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 13606f9a..836ecfea 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -8,7 +8,7 @@ import { ProfileBotBadge } from '@/components/ProfileBotBadge' import ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' @@ -396,13 +396,13 @@ export default function Profile({
- - + + {isBot ? ( diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 54c5cb95..6700e975 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -38,7 +38,8 @@ const AvatarFallback = React.forwardRef< + ) +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarIdenticon } diff --git a/src/constants.ts b/src/constants.ts index b16cf926..207c99f6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -345,6 +345,9 @@ export const BLOSSOM_PRESET_SELECT_PREFIX = 'blossom-preset:' /** [Lotus](https://github.com/0ceanSlim/lotus) — self-hosted Blossom (BUD) server (see GitHub for cdn_url / api_addr). */ export const LOTUS_BLOSSOM_REPO_URL = 'https://github.com/0ceanSlim/lotus' +/** Window event: session cleared — PageManager returns to `/` and closes note overlays. */ +export const APP_RESET_TO_LANDING_EVENT = 'app-reset-to-landing' + export const StorageKey = { VERSION: 'version', THEME_SETTING: 'themeSetting', diff --git a/src/lib/new-user-template-broadcast.ts b/src/lib/new-user-template-broadcast.ts index 105fd7f4..22c003f0 100644 --- a/src/lib/new-user-template-broadcast.ts +++ b/src/lib/new-user-template-broadcast.ts @@ -4,6 +4,8 @@ import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes' import logger from '@/lib/logger' +import { NEW_USER_HTTP_RELAY_URL } from '@/lib/new-user-template' +import { normalizeAnyRelayUrl } from '@/lib/url' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import type { TRelayList } from '@/types' @@ -11,21 +13,80 @@ import { Event, kinds } from 'nostr-tools' const BROADCAST_PENDING_KEY = 'imwaldNewUserTemplateBroadcastPending' -export const NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS = 5000 +/** Space between replaceable template events — keeps relay publish rate limits from tripping. */ +export const NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS = 20_000 /** Replaceable kinds created during one-click signup, in publish order. */ export const NEW_USER_TEMPLATE_BROADCAST_KINDS = [ kinds.RelayList, ExtendedKind.HTTP_RELAY_LIST, ExtendedKind.FAVORITE_RELAYS, + ExtendedKind.BLOCKED_RELAYS, kinds.Metadata, 10015, kinds.Contacts, kinds.Mutelist ] as const +/** Relays that reject bursts or return HTTP 429 on connect during signup publish. */ +const NEW_USER_TEMPLATE_PUBLISH_EXCLUDED = [ + 'wss://relay.layer.systems', + 'wss://profiles.nostrver.se/' +] as const + +/** Profile mirrors that only mirror kind 10002 (not kind 0 or other lists). */ +const RELAY_LIST_ONLY_PROFILE_MIRRORS = ['wss://indexer.coracle.social/'] as const + const broadcastScheduledOrRunning = new Set() +function templateRelayKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url).toLowerCase() +} + +function isExcludedFromTemplateBroadcast(url: string): boolean { + const key = templateRelayKey(url) + return NEW_USER_TEMPLATE_PUBLISH_EXCLUDED.some((u) => templateRelayKey(u) === key) +} + +function relayAllowsTemplateKind(url: string, kind: number): boolean { + if (kind === kinds.RelayList) return true + const key = templateRelayKey(url) + return !RELAY_LIST_ONLY_PROFILE_MIRRORS.some((u) => templateRelayKey(u) === key) +} + +function maxTemplatePublishRelays(kind: number): number { + return kind === kinds.Metadata || kind === kinds.RelayList ? 4 : 3 +} + +/** Prefer mercury + stable write relays; profile index when kind allows. */ +function prioritizeNewUserTemplateRelays(urls: string[]): string[] { + const preferredOrder = [ + NEW_USER_HTTP_RELAY_URL, + 'wss://profiles.nostr1.com', + 'wss://nos.lol', + 'wss://relay.primal.net', + 'wss://relay.damus.io', + 'wss://thecitadel.nostr1.com' + ] + const byKey = new Map(urls.map((u) => [templateRelayKey(u), u])) + const ordered: string[] = [] + for (const pref of preferredOrder) { + const u = byKey.get(templateRelayKey(pref)) + if (u) { + ordered.push(u) + byKey.delete(templateRelayKey(u)) + } + } + for (const u of urls) { + const k = templateRelayKey(u) + if (byKey.has(k)) { + ordered.push(u) + byKey.delete(k) + } + } + return ordered +} + export function markNewUserTemplateBroadcastPending(pubkey: string): void { if (typeof sessionStorage === 'undefined') return sessionStorage.setItem(BROADCAST_PENDING_KEY, pubkey) @@ -45,7 +106,10 @@ export function newUserTemplatePublishRelays(kind: number, relayList: TRelayList kind === kinds.Metadata || kind === kinds.RelayList ? dedupeNormalizeRelayUrlsOrdered([...write, ...PROFILE_RELAY_URLS]) : write - return filterRelaysForEventPublish(merged, kind) + const filtered = filterRelaysForEventPublish(merged, kind) + .filter((u) => !isExcludedFromTemplateBroadcast(u)) + .filter((u) => relayAllowsTemplateKind(u, kind)) + return prioritizeNewUserTemplateRelays(filtered).slice(0, maxTemplatePublishRelays(kind)) } async function loadRelayListForPublish(pubkey: string): Promise { @@ -101,7 +165,7 @@ async function broadcastNewUserTemplateFromStorage(pubkey: string): Promise { expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2) }) + it('builds blocked relays kind 10006 with dead relays', () => { + expect(drafts.blockedRelays.kind).toBe(ExtendedKind.BLOCKED_RELAYS) + const blocked = drafts.blockedRelays.tags.filter((t) => t[0] === 'relay').map((t) => t[1]) + expect(blocked).toEqual([...NEW_USER_BLOCKED_RELAY_URLS]) + }) + it('splits mailbox read and write relays', () => { expect(drafts.relayList.kind).toBe(kinds.RelayList) const readTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'read') @@ -97,19 +103,22 @@ describe('buildNewUserTemplateDrafts', () => { describe('newUserTemplatePublishRelays', () => { const relayList = templateRelayList() - it('uses template write outboxes only for list kinds', () => { + it('caps list kinds to three stable write relays and skips flaky mirrors', () => { const targets = newUserTemplatePublishRelays(10015, relayList) - expectRelayKeys(targets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL]) - const profileOnlyUrls = PROFILE_RELAY_URLS.filter((u) => !FAST_WRITE_RELAY_URLS.includes(u)) - for (const profileUrl of profileOnlyUrls) { - expect(targets.map(relayKey)).not.toContain(relayKey(profileUrl)) - } + expect(targets.length).toBeLessThanOrEqual(3) + expect(targets.map(relayKey)).not.toContain(relayKey('wss://relay.layer.systems')) + expect(targets.map(relayKey)).not.toContain(relayKey('wss://profiles.nostrver.se/')) + expect(targets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/')) + expectRelayKeys(targets, [NEW_USER_HTTP_RELAY_URL]) }) - it('adds profile relays for kind 0 and 10002', () => { + it('adds profile relays for kind 0 and 10002 up to four targets', () => { const profileTargets = newUserTemplatePublishRelays(kinds.Metadata, relayList) - expectRelayKeys(profileTargets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, ...PROFILE_RELAY_URLS]) + expect(profileTargets.length).toBeLessThanOrEqual(4) + expectRelayKeys(profileTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com']) + expect(profileTargets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/')) const relayListTargets = newUserTemplatePublishRelays(kinds.RelayList, relayList) - expectRelayKeys(relayListTargets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, ...PROFILE_RELAY_URLS]) + expect(relayListTargets.length).toBeLessThanOrEqual(4) + expectRelayKeys(relayListTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com']) }) }) diff --git a/src/lib/new-user-template.ts b/src/lib/new-user-template.ts index b13b6af4..0b4dd12c 100644 --- a/src/lib/new-user-template.ts +++ b/src/lib/new-user-template.ts @@ -4,6 +4,7 @@ import { FAST_WRITE_RELAY_URLS } from '@/constants' import { + createBlockedRelaysDraftEvent, createFavoriteRelaysDraftEvent, createFollowListDraftEvent, createHttpRelayListDraftEvent, @@ -16,6 +17,12 @@ import { TDraftEvent, TMailboxRelay } from '@/types' export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' +/** Dead relays seeded into kind 10006 for new accounts. */ +export const NEW_USER_BLOCKED_RELAY_URLS = [ + 'wss://orly-relay.imwald.eu', + 'wss://relay.nostr.band' +] as const + export const NEW_USER_INTEREST_TOPICS = [ 'art', 'music', @@ -66,6 +73,10 @@ export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], []) } +export function buildNewUserBlockedRelaysDraft(): TDraftEvent { + return createBlockedRelaysDraftEvent([...NEW_USER_BLOCKED_RELAY_URLS]) +} + export function buildNewUserRelayListDraft(): TDraftEvent { return createRelayListDraftEvent(buildNewUserMailboxRelays()) } @@ -89,6 +100,7 @@ export function buildNewUserMuteListDraft(): TDraftEvent { export type TNewUserTemplateDrafts = { profile: TDraftEvent favoriteRelays: TDraftEvent + blockedRelays: TDraftEvent relayList: TDraftEvent httpRelayList: TDraftEvent interestList: TDraftEvent @@ -100,6 +112,7 @@ export function buildNewUserTemplateDrafts(pubkey: string): TNewUserTemplateDraf return { profile: buildNewUserProfileDraft(pubkey), favoriteRelays: buildNewUserFavoriteRelaysDraft(), + blockedRelays: buildNewUserBlockedRelaysDraft(), relayList: buildNewUserRelayListDraft(), httpRelayList: buildNewUserHttpRelayListDraft(), interestList: buildNewUserInterestListDraft(), diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index 2cc2ca4a..684d2e2d 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -126,6 +126,15 @@ describe('relaySessionStrikes publish failures', () => { const entry = snap.entries.find((e) => e.key.includes('relay.example.com')) expect(entry?.entry.publishFailures).toBe(1) }) + + it('applies rate-limit cooldown on publish rate-limit NOTICE instead of accruing strikes', () => { + const url = 'wss://relay.damus.io/' + relaySessionStrikes.recordPublishFailure(url, 'rate-limited: you are noting too much') + expect(relaySessionStrikes.isPublishSkipped(url)).toBe(true) + const snap = relaySessionStrikes.getDebugSnapshot() + const entry = snap.entries.find((e) => e.key.includes('damus')) + expect(entry?.entry.publishFailures).toBe(0) + }) }) describe('isRelayStrikeEntryActive', () => { diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index 2f3027b0..edada505 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -30,7 +30,7 @@ const STRIKE_INCREMENT_DEBOUNCE_MS = 30 * 1000 export type RelayNoticeClass = 'rate_limit' | 'fetch_failed' | 'neutral' const RATE_LIMIT_RE = - /too many concurrent|concurrent req|rate\s*limit|overloaded|429|slow down|throttl|backoff|try again later|maximum\s+subscriptions/i + /too many concurrent|concurrent req|rate[\s-]*limit|overloaded|429|slow down|throttl|backoff|try again later|maximum\s+subscriptions|noting too much/i const FETCH_FAILED_RE = /failed to fetch events/i @@ -319,7 +319,13 @@ class RelaySessionStrikes { } recordPublishFailure(url: string, errorMessage?: string): void { - if (errorMessage && isRelayPublishPolicyRejection(errorMessage)) return + if (errorMessage) { + if (isRelayPublishPolicyRejection(errorMessage)) return + if (classifyRelayNotice(errorMessage) === 'rate_limit') { + this.applyRateLimitCooldownForUrl(url) + return + } + } const key = sessionKey(url) if (!key) return const now = Date.now() diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 8640093b..7c458a2b 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -12,7 +12,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -529,13 +529,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { ) : ( - - + + {defaultImage ? : null} )} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 51eb3a32..c9410ff9 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,3 +1,4 @@ +import { APP_RESET_TO_LANDING_EVENT } from '@/constants' import storage from '@/services/local-storage.service' import LoginDialog from '@/components/LoginDialog' import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' @@ -1155,6 +1156,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { storage.switchAccount(null) setAccount(null) setSigner(null) + window.dispatchEvent(new CustomEvent(APP_RESET_TO_LANDING_EVENT)) return null } const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true }) @@ -1966,6 +1968,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const signed = { profile: await signDraft(drafts.profile), favoriteRelays: await signDraft(drafts.favoriteRelays), + blockedRelays: await signDraft(drafts.blockedRelays), relayList: await signDraft(drafts.relayList), httpRelayList: await signDraft(drafts.httpRelayList), interestList: await signDraft(drafts.interestList), @@ -1976,6 +1979,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await Promise.all([ indexedDb.putReplaceableEvent(signed.profile), indexedDb.putReplaceableEvent(signed.favoriteRelays), + indexedDb.putReplaceableEvent(signed.blockedRelays), indexedDb.putReplaceableEvent(signed.relayList), indexedDb.putReplaceableEvent(signed.httpRelayList), indexedDb.putReplaceableEvent(signed.interestList), @@ -1983,6 +1987,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { indexedDb.putReplaceableEvent(signed.muteList) ]) + const blockedUrls = blockedRelayUrlsFromEvent(signed.blockedRelays) + setViewerBlockedRelayUrls(blockedUrls) + setBlockedRelaysEvent(signed.blockedRelays) + client.updateRelayListCache(signed.relayList) void client.updateFollowListCache(signed.followList).catch(() => {}) void replaceableEventService.updateReplaceableEventCache(signed.profile).catch(() => {}) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b661ba55..a4bbacd3 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -202,7 +202,7 @@ import { } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' import { initRelayPoolIdle, touchRelayPoolActivity, closePublishTransientRelaySockets, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' -import { relaySessionStrikes } from '@/lib/relay-strikes' +import { classifyRelayNotice, relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' import { ISigner, @@ -2109,7 +2109,11 @@ class ClientService extends EventTarget { error: error instanceof Error ? error.message : 'Connection failed' }) const errMsg = error instanceof Error ? error.message : 'Connection failed' - relaySessionStrikes.recordPublishFailure(url, errMsg) + if (classifyRelayNotice(errMsg) === 'rate_limit' || /\b429\b/.test(errMsg)) { + relaySessionStrikes.applyConnectionRateLimitCooldownForUrl(url) + } else { + relaySessionStrikes.recordPublishFailure(url, errMsg) + } } finally { clearTimeout(relayTimeout) const currentFinished = ++finishedCount