feat(notes): add try again popup for submit and validation

This commit is contained in:
en 2025-02-21 13:13:33 +01:00
parent be9488a752
commit 53dd3cc193
4 changed files with 137 additions and 52 deletions

View File

@ -2,12 +2,19 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
import { FALLBACK_PROFILE_IMAGE } from '../../constants' import { FALLBACK_PROFILE_IMAGE } from '../../constants'
import { useAppSelector, useLocalCache } from 'hooks' import { useAppSelector, useLocalCache } from 'hooks'
import { useProfile } from 'hooks/useProfile' import { useProfile } from 'hooks/useProfile'
import { Navigate, useNavigation, useSubmit } from 'react-router-dom' import {
Navigate,
useActionData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { appRoutes } from 'routes' import { appRoutes } from 'routes'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils' import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils'
import { NotePreview } from './NotePreview' import { NotePreview } from './NotePreview'
import { NoteSubmitForm } from 'types' import { NoteSubmitActionResult, NoteSubmitForm } from 'types'
import { InputError } from 'components/Inputs/Error'
import { AlertPopup } from 'components/AlertPopup'
interface NoteSubmitProps { interface NoteSubmitProps {
initialContent?: string | undefined initialContent?: string | undefined
@ -27,10 +34,18 @@ export const NoteSubmit = ({
const [content, setContent] = useState(initialContent ?? cache?.content ?? '') const [content, setContent] = useState(initialContent ?? cache?.content ?? '')
const [nsfw, setNsfw] = useState(cache?.nsfw ?? false) const [nsfw, setNsfw] = useState(cache?.nsfw ?? false)
const [showPreview, setShowPreview] = useState(!!initialContent) const [showPreview, setShowPreview] = useState(!!initialContent)
const image = profile?.image || FALLBACK_PROFILE_IMAGE const image = useMemo(
() => profile?.image || FALLBACK_PROFILE_IMAGE,
[profile?.image]
)
const ref = useRef<HTMLTextAreaElement>(null) const ref = useRef<HTMLTextAreaElement>(null)
const submit = useSubmit() const submit = useSubmit()
const actionData = useActionData() as NoteSubmitActionResult
const formErrors = useMemo(
() =>
actionData?.type === 'validation' ? actionData.formErrors : undefined,
[actionData]
)
useEffect(() => { useEffect(() => {
if (ref.current && (!!initialContent || !!cache?.content)) { if (ref.current && (!!initialContent || !!cache?.content)) {
adjustTextareaHeight(ref.current) adjustTextareaHeight(ref.current)
@ -45,6 +60,57 @@ export const NoteSubmit = ({
}) })
}, [content, nsfw, setCache]) }, [content, nsfw, setCache])
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
useEffect(() => {
const isTimeout = actionData?.type === 'timeout'
setShowTryAgainPopup(isTimeout)
if (isTimeout && actionData.action.intent === 'submit') {
setContent(actionData.action.data.content)
setNsfw(actionData.action.data.nsfw)
}
}, [actionData])
const handleFormSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault()
const formSubmit = {
intent: 'submit',
data: {
content,
nsfw
}
}
// Reset form
setContent('')
setNsfw(false)
submit(JSON.stringify(formSubmit), {
method: 'post',
encType: 'application/json',
action: appRoutes.feed
})
typeof handleClose === 'function' && handleClose()
},
[content, handleClose, nsfw, submit]
)
const handleTryAgainConfirm = useCallback(
(confirm: boolean) => {
setShowTryAgainPopup(false)
// Cancel if not confirmed
if (!confirm) return
// Reset form
setContent('')
setNsfw(false)
handleFormSubmit()
},
[handleFormSubmit]
)
const handleContentChange = ( const handleContentChange = (
event: React.ChangeEvent<HTMLTextAreaElement> event: React.ChangeEvent<HTMLTextAreaElement>
) => { ) => {
@ -52,29 +118,6 @@ export const NoteSubmit = ({
adjustTextareaHeight(event.currentTarget) adjustTextareaHeight(event.currentTarget)
} }
const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formSubmit = {
intent: 'submit',
data: {
content,
nsfw
}
}
// Reset form
setContent('')
setNsfw(false)
submit(JSON.stringify(formSubmit), {
method: 'post',
encType: 'application/json',
action: appRoutes.feed
})
typeof handleClose === 'function' && handleClose()
}
const handlePreviewToggle = () => { const handlePreviewToggle = () => {
setShowPreview((prev) => !prev) setShowPreview((prev) => !prev)
} }
@ -151,13 +194,24 @@ export const NoteSubmit = ({
className='btn btnMain' className='btn btnMain'
type='submit' type='submit'
style={{ padding: '5px 20px', borderRadius: '8px' }} style={{ padding: '5px 20px', borderRadius: '8px' }}
disabled={navigation.state !== 'idle'} disabled={navigation.state !== 'idle' || !content.length}
> >
{navigation.state === 'submitting' ? 'Posting...' : 'Post'} {navigation.state === 'submitting' ? 'Posting...' : 'Post'}
</button> </button>
</div> </div>
</div> </div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{showPreview && <NotePreview content={content} />} {showPreview && <NotePreview content={content} />}
{showTryAgainPopup && (
<AlertPopup
handleConfirm={handleTryAgainConfirm}
handleClose={() => setShowTryAgainPopup(false)}
header={'Post'}
label={`Posting timed out. Do you want to try again?`}
/>
)}
</div> </div>
</form> </form>
</> </>

View File

@ -129,6 +129,11 @@ export const FeedTabPosts = () => {
return _notes return _notes
}, [filterOptions.repost, notes, showing]) }, [filterOptions.repost, notes, showing])
const newNotes = useMemo(
() => discoveredNotes.filter((d) => !notes.some((n) => n.id === d.id)),
[discoveredNotes, notes]
)
if (!userPubkey) return null if (!userPubkey) return null
const handleLoadMore = () => { const handleLoadMore = () => {
@ -170,15 +175,15 @@ export const FeedTabPosts = () => {
}) })
} }
const discoveredCount = discoveredNotes.length const discoveredCount = newNotes.length
const handleDiscoveredClick = () => { const handleDiscoveredClick = () => {
// Combine newly discovred with the notes // Combine newly discovred with the notes
// Skip events already in notes // Skip events already in notes
setNotes((prev) => { setNotes((prev) => {
return Array.from(new Set([...discoveredNotes, ...prev])) return [...newNotes, ...prev]
}) })
// Increase showing by the discovered count // Increase showing by the discovered count
setShowing((prev) => prev + discoveredNotes.length) setShowing((prev) => prev + discoveredCount)
setDiscoveredNotes([]) setDiscoveredNotes([])
} }

