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/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/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