chore(git): merge pull request #191 from 186-try-again-mod-publish into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
Reviewed-on: #191
This commit is contained in:
commit
2949444c8a
@ -1,3 +1,4 @@
|
|||||||
|
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigation } from 'react-router-dom'
|
import { useNavigation } from 'react-router-dom'
|
||||||
import styles from '../../styles/loadingSpinner.module.scss'
|
import styles from '../../styles/loadingSpinner.module.scss'
|
||||||
|
|
||||||
@ -5,9 +6,7 @@ interface Props {
|
|||||||
desc: string
|
desc: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoadingSpinner = (props: Props) => {
|
export const LoadingSpinner = ({ desc }: Props) => {
|
||||||
const { desc } = props
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.loadingSpinnerOverlay}>
|
<div className={styles.loadingSpinnerOverlay}>
|
||||||
<div className={styles.loadingSpinnerContainer}>
|
<div className={styles.loadingSpinnerContainer}>
|
||||||
@ -28,3 +27,51 @@ export const RouterLoadingSpinner = () => {
|
|||||||
|
|
||||||
return <LoadingSpinner desc={`${desc}...`} />
|
return <LoadingSpinner desc={`${desc}...`} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimerLoadingSpinner {
|
||||||
|
timeoutMs?: number
|
||||||
|
countdownMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimerLoadingSpinner = ({
|
||||||
|
timeoutMs = 10000,
|
||||||
|
countdownMs = 30000,
|
||||||
|
children
|
||||||
|
}: PropsWithChildren<TimerLoadingSpinner>) => {
|
||||||
|
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 (
|
||||||
|
<div className={styles.loadingSpinnerOverlay}>
|
||||||
|
<div className={styles.loadingSpinnerContainer}>
|
||||||
|
<div className={styles.loadingSpinner}></div>
|
||||||
|
{children}
|
||||||
|
{show && (
|
||||||
|
<>
|
||||||
|
<div>You can try again in {timer}s...</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -18,9 +18,9 @@ import { useGames } from '../hooks'
|
|||||||
import '../styles/styles.css'
|
import '../styles/styles.css'
|
||||||
import {
|
import {
|
||||||
DownloadUrl,
|
DownloadUrl,
|
||||||
FormErrors,
|
|
||||||
ModFormState,
|
ModFormState,
|
||||||
ModPageLoaderResult
|
ModPageLoaderResult,
|
||||||
|
SubmitModActionResult
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { initializeFormState, MOD_DRAFT_CACHE_KEY } from '../utils'
|
import { initializeFormState, MOD_DRAFT_CACHE_KEY } from '../utils'
|
||||||
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
|
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
|
||||||
@ -41,7 +41,11 @@ interface GameOption {
|
|||||||
export const ModForm = () => {
|
export const ModForm = () => {
|
||||||
const data = useLoaderData() as ModPageLoaderResult
|
const data = useLoaderData() as ModPageLoaderResult
|
||||||
const mod = data?.mod
|
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 navigation = useNavigation()
|
||||||
const submit = useSubmit()
|
const submit = useSubmit()
|
||||||
const games = useGames()
|
const games = useGames()
|
||||||
@ -56,7 +60,17 @@ export const ModForm = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
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])
|
}, [formState, isEditing, setCache])
|
||||||
|
|
||||||
const editorRef = useRef<EditorRef>(null)
|
const editorRef = useRef<EditorRef>(null)
|
||||||
@ -154,6 +168,32 @@ export const ModForm = () => {
|
|||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(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<boolean>(false)
|
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
@ -426,13 +466,21 @@ export const ModForm = () => {
|
|||||||
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
|
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{showTryAgainPopup && (
|
||||||
|
<AlertPopup
|
||||||
|
handleConfirm={handleTryAgainConfirm}
|
||||||
|
handleClose={() => setShowTryAgainPopup(false)}
|
||||||
|
header={'Publish'}
|
||||||
|
label={`Submission timed out. Do you want to try again?`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showConfirmPopup && (
|
{showConfirmPopup && (
|
||||||
<AlertPopup
|
<AlertPopup
|
||||||
handleConfirm={handleResetConfirm}
|
handleConfirm={handleResetConfirm}
|
||||||
handleClose={() => setShowConfirmPopup(false)}
|
handleClose={() => setShowConfirmPopup(false)}
|
||||||
header={'Are you sure?'}
|
header={'Are you sure?'}
|
||||||
label={
|
label={
|
||||||
mod
|
isEditing
|
||||||
? `Are you sure you want to clear all changes?`
|
? `Are you sure you want to clear all changes?`
|
||||||
: `Are you sure you want to clear all field data?`
|
: `Are you sure you want to clear all field data?`
|
||||||
}
|
}
|
||||||
|
@ -369,16 +369,14 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const publish = async (event: NDKEvent): Promise<string[]> => {
|
const publish = async (event: NDKEvent): Promise<string[]> => {
|
||||||
if (!event.sig) throw new Error('Before publishing first sign the event!')
|
if (!event.sig) throw new Error('Before publishing first sign the event!')
|
||||||
|
|
||||||
return event
|
try {
|
||||||
.publish(undefined, 10000)
|
const res = await event.publish(undefined, 10000)
|
||||||
.then((res) => {
|
|
||||||
const relaysPublishedOn = Array.from(res)
|
const relaysPublishedOn = Array.from(res)
|
||||||
return relaysPublishedOn.map((relay) => relay.url)
|
return relaysPublishedOn.map((relay) => relay.url)
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
console.error(`An error occurred in publishing event`, err)
|
console.error(`An error occurred in publishing event`, err)
|
||||||
return []
|
return []
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -5,7 +5,12 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom'
|
|||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { getModPageRoute } from 'routes'
|
import { getModPageRoute } from 'routes'
|
||||||
import { store } from 'store'
|
import { store } from 'store'
|
||||||
import { FormErrors, ModFormState } from 'types'
|
import {
|
||||||
|
FormErrors,
|
||||||
|
ModFormState,
|
||||||
|
SubmitModActionResult,
|
||||||
|
TimeoutError
|
||||||
|
} from 'types'
|
||||||
import {
|
import {
|
||||||
isReachable,
|
isReachable,
|
||||||
isValidImageUrl,
|
isValidImageUrl,
|
||||||
@ -14,7 +19,8 @@ import {
|
|||||||
LogType,
|
LogType,
|
||||||
MOD_DRAFT_CACHE_KEY,
|
MOD_DRAFT_CACHE_KEY,
|
||||||
now,
|
now,
|
||||||
removeLocalStorageItem
|
removeLocalStorageItem,
|
||||||
|
timeout
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { T_TAG_VALUE } from '../../constants'
|
import { T_TAG_VALUE } from '../../constants'
|
||||||
@ -51,29 +57,31 @@ export const submitModRouteAction =
|
|||||||
// Check for errors
|
// Check for errors
|
||||||
const formErrors = await validateState(formState)
|
const formErrors = await validateState(formState)
|
||||||
|
|
||||||
// Return earily if there are any errors
|
// Return early if there are any errors
|
||||||
if (Object.keys(formErrors).length) return formErrors
|
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
|
const { naddr } = params
|
||||||
|
// Check if we are editing or this is a new mob
|
||||||
const isEditing = naddr && request.method === 'PUT'
|
const isEditing = naddr && request.method === 'PUT'
|
||||||
|
|
||||||
const uuid = formState.dTag || uuidv4()
|
const uuid = formState.dTag || uuidv4()
|
||||||
const currentTimeStamp = now()
|
|
||||||
const aTag =
|
const aTag =
|
||||||
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
||||||
|
const published_at = formState.published_at || currentTimeStamp
|
||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
['d', uuid],
|
['d', uuid],
|
||||||
['a', aTag],
|
['a', aTag],
|
||||||
['r', formState.rTag],
|
['r', formState.rTag],
|
||||||
['t', T_TAG_VALUE],
|
['t', T_TAG_VALUE],
|
||||||
[
|
['published_at', published_at.toString()],
|
||||||
'published_at',
|
|
||||||
isEditing
|
|
||||||
? formState.published_at.toString()
|
|
||||||
: currentTimeStamp.toString()
|
|
||||||
],
|
|
||||||
['game', formState.game],
|
['game', formState.game],
|
||||||
['title', formState.title],
|
['title', formState.title],
|
||||||
['featuredImageUrl', formState.featuredImageUrl],
|
['featuredImageUrl', formState.featuredImageUrl],
|
||||||
@ -131,8 +139,13 @@ export const submitModRouteAction =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
|
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
|
// Handle cases where publishing failed or succeeded
|
||||||
if (publishedOnRelays.length === 0) {
|
if (publishedOnRelays.length === 0) {
|
||||||
toast.error('Failed to publish event on any relay')
|
toast.error('Failed to publish event on any relay')
|
||||||
@ -154,9 +167,24 @@ export const submitModRouteAction =
|
|||||||
|
|
||||||
return redirect(getModPageRoute(naddr))
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rethrow non-timeout for general catch
|
||||||
|
throw error
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(true, LogType.Error, 'Failed to sign the event!', error)
|
log(true, LogType.Error, 'Failed to sign the event!', error)
|
||||||
toast.error('Failed to sign the event!')
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
import { LoadingSpinner, TimerLoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { ModForm } from 'components/ModForm'
|
import { ModForm } from 'components/ModForm'
|
||||||
import { ProfileSection } from 'components/ProfileSection'
|
import { ProfileSection } from 'components/ProfileSection'
|
||||||
import { useAppSelector } from 'hooks'
|
import { useAppSelector } from 'hooks'
|
||||||
@ -24,7 +24,9 @@ export const SubmitModPage = () => {
|
|||||||
<LoadingSpinner desc='Fetching mod details from relays' />
|
<LoadingSpinner desc='Fetching mod details from relays' />
|
||||||
)}
|
)}
|
||||||
{navigation.state === 'submitting' && (
|
{navigation.state === 'submitting' && (
|
||||||
<LoadingSpinner desc='Publishing mod to relays' />
|
<TimerLoadingSpinner timeoutMs={10000} countdownMs={30000}>
|
||||||
|
Publishing mod to relays
|
||||||
|
</TimerLoadingSpinner>
|
||||||
)}
|
)}
|
||||||
<ModForm />
|
<ModForm />
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,8 +42,8 @@ export class BaseError extends Error {
|
|||||||
|
|
||||||
super(message, { cause: cause })
|
super(message, { cause: cause })
|
||||||
this.name = this.constructor.name
|
this.name = this.constructor.name
|
||||||
|
|
||||||
this.context = context
|
this.context = context
|
||||||
|
Object.setPrototypeOf(this, BaseError.prototype)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,3 +56,15 @@ export function errorFeedback(error: unknown) {
|
|||||||
log(true, LogType.Error, error)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -68,6 +68,17 @@ export interface ModPageLoaderResult {
|
|||||||
isRepost: boolean
|
isRepost: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubmitModActionResult =
|
||||||
|
| { type: 'validation'; error: FormErrors }
|
||||||
|
| {
|
||||||
|
type: 'timeout'
|
||||||
|
data: {
|
||||||
|
dTag: string
|
||||||
|
aTag: string
|
||||||
|
published_at: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormErrors {
|
export interface FormErrors {
|
||||||
game?: string
|
game?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { TimeoutError } from 'types'
|
||||||
|
|
||||||
export enum LogType {
|
export enum LogType {
|
||||||
Info = 'info',
|
Info = 'info',
|
||||||
Error = 'error',
|
Error = 'error',
|
||||||
@ -23,14 +25,14 @@ export const log = (
|
|||||||
/**
|
/**
|
||||||
* Creates a promise that rejects with a timeout error after a specified duration.
|
* 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).
|
* @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) => {
|
export const timeout = (ms: number = 60000) => {
|
||||||
return new Promise<never>((_, reject) => {
|
return new Promise<never>((_, reject) => {
|
||||||
// Set a timeout using setTimeout
|
// Set a timeout using setTimeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Reject the promise with an Error indicating a timeout
|
// Reject the promise with an Error indicating a timeout
|
||||||
reject(new Error('Timeout'))
|
reject(new TimeoutError(ms))
|
||||||
}, ms) // Timeout duration in milliseconds
|
}, ms) // Timeout duration in milliseconds
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user