Skip to content
2 changes: 2 additions & 0 deletions app/user/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,8 @@ function UserProfileContent() {
author: reply.author,
content: reply.content,
createdAt: reply.createdAt,
updatedAt: reply.updatedAt,
isEdited: reply.isEdited,
likes: reply.likes,
reposts: reply.reposts,
replies: reply.replies,
Expand Down
199 changes: 199 additions & 0 deletions components/post/edit-post-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use client'

import { logger } from '@/lib/logger'
import { useEffect, useRef, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/button'
import { IconButton } from '@/components/ui/icon-button'
import { Spinner } from '@/components/ui/spinner'
import { UserAvatar } from '@/components/ui/avatar-image'
import { CharacterCounter } from '@/components/compose/compose-sub-components'
import { useEditPostModal } from '@/hooks/use-edit-post-modal'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuth } from '@/contexts/auth-context'
import { useSettingsStore } from '@/lib/store'
import { categorizeError } from '@/lib/error-utils'
import { CHARACTER_LIMIT } from '@/components/compose/thread-post-editor'

/**
* Modal for editing the content of an existing post or reply.
*
* Uses document replacement on Dash Platform: only the content field changes,
* all other stored fields are preserved by the service layer. The platform
* bumps $revision on replacement, which drives the "(edited)" indicator.
*/
export function EditPostModal() {
const { isOpen, post, close } = useEditPostModal()
const { requireAuth } = useRequireAuth()
const { user } = useAuth()
const potatoMode = useSettingsStore((s) => s.potatoMode)
const [content, setContent] = useState('')
const [isSaving, setIsSaving] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)

// Pre-populate content when the modal opens
useEffect(() => {
if (isOpen && post) {
setContent(post.content)
// Small delay so the modal animation has mounted the textarea
const timeoutId = setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}
}, 50)
return () => clearTimeout(timeoutId)
}
}, [isOpen, post])

// Auto-resize the textarea to fit its content, like the thread composer
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = 'auto'
textarea.style.height = `${Math.max(120, textarea.scrollHeight)}px`
}, [content, isOpen])

const trimmed = content.trim()
const isUnchanged = !!post && trimmed === post.content.trim()
const canSave = !!post && trimmed.length > 0 && content.length <= CHARACTER_LIMIT && !isUnchanged && !isSaving
const isReply = Boolean(post?.parentId)
Comment on lines +61 to +64

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Validate the trimmed content that will be submitted

The services receive and validate trimmed, but canSave applies the 500-character limit to the untrimmed textarea value. An edit containing 500 meaningful characters plus a trailing space or newline is therefore disabled even though the submitted payload would be valid. Apply the limit to trimmed.length so modal validation matches the payload.

Suggested change
const trimmed = content.trim()
const isUnchanged = !!post && trimmed === post.content.trim()
const canSave = !!post && trimmed.length > 0 && content.length <= CHARACTER_LIMIT && !isUnchanged && !isSaving
const isReply = Boolean(post?.parentId)
const canSave = !!post && trimmed.length > 0 && trimmed.length <= CHARACTER_LIMIT && !isUnchanged && !isSaving

source: ['codex']


const handleSave = async () => {
if (!post || !canSave) return
const authedUser = requireAuth('post')
if (!authedUser) return

setIsSaving(true)
try {
if (isReply) {
const { replyService } = await import('@/lib/services/reply-service')
await replyService.updateReply(post.id, authedUser.identityId, trimmed)
} else {
const { postService } = await import('@/lib/services/post-service')
await postService.updatePost(post.id, authedUser.identityId, trimmed)
}

toast.success(isReply ? 'Reply updated' : 'Post updated')

// Let mounted cards/pages refresh their displayed content
window.dispatchEvent(new CustomEvent('post-updated', {
detail: { postId: post.id, content: trimmed, isReply }
}))

close()
} catch (error) {
logger.error('Failed to update post:', error)
toast.error(categorizeError(error))
} finally {
setIsSaving(false)
}
}

const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
handleSave().catch(err => logger.error('Failed to save edit:', err))
}
}

