feat(nostr-feed): Add inline reply forms for comments

- Add inline reply form that appears below the comment being replied to
- Pass replyingToId and isSubmittingReply props through comment tree
- Add cancel-reply and submit-reply events for inline form handling
- Top composer now only shows for new top-level comments
- Better UX: no need to scroll to top to reply to nested comments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-01 20:47:52 +01:00
parent c74ceaaf85
commit 4391a658d3
2 changed files with 109 additions and 16 deletions

View file

@ -4,9 +4,9 @@
* Displays a single comment with vote controls and nested replies * Displays a single comment with vote controls and nested replies
*/ */
import { computed } from 'vue' import { computed, ref } from 'vue'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal } from 'lucide-vue-next' import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send, X } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue' import VoteControls from './VoteControls.vue'
import type { SubmissionComment as CommentType } from '../types/submission' import type { SubmissionComment as CommentType } from '../types/submission'
@ -17,18 +17,49 @@ interface Props {
getDisplayName: (pubkey: string) => string getDisplayName: (pubkey: string) => string
isAuthenticated: boolean isAuthenticated: boolean
currentUserPubkey?: string | null currentUserPubkey?: string | null
replyingToId?: string | null
isSubmittingReply?: boolean
} }
interface Emits { interface Emits {
(e: 'toggle-collapse', commentId: string): void (e: 'toggle-collapse', commentId: string): void
(e: 'reply', comment: CommentType): void (e: 'reply', comment: CommentType): void
(e: 'cancel-reply'): void
(e: 'submit-reply', commentId: string, text: string): void
(e: 'upvote', comment: CommentType): void (e: 'upvote', comment: CommentType): void
(e: 'downvote', comment: CommentType): void (e: 'downvote', comment: CommentType): void
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
replyingToId: null,
isSubmittingReply: false
})
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// Local reply text
const replyText = ref('')
// Is this comment being replied to
const isBeingRepliedTo = computed(() => props.replyingToId === props.comment.id)
// Handle reply click
function onReplyClick() {
emit('reply', props.comment)
}
// Submit the reply
function submitReply() {
if (!replyText.value.trim()) return
emit('submit-reply', props.comment.id, replyText.value.trim())
replyText.value = ''
}
// Cancel reply
function cancelReply() {
replyText.value = ''
emit('cancel-reply')
}
// Is this comment collapsed // Is this comment collapsed
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id)) const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
@ -168,7 +199,8 @@ const isOwnComment = computed(() =>
<!-- Reply --> <!-- Reply -->
<button <button
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
@click="emit('reply', comment)" :disabled="!isAuthenticated"
@click="onReplyClick"
> >
<Reply class="h-3 w-3" /> <Reply class="h-3 w-3" />
<span>reply</span> <span>reply</span>
@ -191,6 +223,35 @@ const isOwnComment = computed(() =>
</button> </button>
</div> </div>
<!-- Inline reply form -->
<div v-if="isBeingRepliedTo" class="mt-2 pl-5">
<div class="border rounded-lg bg-background p-2">
<textarea
v-model="replyText"
placeholder="Write a reply..."
rows="3"
class="w-full px-2 py-1.5 text-sm bg-transparent resize-none focus:outline-none"
:disabled="isSubmittingReply"
/>
<div class="flex items-center justify-end gap-2 mt-1">
<button
class="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="cancelReply"
>
Cancel
</button>
<button
class="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!replyText.trim() || isSubmittingReply"
@click="submitReply"
>
<Send class="h-3 w-3" />
Reply
</button>
</div>
</div>
</div>
<!-- Nested replies --> <!-- Nested replies -->
<div v-if="hasReplies" class="mt-1"> <div v-if="hasReplies" class="mt-1">
<SubmissionComment <SubmissionComment
@ -202,8 +263,12 @@ const isOwnComment = computed(() =>
:get-display-name="getDisplayName" :get-display-name="getDisplayName"
:is-authenticated="isAuthenticated" :is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey" :current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingReply"
@toggle-collapse="emit('toggle-collapse', $event)" @toggle-collapse="emit('toggle-collapse', $event)"
@reply="emit('reply', $event)" @reply="emit('reply', $event)"
@cancel-reply="emit('cancel-reply')"
@submit-reply="(commentId, text) => emit('submit-reply', commentId, text)"
@upvote="emit('upvote', $event)" @upvote="emit('upvote', $event)"
@downvote="emit('downvote', $event)" @downvote="emit('downvote', $event)"
/> />

View file

@ -169,14 +169,20 @@ function onShare() {
// TODO: Show toast // TODO: Show toast
} }
// Handle comment reply // Computed for passing to comment components
const replyingToId = computed(() => replyingTo.value?.id || null)
// Handle comment reply - for inline replies to comments
function startReply(comment?: SubmissionCommentType) { function startReply(comment?: SubmissionCommentType) {
if (comment) { if (comment) {
// Replying to a comment - show inline form (handled by SubmissionComment)
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) } replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
showComposer.value = false // Hide top composer
} else { } else {
// Top-level comment - show top composer
replyingTo.value = null replyingTo.value = null
}
showComposer.value = true showComposer.value = true
}
commentText.value = '' commentText.value = ''
} }
@ -186,6 +192,7 @@ function cancelReply() {
commentText.value = '' commentText.value = ''
} }
// Submit top-level comment (from top composer)
async function submitComment() { async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
@ -196,7 +203,7 @@ async function submitComment() {
await submissionService.createComment( await submissionService.createComment(
props.submissionId, props.submissionId,
commentText.value.trim(), commentText.value.trim(),
replyingTo.value?.id null // Top-level comment
) )
cancelReply() cancelReply()
} catch (err: any) { } catch (err: any) {
@ -207,6 +214,28 @@ async function submitComment() {
} }
} }
// Submit inline reply (from SubmissionComment's inline form)
async function submitReply(commentId: string, text: string) {
if (!text.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
text.trim(),
commentId
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit reply:', err)
commentError.value = err.message || 'Failed to post reply'
} finally {
isSubmittingComment.value = false
}
}
// Toggle comment collapse // Toggle comment collapse
function toggleCollapse(commentId: string) { function toggleCollapse(commentId: string) {
if (collapsedComments.value.has(commentId)) { if (collapsedComments.value.has(commentId)) {
@ -429,17 +458,12 @@ function goBack() {
</div> </div>
</article> </article>
<!-- Comment composer --> <!-- Top-level comment composer (only for new comments, not replies) -->
<div v-if="showComposer || isAuthenticated" class="p-4 border-b"> <div v-if="isAuthenticated && !replyingTo" class="p-4 border-b">
<div v-if="replyingTo" class="text-xs text-muted-foreground mb-2">
Replying to {{ replyingTo.author }}
<button class="ml-2 text-primary hover:underline" @click="cancelReply">Cancel</button>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<textarea <textarea
v-model="commentText" v-model="commentText"
:placeholder="isAuthenticated ? 'Write a comment...' : 'Login to comment'" placeholder="Write a comment..."
:disabled="!isAuthenticated"
rows="3" rows="3"
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary" class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/> />
@ -451,7 +475,7 @@ function goBack() {
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<Button <Button
size="sm" size="sm"
:disabled="!commentText.trim() || isSubmittingComment || !isAuthenticated" :disabled="!commentText.trim() || isSubmittingComment"
@click="submitComment" @click="submitComment"
> >
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" /> <Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
@ -490,8 +514,12 @@ function goBack() {
:get-display-name="getDisplayName" :get-display-name="getDisplayName"
:is-authenticated="isAuthenticated" :is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey" :current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingComment"
@toggle-collapse="toggleCollapse" @toggle-collapse="toggleCollapse"
@reply="startReply" @reply="startReply"
@cancel-reply="cancelReply"
@submit-reply="submitReply"
@upvote="onCommentUpvote" @upvote="onCommentUpvote"
@downvote="onCommentDownvote" @downvote="onCommentDownvote"
/> />