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