return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && !isSaving && close()}>
<AnimatePresence>
{isOpen && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={`fixed inset-0 bg-black/60 z-50 flex items-start justify-center pt-12 sm:pt-20 px-4 overflow-y-auto pb-12 ${potatoMode ? '' : 'backdrop-blur-sm'}`}
>
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="w-full max-w-2xl bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* Accessibility */}
<Dialog.Title className="sr-only">
Edit {isReply ? 'reply' : 'post'}
</Dialog.Title>
<Dialog.Description className="sr-only">
Your changes replace the original content. The {isReply ? 'reply' : 'post'} will be marked as edited.
</Dialog.Description>

{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3">
<IconButton
onClick={() => !isSaving && close()}
className="hover:bg-gray-200 dark:hover:bg-gray-800"
>
<XMarkIcon className="h-5 w-5" />
</IconButton>
{user && (
<UserAvatar userId={user.identityId} size="sm" alt="Your avatar" />
)}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Edit {isReply ? 'reply' : 'post'}
</span>
</div>

<Button
onClick={() => { handleSave().catch(err => logger.error('Failed to save edit:', err)) }}
disabled={!canSave}
className={`min-w-[100px] h-10 px-5 text-sm font-semibold transition-all ${
canSave
? 'bg-yappr-500 hover:bg-yappr-600 shadow-lg shadow-yappr-500/25 hover:shadow-xl hover:shadow-yappr-500/30 hover:scale-[1.02]'
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
>
{isSaving ? (
<span className="flex items-center gap-2">
<Spinner size="xs" className="border-current" />
Saving...
</span>
) : (
'Save'
)}
</Button>
</div>

{/* Main content area */}
<div className="px-5 py-4 max-h-[60vh] overflow-y-auto">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={isReply ? 'Edit your reply' : 'Edit your post'}
disabled={isSaving}
className="w-full min-h-[120px] text-base resize-none outline-none bg-transparent placeholder:text-gray-400 dark:placeholder:text-gray-600 disabled:opacity-60"
/>

{/* Footer with edit note and character count */}
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-xs text-gray-400">
Replaces the original — will be marked as edited
</span>
<CharacterCounter current={content.length} limit={CHARACTER_LIMIT} />
</div>
</div>
</motion.div>
</Dialog.Content>
</motion.div>
</Dialog.Overlay>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
)
}
54 changes: 50 additions & 4 deletions components/post/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { useHashtagRecoveryModal } from '@/hooks/use-hashtag-recovery-modal'
import { useMentionValidation } from '@/hooks/use-mention-validation'
import { useMentionRecoveryModal } from '@/hooks/use-mention-recovery-modal'
import { useDeleteConfirmationModal } from '@/hooks/use-delete-confirmation-modal'
import { useEditPostModal } from '@/hooks/use-edit-post-modal'
import { tipService } from '@/lib/services/tip-service'
import { useCanReplyToPrivate } from '@/hooks/use-can-reply-to-private'

Expand Down Expand Up @@ -172,10 +173,18 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
const initialBookmarked = progressiveEnrichment?.interactions?.bookmarked ?? post.bookmarked ?? false


// Local override applied when this post is edited while mounted
// (set from the global 'post-updated' event dispatched by the edit modal)
const [editedContent, setEditedContent] = useState<string | null>(null)
const displayContent = editedContent ?? post.content
const isEdited = Boolean(post.isEdited) || editedContent !== null
Comment on lines +176 to +180

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Persist edited content beyond the mounted card

A successful edit updates only the local state of PostCard instances that are mounted when the event fires. Parent collections such as the profile page's posts and userReplies remain unchanged, so switching between Posts and Replies unmounts the card and recreates it from the old post.content, making the successful edit appear to revert. The separate module-level referenceCache in use-yappr-post-reference.ts is also neither updated nor invalidated, so internal embeds can continue showing the old content for the rest of the SPA session. Store edit overrides in shared ID-keyed state or update the owning collections and invalidate all content caches when the edit succeeds.

source: ['codex']


// Memoize enriched post for use in compose/tip modals and caching
// Includes all resolved values so cached posts display correctly
const enrichedPost = useMemo(() => ({
...post,
content: displayContent,
isEdited,
author: {
...post.author,
username: usernameState || post.author.username,
Expand All @@ -185,7 +194,7 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
// undefined = still loading, true = has DPNS, false = no DPNS
hasDpns: usernameState !== undefined ? (usernameState !== null) : post.author.hasDpns
}
}), [post, usernameState, displayName, avatarUrl])
}), [post, displayContent, isEdited, usernameState, displayName, avatarUrl])

// Render username/identity display based on state
const renderUsernameOrIdentity = useCallback(() => {
Expand Down Expand Up @@ -271,6 +280,7 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
const { open: openHashtagRecoveryModal } = useHashtagRecoveryModal()
const { open: openMentionRecoveryModal } = useMentionRecoveryModal()
const { open: openDeleteModal } = useDeleteConfirmationModal()
const { open: openEditModal } = useEditPostModal()

// Validate hashtags for all posts (checks if hashtag documents exist on platform)
const { validations: hashtagValidations, revalidate: revalidateHashtags } = useHashtagValidation(post)
Expand Down Expand Up @@ -327,8 +337,22 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
}
}, [post.id, revalidateMentions])

// Listen for edit events so the displayed content refreshes without a refetch
useEffect(() => {
const handlePostUpdated = (event: CustomEvent<{ postId: string; content: string }>) => {
if (event.detail.postId === post.id) {
setEditedContent(event.detail.content)
}
}

window.addEventListener('post-updated', handlePostUpdated as EventListener)
return () => {
window.removeEventListener('post-updated', handlePostUpdated as EventListener)
}
}, [post.id])

// Check if this post is a tip and parse tip info
const tipInfo = useMemo(() => tipService.parseTipContent(post.content), [post.content])
const tipInfo = useMemo(() => tipService.parseTipContent(displayContent), [displayContent])
const isTipPost = !!tipInfo
const createdAtLabel = useRelativeTime(post.createdAt)

Expand Down Expand Up @@ -469,6 +493,12 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
openMentionRecoveryModal(post, username)
}

const handleEdit = () => {
if (!requireAuth('post')) return
// enrichedPost carries the latest displayed content so the modal pre-fills correctly
openEditModal(enrichedPost)
}

const handleDelete = () => {
const authedUser = requireAuth('delete')
if (!authedUser) return
Expand Down Expand Up @@ -617,6 +647,13 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
</span>
)}
<span className="text-gray-500 text-sm">{createdAtLabel}</span>
{isEdited && (
<Tooltip.Provider>
<ActionTooltip label={post.updatedAt ? `Edited ${post.updatedAt.toLocaleString()}` : 'This post was edited'}>
<span className="text-gray-400 text-sm cursor-help">(edited)</span>
</ActionTooltip>
</Tooltip.Provider>
)}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<IconButton onClick={(e: React.MouseEvent) => e.stopPropagation()}>
Expand Down Expand Up @@ -645,6 +682,15 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
>
View post engagements
</DropdownMenu.Item>
{isOwnPost && !isPrivatePost(post) && (
<DropdownMenu.Item
onClick={(e) => { e.stopPropagation(); handleEdit(); }}
className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none"
>
<PencilSquareIcon className="h-4 w-4" />
Edit {post.parentId ? 'reply' : 'post'}
</DropdownMenu.Item>
)}
{isOwnPost && (
<DropdownMenu.Item
onClick={(e) => { e.stopPropagation(); handleDelete(); }}
Expand Down Expand Up @@ -704,9 +750,9 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
mentionValidations={mentionValidations}
onFailedMentionClick={handleFailedMentionClick}
/>
) : post.content ? (
) : displayContent ? (
<PostContent
content={post.content}
content={displayContent}
className="mt-1"
hashtagValidations={hashtagValidations}
onFailedHashtagClick={handleFailedHashtagClick}
Expand Down
2 changes: 2 additions & 0 deletions components/post/reply-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function replyToPostLike(reply: Reply): Post {
author: reply.author,
content: reply.content,
createdAt: reply.createdAt,
updatedAt: reply.updatedAt,
isEdited: reply.isEdited,
likes: reply.likes,
reposts: reply.reposts,
replies: reply.replies,
Expand Down
2 changes: 2 additions & 0 deletions components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TipModal } from '@/components/post/tip-modal'
import { HashtagRecoveryModal } from '@/components/post/hashtag-recovery-modal'
import { MentionRecoveryModal } from '@/components/post/mention-recovery-modal'
import { DeleteConfirmationModal } from '@/components/post/delete-confirmation-modal'
import { EditPostModal } from '@/components/post/edit-post-modal'
import { DashPayContactsModal } from '@/components/contacts/dashpay-contacts-modal'
import { EncryptionKeyModal } from '@/components/auth/encryption-key-modal'

Expand All @@ -29,6 +30,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<HashtagRecoveryModal />
<MentionRecoveryModal />
<DeleteConfirmationModal />
<EditPostModal />
<DashPayContactsModal />
<EncryptionKeyModal />
</AuthProvider>
Expand Down
22 changes: 22 additions & 0 deletions hooks/use-edit-post-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { create } from 'zustand'
import { Post } from '@/lib/types'

interface EditPostModalStore {
isOpen: boolean
post: Post | null
open: (post: Post) => void
close: () => void
}

/**
* Global store for the edit post modal.
* Use this to let users edit the content of their own posts and replies.
*/
export const useEditPostModal = create<EditPostModalStore>((set) => ({
isOpen: false,
post: null,
open: (post) => set({ isOpen: true, post }),
close: () => set({ isOpen: false, post: null }),
}))
2 changes: 2 additions & 0 deletions hooks/use-yappr-post-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ function convertReplyToPost(reply: Reply): Post {
author: reply.author,
content: reply.content,
createdAt: reply.createdAt,
updatedAt: reply.updatedAt,
isEdited: reply.isEdited,
likes: reply.likes,
reposts: reply.reposts,
replies: reply.replies,
Expand Down
Loading
Loading