From dddabbc1d172d5a2c1e82f9bae2953bb730ce554 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 9 Jan 2025 17:20:59 +0100 Subject: [PATCH 1/4] feat(errors): timeout error and set prototype --- src/types/errors.ts | 14 +++++++++++++- src/utils/utils.ts | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) 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/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 }) } From a247f05f6e666de7ea144dfcb306ce5d35a2e7bb Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 14 Jan 2025 17:16:22 +0100 Subject: [PATCH 2/4] refactor: publish then catch to try catch --- src/contexts/NDKContext.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 [] + } } /** From 5d479102d4d363e61574a12405ac66b55b2a8d9e Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 14 Jan 2025 17:23:47 +0100 Subject: [PATCH 3/4] feat: mod submission try again --- src/components/ModForm.tsx | 58 ++++++++++++++++++++-- src/pages/submitMod/action.ts | 90 +++++++++++++++++++++++------------ src/types/mod.ts | 11 +++++ 3 files changed, 123 insertions(+), 36 deletions(-) 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 From ce27515bfeb0ea8e8632f633837214120ec697f1 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 14 Jan 2025 17:48:15 +0100 Subject: [PATCH 4/4] feat: spinner with timer --- src/components/LoadingSpinner/index.tsx | 53 +++++++++++++++++++++++-- src/pages/submitMod/index.tsx | 6 ++- 2 files changed, 54 insertions(+), 5 deletions(-) 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/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 + )}