View File

@ -4,13 +4,19 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { getFeedNotePageRoute } from 'routes' import { getFeedNotePageRoute } from 'routes'
import { store } from 'store' import { store } from 'store'
import { NoteAction, NoteSubmitForm, NoteSubmitFormErrors } from 'types' import {
NoteAction,
NoteSubmitForm,
NoteSubmitFormErrors,
TimeoutError
} from 'types'
import { import {
log, log,
LogType, LogType,
NOTE_DRAFT_CACHE_KEY, NOTE_DRAFT_CACHE_KEY,
now, now,
removeLocalStorageItem removeLocalStorageItem,
timeout
} from 'utils' } from 'utils'
export const feedPostRouteAction = export const feedPostRouteAction =
@ -38,8 +44,9 @@ export const feedPostRouteAction =
return null return null
} }
let action: NoteAction | undefined
try { try {
const action = (await request.json()) as NoteAction action = (await request.json()) as NoteAction
switch (action.intent) { switch (action.intent) {
case 'submit': case 'submit':
return await handleActionSubmit( return await handleActionSubmit(
@ -59,6 +66,14 @@ export const feedPostRouteAction =
throw new Error('Unsupported feed action. Intent missing.') throw new Error('Unsupported feed action. Intent missing.')
} }
} catch (error) { } catch (error) {
if (action && error instanceof TimeoutError) {
log(true, LogType.Error, 'Failed to publish note. Try again initiated')
const result = {
type: 'timeout',
action
}
return result
}
log(true, LogType.Error, 'Failed to publish note', error) log(true, LogType.Error, 'Failed to publish note', error)
toast.error('Failed to publish note') toast.error('Failed to publish note')
return null return null
@ -82,7 +97,11 @@ async function handleActionSubmit(
) { ) {
const formErrors = validateFormData(data) const formErrors = validateFormData(data)
if (Object.keys(formErrors).length) return formErrors if (Object.keys(formErrors).length)
return {
type: 'validation',
formErrors
}
const content = decodeURIComponent(data.content!) const content = decodeURIComponent(data.content!)
const currentTimeStamp = now() const currentTimeStamp = now()
@ -98,24 +117,21 @@ async function handleActionSubmit(
pubkey pubkey
}) })
try { if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning'])
if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning'])
await ndkEvent.sign() await ndkEvent.sign()
const note1 = ndkEvent.encode() const note1 = ndkEvent.encode()
const publishedOnRelays = await ndkEvent.publish() const publishedOnRelays = await Promise.race([
if (publishedOnRelays.size === 0) { ndkEvent.publish(),
toast.error('Failed to publish note on any relay') timeout(30000)
return null ])
} else { if (publishedOnRelays.size === 0) {
toast.success('Note published successfully') toast.error('Failed to publish note on any relay')
removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY)
return redirect(getFeedNotePageRoute(note1))
}
} catch (error) {
log(true, LogType.Error, 'Failed to publish note', error)
toast.error('Failed to publish note')
return null return null
} else {
toast.success('Note published successfully')
removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY)
return redirect(getFeedNotePageRoute(note1))
} }
} }
async function handleActionRepost(ndk: NDK, data: NostrEvent, note1: string) { async function handleActionRepost(ndk: NDK, data: NostrEvent, note1: string) {

View File

@ -17,3 +17,13 @@ export type NoteAction =
note1: string note1: string
data: NostrEvent data: NostrEvent
} }
export type NoteSubmitActionResult =
| {
type: 'timeout'
action: NoteAction
}
| {
type: 'validation'
formErrors: NoteSubmitFormErrors
}