-
Notifications
You must be signed in to change notification settings - Fork 2
Add post and reply editing #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d883000
719c6c1
718d876
1d57d19
2713400
bd7b047
b3d3497
421cb83
e1b4052
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
| 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> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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, | ||
|
|
@@ -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(() => { | ||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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()}> | ||
|
|
@@ -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(); }} | ||
|
|
@@ -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} | ||
|
|
||
| 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 }), | ||
| })) |
There was a problem hiding this comment.
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, butcanSaveapplies 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 totrimmed.lengthso modal validation matches the payload.source: ['codex']