mod publish - try gain #191

Merged
enes merged 4 commits from 186-try-again-mod-publish into staging 2025-01-15 12:24:37 +00:00
8 changed files with 202 additions and 54 deletions

View File

@ -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>
)
}

View File

@ -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?`
} }

View File

@ -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) {
}) console.error(`An error occurred in publishing event`, err)
.catch((err) => { return []
console.error(`An error occurred in publishing event`, err) }
return []
})
} }
/** /**

View File

@ -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,32 +139,52 @@ 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
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 !isEditing && removeLocalStorageItem(MOD_DRAFT_CACHE_KEY)
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) const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
const naddr = nip19.naddrEncode({ return redirect(getModPageRoute(naddr))
identifier: aTag, }
pubkey: signedEvent.pubkey, } catch (error) {
kind: signedEvent.kind, if (error instanceof TimeoutError) {
relays: publishedOnRelays 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) { } 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
} }

View File

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

View File

@ -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)
}
}

View File

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

View File

@ -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
}) })
} }