diff --git a/src/components/LoadingSpinner/index.tsx b/src/components/LoadingSpinner/index.tsx
index f1f79af..12f820e 100644
--- a/src/components/LoadingSpinner/index.tsx
+++ b/src/components/LoadingSpinner/index.tsx
@@ -1,3 +1,4 @@
+import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useNavigation } from 'react-router-dom'
import styles from '../../styles/loadingSpinner.module.scss'
@@ -5,9 +6,7 @@ interface Props {
desc: string
}
-export const LoadingSpinner = (props: Props) => {
- const { desc } = props
-
+export const LoadingSpinner = ({ desc }: Props) => {
return (
@@ -28,3 +27,51 @@ export const RouterLoadingSpinner = () => {
return
}
+
+interface TimerLoadingSpinner {
+ timeoutMs?: number
+ countdownMs?: number
+}
+
+export const TimerLoadingSpinner = ({
+ timeoutMs = 10000,
+ countdownMs = 30000,
+ children
+}: PropsWithChildren
) => {
+ const [show, setShow] = useState(false)
+ const [timer, setTimer] = useState(
+ Math.floor((countdownMs - timeoutMs) / 1000)
+ )
+ const startTime = useMemo(() => Date.now(), [])
+
+ useEffect(() => {
+ let interval: number
+ const timeout = window.setTimeout(() => {
+ setShow(true)
+ interval = window.setInterval(() => {
+ const time = Date.now() - startTime
+ const diff = Math.max(0, countdownMs - time)
+ setTimer(Math.floor(diff / 1000))
+ }, 1000)
+ }, timeoutMs)
+
+ return () => {
+ clearTimeout(timeout)
+ clearInterval(interval)
+ }
+ }, [countdownMs, startTime, timeoutMs])
+
+ return (
+
+
+
+ {children}
+ {show && (
+ <>
+
You can try again in {timer}s...
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx
index a7c3699..afeccd5 100644
--- a/src/components/ModForm.tsx
+++ b/src/components/ModForm.tsx
@@ -18,9 +18,9 @@ import { useGames } from '../hooks'
import '../styles/styles.css'
import {
DownloadUrl,
- FormErrors,
ModFormState,
- ModPageLoaderResult
+ ModPageLoaderResult,
+ SubmitModActionResult
} from '../types'
import { initializeFormState, MOD_DRAFT_CACHE_KEY } from '../utils'
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
@@ -41,7 +41,11 @@ interface GameOption {
export const ModForm = () => {
const data = useLoaderData() as ModPageLoaderResult
const mod = data?.mod
- const formErrors = useActionData() as FormErrors
+ const actionData = useActionData() as SubmitModActionResult
+ const formErrors = useMemo(
+ () => (actionData?.type === 'validation' ? actionData.error : undefined),
+ [actionData]
+ )
const navigation = useNavigation()
const submit = useSubmit()
const games = useGames()
@@ -56,7 +60,17 @@ export const ModForm = () => {
)
useEffect(() => {
- !isEditing && setCache(formState)
+ if (!isEditing) {
+ const newCache = _.cloneDeep(formState)
+
+ // Remove aTag, dTag and published_at from cache
+ // These are used for editing and try again timeout
+ newCache.aTag = ''
+ newCache.dTag = ''
+ newCache.published_at = 0
+
+ setCache(newCache)
+ }
}, [formState, isEditing, setCache])
const editorRef = useRef(null)
@@ -154,6 +168,32 @@ export const ModForm = () => {
},
[]
)
+ const [showTryAgainPopup, setShowTryAgainPopup] = useState(false)
+ useEffect(() => {
+ const isTimeout = actionData?.type === 'timeout'
+ setShowTryAgainPopup(isTimeout)
+ if (isTimeout) {
+ setFormState((prev) => ({
+ ...prev,
+ aTag: actionData.data.aTag,
+ dTag: actionData.data.dTag,
+ published_at: actionData.data.published_at
+ }))
+ }
+ }, [actionData])
+ const handleTryAgainConfirm = useCallback(
+ (confirm: boolean) => {
+ setShowTryAgainPopup(false)
+
+ // Cancel if not confirmed
+ if (!confirm) return
+ submit(JSON.stringify(formState), {
+ method: isEditing ? 'put' : 'post',
+ encType: 'application/json'
+ })
+ },
+ [formState, isEditing, submit]
+ )
const [showConfirmPopup, setShowConfirmPopup] = useState(false)
const handleReset = useCallback(() => {
@@ -426,13 +466,21 @@ export const ModForm = () => {
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
+ {showTryAgainPopup && (
+
setShowTryAgainPopup(false)}
+ header={'Publish'}
+ label={`Submission timed out. Do you want to try again?`}
+ />
+ )}
{showConfirmPopup && (
setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
- mod
+ isEditing
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx
index fe9b773..dd5e605 100644
--- a/src/contexts/NDKContext.tsx
+++ b/src/contexts/NDKContext.tsx
@@ -369,16 +369,14 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
const publish = async (event: NDKEvent): Promise => {
if (!event.sig) throw new Error('Before publishing first sign the event!')
- return event
- .publish(undefined, 10000)
- .then((res) => {
- const relaysPublishedOn = Array.from(res)
- return relaysPublishedOn.map((relay) => relay.url)
- })
- .catch((err) => {
- console.error(`An error occurred in publishing event`, err)
- return []
- })
+ try {
+ const res = await event.publish(undefined, 10000)
+ const relaysPublishedOn = Array.from(res)
+ return relaysPublishedOn.map((relay) => relay.url)
+ } catch (err) {
+ console.error(`An error occurred in publishing event`, err)
+ return []
+ }
}
/**
diff --git a/src/pages/submitMod/action.ts b/src/pages/submitMod/action.ts
index ab5af31..5e51df4 100644
--- a/src/pages/submitMod/action.ts
+++ b/src/pages/submitMod/action.ts
@@ -5,7 +5,12 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getModPageRoute } from 'routes'
import { store } from 'store'
-import { FormErrors, ModFormState } from 'types'
+import {
+ FormErrors,
+ ModFormState,
+ SubmitModActionResult,
+ TimeoutError
+} from 'types'
import {
isReachable,
isValidImageUrl,
@@ -14,7 +19,8 @@ import {
LogType,
MOD_DRAFT_CACHE_KEY,
now,
- removeLocalStorageItem
+ removeLocalStorageItem,
+ timeout
} from 'utils'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../../constants'
@@ -51,29 +57,31 @@ export const submitModRouteAction =
// Check for errors
const formErrors = await validateState(formState)
- // Return earily if there are any errors
- if (Object.keys(formErrors).length) return formErrors
+ // Return early if there are any errors
+ if (Object.keys(formErrors).length) {
+ return {
+ type: 'validation',
+ error: formErrors
+ } as SubmitModActionResult
+ }
+
+ const currentTimeStamp = now()
- // Check if we are editing or this is a new mob
const { naddr } = params
+ // Check if we are editing or this is a new mob
const isEditing = naddr && request.method === 'PUT'
const uuid = formState.dTag || uuidv4()
- const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
+ const published_at = formState.published_at || currentTimeStamp
const tags = [
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
- [
- 'published_at',
- isEditing
- ? formState.published_at.toString()
- : currentTimeStamp.toString()
- ],
+ ['published_at', published_at.toString()],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
@@ -131,32 +139,52 @@ export const submitModRouteAction =
}
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
- const publishedOnRelays = await ndkContext.publish(ndkEvent)
+ // Publishing a mod sometime hangs (ndk.publish has internal timeout of 10s)
+ // Make sure to actually throw a timeout error (30s)
+ try {
+ const publishedOnRelays = await Promise.race([
+ ndkContext.publish(ndkEvent),
+ timeout(30000)
+ ])
+ // Handle cases where publishing failed or succeeded
+ if (publishedOnRelays.length === 0) {
+ toast.error('Failed to publish event on any relay')
+ } else {
+ toast.success(
+ `Event published successfully to the following relays\n\n${publishedOnRelays.join(
+ '\n'
+ )}`
+ )
- // Handle cases where publishing failed or succeeded
- if (publishedOnRelays.length === 0) {
- toast.error('Failed to publish event on any relay')
- } else {
- toast.success(
- `Event published successfully to the following relays\n\n${publishedOnRelays.join(
- '\n'
- )}`
- )
+ !isEditing && removeLocalStorageItem(MOD_DRAFT_CACHE_KEY)
- !isEditing && removeLocalStorageItem(MOD_DRAFT_CACHE_KEY)
+ const naddr = nip19.naddrEncode({
+ identifier: aTag,
+ pubkey: signedEvent.pubkey,
+ kind: signedEvent.kind,
+ relays: publishedOnRelays
+ })
- const naddr = nip19.naddrEncode({
- identifier: aTag,
- pubkey: signedEvent.pubkey,
- kind: signedEvent.kind,
- relays: publishedOnRelays
- })
+ return redirect(getModPageRoute(naddr))
+ }
+ } catch (error) {
+ if (error instanceof TimeoutError) {
+ const result: SubmitModActionResult = {
+ type: 'timeout',
+ data: {
+ dTag: uuid,
+ aTag,
+ published_at: published_at
+ }
+ }
+ return result
+ }
- return redirect(getModPageRoute(naddr))
+ // Rethrow non-timeout for general catch
+ throw error
}
} catch (error) {
log(true, LogType.Error, 'Failed to sign the event!', error)
- toast.error('Failed to sign the event!')
return null
}
diff --git a/src/pages/submitMod/index.tsx b/src/pages/submitMod/index.tsx
index cfd1b66..c628053 100644
--- a/src/pages/submitMod/index.tsx
+++ b/src/pages/submitMod/index.tsx
@@ -1,4 +1,4 @@
-import { LoadingSpinner } from 'components/LoadingSpinner'
+import { LoadingSpinner, TimerLoadingSpinner } from 'components/LoadingSpinner'
import { ModForm } from 'components/ModForm'
import { ProfileSection } from 'components/ProfileSection'
import { useAppSelector } from 'hooks'
@@ -24,7 +24,9 @@ export const SubmitModPage = () => {
)}
{navigation.state === 'submitting' && (
-
+
+ Publishing mod to relays
+
)}
diff --git a/src/types/errors.ts b/src/types/errors.ts
index 520d6f3..efe2c01 100644
--- a/src/types/errors.ts
+++ b/src/types/errors.ts
@@ -42,8 +42,8 @@ export class BaseError extends Error {
super(message, { cause: cause })
this.name = this.constructor.name
-
this.context = context
+ Object.setPrototypeOf(this, BaseError.prototype)
}
}
@@ -56,3 +56,15 @@ export function errorFeedback(error: unknown) {
log(true, LogType.Error, error)
}
}
+
+export class TimeoutError extends Error {
+ constructor(timeoutMs?: number) {
+ let message = 'Time elapsed.'
+ if (timeoutMs) {
+ message += `\n* ${timeoutMs}ms`
+ }
+ super(message)
+ this.name = this.constructor.name
+ Object.setPrototypeOf(this, TimeoutError.prototype)
+ }
+}
diff --git a/src/types/mod.ts b/src/types/mod.ts
index 799ef64..1b4ae53 100644
--- a/src/types/mod.ts
+++ b/src/types/mod.ts
@@ -68,6 +68,17 @@ export interface ModPageLoaderResult {
isRepost: boolean
}
+export type SubmitModActionResult =
+ | { type: 'validation'; error: FormErrors }
+ | {
+ type: 'timeout'
+ data: {
+ dTag: string
+ aTag: string
+ published_at: number
+ }
+ }
+
export interface FormErrors {
game?: string
title?: string
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index 2f19e8e..c7e03c6 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -1,3 +1,5 @@
+import { TimeoutError } from 'types'
+
export enum LogType {
Info = 'info',
Error = 'error',
@@ -23,14 +25,14 @@ export const log = (
/**
* Creates a promise that rejects with a timeout error after a specified duration.
* @param ms The duration in milliseconds after which the promise should reject. Defaults to 60000 milliseconds (1 minute).
- * @returns A promise that rejects with an Error('Timeout') after the specified duration.
+ * @returns A promise that rejects with an TimeoutError after the specified duration.
*/
export const timeout = (ms: number = 60000) => {
return new Promise((_, reject) => {
// Set a timeout using setTimeout
setTimeout(() => {
// Reject the promise with an Error indicating a timeout
- reject(new Error('Timeout'))
+ reject(new TimeoutError(ms))
}, ms) // Timeout duration in milliseconds
})
}