diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx index ef086bf..a77430e 100644 --- a/src/components/Notes/NoteSubmit.tsx +++ b/src/components/Notes/NoteSubmit.tsx @@ -2,12 +2,19 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { FALLBACK_PROFILE_IMAGE } from '../../constants' import { useAppSelector, useLocalCache } from 'hooks' 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 { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils' 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 { initialContent?: string | undefined @@ -27,10 +34,18 @@ export const NoteSubmit = ({ const [content, setContent] = useState(initialContent ?? cache?.content ?? '') const [nsfw, setNsfw] = useState(cache?.nsfw ?? false) 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(null) const submit = useSubmit() - + const actionData = useActionData() as NoteSubmitActionResult + const formErrors = useMemo( + () => + actionData?.type === 'validation' ? actionData.formErrors : undefined, + [actionData] + ) useEffect(() => { if (ref.current && (!!initialContent || !!cache?.content)) { adjustTextareaHeight(ref.current) @@ -45,6 +60,57 @@ export const NoteSubmit = ({ }) }, [content, nsfw, setCache]) + const [showTryAgainPopup, setShowTryAgainPopup] = useState(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) => { + 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 = ( event: React.ChangeEvent ) => { @@ -52,29 +118,6 @@ export const NoteSubmit = ({ adjustTextareaHeight(event.currentTarget) } - const handleFormSubmit = async (event: React.FormEvent) => { - 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 = () => { setShowPreview((prev) => !prev) } @@ -151,13 +194,24 @@ export const NoteSubmit = ({ className='btn btnMain' type='submit' style={{ padding: '5px 20px', borderRadius: '8px' }} - disabled={navigation.state !== 'idle'} + disabled={navigation.state !== 'idle' || !content.length} > {navigation.state === 'submitting' ? 'Posting...' : 'Post'} + {typeof formErrors?.content !== 'undefined' && ( + + )} {showPreview && } + {showTryAgainPopup && ( + setShowTryAgainPopup(false)} + header={'Post'} + label={`Posting timed out. Do you want to try again?`} + /> + )} diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 26c5d80..19ef011 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -129,6 +129,11 @@ export const FeedTabPosts = () => { return _notes }, [filterOptions.repost, notes, showing]) + const newNotes = useMemo( + () => discoveredNotes.filter((d) => !notes.some((n) => n.id === d.id)), + [discoveredNotes, notes] + ) + if (!userPubkey) return null const handleLoadMore = () => { @@ -170,15 +175,15 @@ export const FeedTabPosts = () => { }) } - const discoveredCount = discoveredNotes.length + const discoveredCount = newNotes.length const handleDiscoveredClick = () => { // Combine newly discovred with the notes // Skip events already in notes setNotes((prev) => { - return Array.from(new Set([...discoveredNotes, ...prev])) + return [...newNotes, ...prev] }) // Increase showing by the discovered count - setShowing((prev) => prev + discoveredNotes.length) + setShowing((prev) => prev + discoveredCount) setDiscoveredNotes([]) } diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts index 61a3ce8..7fc4fc9 100644 --- a/src/pages/feed/action.ts +++ b/src/pages/feed/action.ts @@ -4,13 +4,19 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { getFeedNotePageRoute } from 'routes' import { store } from 'store' -import { NoteAction, NoteSubmitForm, NoteSubmitFormErrors } from 'types' +import { + NoteAction, + NoteSubmitForm, + NoteSubmitFormErrors, + TimeoutError +} from 'types' import { log, LogType, NOTE_DRAFT_CACHE_KEY, now, - removeLocalStorageItem + removeLocalStorageItem, + timeout } from 'utils' export const feedPostRouteAction = @@ -38,8 +44,9 @@ export const feedPostRouteAction = return null } + let action: NoteAction | undefined try { - const action = (await request.json()) as NoteAction + action = (await request.json()) as NoteAction switch (action.intent) { case 'submit': return await handleActionSubmit( @@ -59,6 +66,14 @@ export const feedPostRouteAction = throw new Error('Unsupported feed action. Intent missing.') } } 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) toast.error('Failed to publish note') return null @@ -82,7 +97,11 @@ async function handleActionSubmit( ) { 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 currentTimeStamp = now() @@ -98,24 +117,21 @@ async function handleActionSubmit( pubkey }) - try { - if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning']) + if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning']) - await ndkEvent.sign() - const note1 = ndkEvent.encode() - const publishedOnRelays = await ndkEvent.publish() - if (publishedOnRelays.size === 0) { - toast.error('Failed to publish note on any relay') - return null - } else { - toast.success('Note published successfully') - 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') + await ndkEvent.sign() + const note1 = ndkEvent.encode() + const publishedOnRelays = await Promise.race([ + ndkEvent.publish(), + timeout(30000) + ]) + if (publishedOnRelays.size === 0) { + toast.error('Failed to publish note on any relay') 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) { diff --git a/src/types/note.ts b/src/types/note.ts index 31668b3..a6c6204 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -17,3 +17,13 @@ export type NoteAction = note1: string data: NostrEvent } + +export type NoteSubmitActionResult = + | { + type: 'timeout' + action: NoteAction + } + | { + type: 'validation' + formErrors: NoteSubmitFormErrors + }