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:
parent
c74ceaaf85
commit
4391a658d3
2 changed files with 109 additions and 16 deletions
|
|
@ -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)"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue