Skip to content
Open
145 changes: 144 additions & 1 deletion components/compose/compose-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { ImageAttachment } from './image-attachment'
import { StorageProviderModal } from './storage-provider-modal'
import { useImageUpload } from '@/hooks/use-image-upload'
import type { UploadResult } from '@/lib/upload'
import { POST_DUPLICATE_LOOKBACK_MS } from '@/lib/constants'

export function ComposeModal() {
const {
Expand Down Expand Up @@ -76,6 +77,7 @@ export function ComposeModal() {
const [showPreview, setShowPreview] = useState(false)
const firstTextareaRef = useRef<HTMLTextAreaElement>(null)
const teaserTextareaRef = useRef<HTMLTextAreaElement>(null)
const duplicateOverrideRef = useRef<Map<string, number>>(new Map())

// Private feed state
const [hasPrivateFeed, setHasPrivateFeed] = useState(false)
Expand Down Expand Up @@ -461,6 +463,91 @@ export function ComposeModal() {
setAttachedImage({ file, preview })
}, [willBeEncrypted, attachedImage, isProviderConnected])

const isDuplicateOverrideActive = (signature: string): boolean => {
const lastOverride = duplicateOverrideRef.current.get(signature)
if (!lastOverride) return false
if (Date.now() - lastOverride > POST_DUPLICATE_LOOKBACK_MS) {
duplicateOverrideRef.current.delete(signature)
return false
}
return true
}

const setDuplicateOverride = (signature: string): void => {
duplicateOverrideRef.current.set(signature, Date.now())
}

const buildDuplicateSignature = (data: {
type: 'post' | 'reply'
content: string
quotedPostId?: string
quotedPostOwnerId?: string
parentId?: string | null
parentOwnerId?: string | null
}): string => {
const quoteId = data.quotedPostId ?? ''
const quoteOwnerId = data.quotedPostOwnerId ?? ''
const parentId = data.parentId ?? ''
const parentOwnerId = data.parentOwnerId ?? ''
return `${data.type}:${data.content}::${quoteId}:${quoteOwnerId}::${parentId}:${parentOwnerId}`
}

const checkRecentDuplicate = async (params: {
ownerId: string
type: 'post' | 'reply'
content: string
quotedPostId?: string
quotedPostOwnerId?: string
parentId?: string | null
parentOwnerId?: string | null
}): Promise<boolean> => {
const minCreatedAt = Date.now() - POST_DUPLICATE_LOOKBACK_MS
try {
if (params.type === 'reply') {
const { replyService } = await import('@/lib/services/reply-service')
const result = await replyService.getUserReplies(params.ownerId, {
where: [
['$ownerId', '==', params.ownerId],
['$createdAt', '>', minCreatedAt]
],
orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']],
limit: 20,
skipEnrichment: true
})

return result.documents.some((reply) =>
!reply.encryptedContent &&
reply.content === params.content &&
reply.parentId === params.parentId &&
reply.parentOwnerId === params.parentOwnerId
)
}

const { postService } = await import('@/lib/services')
const result = await postService.getUserPosts(params.ownerId, {
where: [
['$ownerId', '==', params.ownerId],
['$createdAt', '>', minCreatedAt]
],
orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']],
limit: 20
})

const expectedQuotedId = params.quotedPostId ?? null
const expectedQuotedOwnerId = params.quotedPostOwnerId ?? null

return result.documents.some((post) =>
!post.encryptedContent &&
post.content === params.content &&
(post.quotedPostId ?? null) === expectedQuotedId &&
(post.quotedPostOwnerId ?? null) === expectedQuotedOwnerId
)
} catch (error) {
console.warn('Duplicate pre-check failed; continuing without block:', error)
return false
}
}

const handlePost = async () => {
const authedUser = requireAuth('post')
if (!authedUser || !canPost) return
Expand All @@ -479,6 +566,9 @@ export function ComposeModal() {
const timeoutPosts: { index: number; threadPostId: string }[] = [] // Posts that timed out (may have succeeded)
let failedAtIndex: number | null = null
let failureError: Error | null = null
let duplicateDetectedAtIndex: number | null = null
let duplicateDetectedThreadPostId: string | null = null
let duplicateDetectedIsReply = false

// Upload image first if attached (and not already uploaded)
let imageUrl: string | undefined
Expand Down Expand Up @@ -570,12 +660,45 @@ export function ComposeModal() {
// Determine if this is a reply (to existing post/reply) or a top-level post
// - If replyingTo is set: all posts in thread are replies
// - If replyingTo is not set: first post is a top-level post, subsequent are replies
const isReply = (i === 0 && replyingTo) || (i > 0 && previousPostId)
const isReply = Boolean((i === 0 && replyingTo) || (i > 0 && previousPostId))
const parentId = i === 0 && replyingTo ? replyingTo.id : previousPostId
const parentOwnerId = i === 0 && replyingTo
? replyingTo.author.id
: previousPostId ? authedUser.identityId : undefined
Comment thread
thepastaclaw marked this conversation as resolved.
Outdated

const shouldCheckDuplicate = !isThisPostPrivate && !isThisReplyInherited
const duplicateSignature = buildDuplicateSignature({
type: isReply ? 'reply' : 'post',
content: postContent,
quotedPostId: i === 0 ? quotingPost?.id : undefined,
quotedPostOwnerId: i === 0 ? quotingPost?.author.id : undefined,
parentId,
parentOwnerId,
})
const hasOverride = isDuplicateOverrideActive(duplicateSignature)

if (shouldCheckDuplicate && !hasOverride) {
const isDuplicate = await checkRecentDuplicate({
ownerId: authedUser.identityId,
type: isReply ? 'reply' : 'post',
content: postContent,
quotedPostId: i === 0 ? quotingPost?.id : undefined,
quotedPostOwnerId: i === 0 ? quotingPost?.author.id : undefined,
parentId,
parentOwnerId,
})

if (isDuplicate) {
setDuplicateOverride(duplicateSignature)
duplicateDetectedAtIndex = i
duplicateDetectedThreadPostId = threadPostId
duplicateDetectedIsReply = isReply
break
}
} else if (hasOverride) {
duplicateOverrideRef.current.delete(duplicateSignature)
}

const result = await retryPostCreation(async () => {
// Check for sync required errors before they get wrapped by retry
try {
Expand Down Expand Up @@ -753,6 +876,26 @@ export function ComposeModal() {
}

// Handle results based on success/failure/timeout state
if (duplicateDetectedAtIndex !== null) {
// Mark confirmed successful posts as posted
successfulPosts.forEach(({ threadPostId, postId }) => {
markThreadPostAsPosted(threadPostId, postId)
})

const duplicateLabel = duplicateDetectedIsReply ? 'reply' : 'post'
toast(
`A very recent ${duplicateLabel} with the same content was found. ` +
`Press Post again to send anyway.`,
{ duration: 6000, icon: '⚠️' }
)

if (duplicateDetectedThreadPostId) {
setActiveThreadPost(duplicateDetectedThreadPostId)
}

return
}

const allSuccessful = failedAtIndex === null && timeoutPosts.length === 0
const hasTimeouts = timeoutPosts.length > 0
const successfulThreadPostIds = new Set(successfulPosts.map(p => p.threadPostId))
Expand Down
10 changes: 9 additions & 1 deletion lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export const INSIGHT_API_CONFIG = {
timeoutMs: 120000
} as const

// Post creation recovery (ambiguous errors)
export const POST_RECOVERY_LOOKBACK_MS = 2 * 60 * 1000
export const POST_RECOVERY_POLL_ATTEMPTS = 3
export const POST_RECOVERY_POLL_DELAY_MS = 2000

// Pre-flight duplicate detection
export const POST_DUPLICATE_LOOKBACK_MS = 2 * 60 * 1000

// Document types
// Note: AVATAR, REPOST, DIRECT_MESSAGE, NOTIFICATION were removed in contract migration
// - avatar: now in unified profile contract
Expand Down Expand Up @@ -68,4 +76,4 @@ export const STOREFRONT_DOCUMENT_TYPES = {
} as const

// DPNS
export const DPNS_DOCUMENT_TYPE = 'domain'
export const DPNS_DOCUMENT_TYPE = 'domain'
76 changes: 53 additions & 23 deletions lib/retry-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ export interface RetryResult<T> {
function defaultRetryCondition(error: unknown): boolean {
if (!error) return false

const errObj = error as { message?: string; toString?: () => string }
const errorMessage = errObj.message?.toLowerCase() || ''
const errorString = errObj.toString?.()?.toLowerCase() || ''

const errorMessage = getErrorMessage(error).toLowerCase()

// Network-related errors
const networkErrors = [
'network error',
'network request failed',
'fetch failed',
'connection refused',
'timeout',
'timed out',
'deadline',
'etimedout',
'enotfound',
'econnreset',
Expand All @@ -45,10 +45,56 @@ function defaultRetryCondition(error: unknown): boolean {

// Check if it's a retryable error
return networkErrors.some(networkError =>
errorMessage.includes(networkError) || errorString.includes(networkError)
errorMessage.includes(networkError)
)
}

function getErrorMessage(error: unknown): string {
if (!error) return ''
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (typeof error === 'object') {
const errObj = error as { message?: unknown }
if (typeof errObj.message === 'string') return errObj.message
if (errObj.message && typeof errObj.message === 'object') {
try {
return JSON.stringify(errObj.message)
} catch {
return String(errObj.message)
}
}
try {
const asJson = JSON.stringify(error)
if (asJson && asJson !== '{}') return asJson
} catch {
// Ignore JSON stringify errors
}
}
return String(error)
}

/**
* Check if a post creation error is ambiguous and might have actually succeeded.
* Includes network/timeouts and Dash Platform specific retryable errors.
*/
export function isPostCreationAmbiguousError(error: unknown): boolean {
if (defaultRetryCondition(error)) return true

const errorMessage = getErrorMessage(error).toLowerCase()

const dashErrors = [
'internal error',
'temporarily unavailable',
'service unavailable',
'consensus error',
'quorum not available',
'tenderdash is not available',
'tenderdash not available'
]

return dashErrors.some(dashError => errorMessage.includes(dashError))
}

/**
* Exponential backoff with jitter
*/
Expand Down Expand Up @@ -130,23 +176,7 @@ export async function retryPostCreation<T>(
initialDelayMs: 2000,
maxDelayMs: 8000,
backoffMultiplier: 2,
retryCondition: (error) => {
// Use default retry condition plus Dash Platform specific errors
if (defaultRetryCondition(error)) return true

const errorMessage = error instanceof Error ? error.message.toLowerCase() : ''

// Dash Platform specific retryable errors
const dashErrors = [
'internal error',
'temporarily unavailable',
'service unavailable',
'consensus error',
'quorum not available'
]

return dashErrors.some(dashError => errorMessage.includes(dashError))
},
retryCondition: isPostCreationAmbiguousError,
...options
})
}
Expand All @@ -163,4 +193,4 @@ export function isNetworkError(error: unknown): boolean {
*/
export function isRetryableError(error: unknown): boolean {
return defaultRetryCondition(error)
}
}
Loading