feat(nostr-feed): Complete Phase 4 - comment sorting and submission

- Add createComment() method to SubmissionService for posting comments
- Add getSortedComments() with sort options: best, new, old, controversial
- Wire up comment submission in SubmissionDetail.vue
- Add comment sort dropdown in detail view
- Support replying to specific comments or root submission
- Display comment submission errors

🤖 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 19:33:26 +01:00
parent 54d81b7c79
commit 66fa6459e7
3 changed files with 205 additions and 11 deletions

View file

@ -108,8 +108,8 @@ Transform the nostr-feed module into a Reddit-style link aggregator with support
- [x] Create `SubmissionComment.vue` - Recursive threaded comments - [x] Create `SubmissionComment.vue` - Recursive threaded comments
- [x] Create `SubmissionDetailPage.vue` - Route page wrapper - [x] Create `SubmissionDetailPage.vue` - Route page wrapper
- [x] Add route `/submission/:id` for detail view - [x] Add route `/submission/:id` for detail view
- [ ] Add comment sorting (pending) - [x] Add comment sorting (best, new, old, controversial)
- [ ] Integrate comment submission (pending) - [x] Integrate comment submission via SubmissionService.createComment()
### Phase 5: Communities (Future) ### Phase 5: Communities (Future)
- [ ] Create `CommunityService.ts` - [ ] Create `CommunityService.ts`

View file

@ -29,6 +29,7 @@ import SubmissionCommentComponent from './SubmissionComment.vue'
import { useSubmission, useSubmissions } from '../composables/useSubmissions' import { useSubmission, useSubmissions } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../services/ProfileService' import type { ProfileService } from '../services/ProfileService'
import type { SubmissionService } from '../services/SubmissionService'
import type { import type {
SubmissionWithMeta, SubmissionWithMeta,
SubmissionComment as SubmissionCommentType, SubmissionComment as SubmissionCommentType,
@ -45,9 +46,13 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const router = useRouter() const router = useRouter()
// Comment sort options
type CommentSort = 'best' | 'new' | 'old' | 'controversial'
// Inject services // Inject services
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE) const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE) const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
// Use submission composable // Use submission composable
const { submission, comments, upvote, downvote } = useSubmission(props.submissionId) const { submission, comments, upvote, downvote } = useSubmission(props.submissionId)
@ -60,10 +65,22 @@ const showComposer = ref(false)
const replyingTo = ref<{ id: string; author: string } | null>(null) const replyingTo = ref<{ id: string; author: string } | null>(null)
const commentText = ref('') const commentText = ref('')
const isSubmittingComment = ref(false) const isSubmittingComment = ref(false)
const commentError = ref<string | null>(null)
// Comment sorting state
const commentSort = ref<CommentSort>('best')
// Collapsed comments state // Collapsed comments state
const collapsedComments = ref(new Set<string>()) const collapsedComments = ref(new Set<string>())
// Sorted comments
const sortedComments = computed(() => {
if (submissionService) {
return submissionService.getSortedComments(props.submissionId, commentSort.value)
}
return comments.value
})
// Auth state // Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false) const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null) const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
@ -154,18 +171,21 @@ function cancelReply() {
} }
async function submitComment() { async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value) return if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true isSubmittingComment.value = true
commentError.value = null
try { try {
// TODO: Implement comment submission via SubmissionService await submissionService.createComment(
console.log('Submit comment:', { props.submissionId,
text: commentText.value, commentText.value.trim(),
parentId: replyingTo.value?.id || props.submissionId replyingTo.value?.id
}) )
cancelReply() cancelReply()
} catch (err) { } catch (err: any) {
console.error('Failed to submit comment:', err) console.error('Failed to submit comment:', err)
commentError.value = err.message || 'Failed to post comment'
} finally { } finally {
isSubmittingComment.value = false isSubmittingComment.value = false
} }
@ -415,6 +435,10 @@ onMounted(() => {
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"
/> />
</div> </div>
<!-- Comment error -->
<div v-if="commentError" class="mt-2 text-sm text-destructive">
{{ commentError }}
</div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<Button <Button
size="sm" size="sm"
@ -430,12 +454,26 @@ onMounted(() => {
<!-- Comments section --> <!-- Comments section -->
<section class="divide-y divide-border"> <section class="divide-y divide-border">
<div v-if="comments.length === 0" class="p-8 text-center text-sm text-muted-foreground"> <!-- Comment sort selector -->
<div v-if="sortedComments.length > 0" class="px-4 py-3 border-b flex items-center gap-2">
<span class="text-sm text-muted-foreground">Sort by:</span>
<select
v-model="commentSort"
class="text-sm bg-background border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="best">Best</option>
<option value="new">New</option>
<option value="old">Old</option>
<option value="controversial">Controversial</option>
</select>
</div>
<div v-if="sortedComments.length === 0" class="p-8 text-center text-sm text-muted-foreground">
No comments yet. Be the first to comment! No comments yet. Be the first to comment!
</div> </div>
<!-- Recursive comment rendering --> <!-- Recursive comment rendering -->
<template v-for="comment in comments" :key="comment.id"> <template v-for="comment in sortedComments" :key="comment.id">
<SubmissionCommentComponent <SubmissionCommentComponent
:comment="comment" :comment="comment"
:depth="0" :depth="0"

View file

@ -923,6 +923,162 @@ export class SubmissionService extends BaseService {
} }
} }
/**
* Get sorted threaded comments
*/
getSortedComments(
submissionId: string,
sort: 'best' | 'new' | 'old' | 'controversial' = 'best'
): SubmissionComment[] {
const comments = this.getComments(submissionId)
const tree = this.buildCommentTree(comments)
// Apply custom sort to the tree
const compareFn = this.getCommentSortFn(sort)
tree.sort(compareFn)
tree.forEach(comment => this.sortReplies(comment, compareFn))
return tree
}
/**
* Get comparison function for comment sorting
*/
private getCommentSortFn(
sort: 'best' | 'new' | 'old' | 'controversial'
): (a: SubmissionComment, b: SubmissionComment) => number {
switch (sort) {
case 'best':
return (a, b) => b.votes.score - a.votes.score
case 'new':
return (a, b) => b.created_at - a.created_at
case 'old':
return (a, b) => a.created_at - b.created_at
case 'controversial':
return (a, b) => {
const aControversy = calculateControversyRank(a.votes.upvotes, a.votes.downvotes)
const bControversy = calculateControversyRank(b.votes.upvotes, b.votes.downvotes)
return bControversy - aControversy
}
default:
return (a, b) => b.votes.score - a.votes.score
}
}
/**
* Create a comment on a submission or reply to another comment
*/
async createComment(
submissionId: string,
content: string,
parentCommentId?: string
): Promise<string> {
this.requireAuth()
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the root submission
const submission = this._submissions.get(submissionId)
if (!submission) {
throw new Error('Submission not found')
}
try {
this._isLoading.value = true
const tags: string[][] = []
// Root scope - the community (if submission has one)
if (submission.communityRef) {
const ref = parseCommunityRef(submission.communityRef)
if (ref) {
tags.push(['A', submission.communityRef])
tags.push(['K', '34550'])
tags.push(['P', ref.pubkey])
}
}
// Root reference - the submission
tags.push(['E', submissionId, '', submission.pubkey])
// Parent reference
if (parentCommentId) {
// Replying to a comment
const parentComment = this.findComment(submissionId, parentCommentId)
if (parentComment) {
tags.push(['e', parentCommentId, '', parentComment.pubkey])
tags.push(['p', parentComment.pubkey])
} else {
// Fallback to submission as parent
tags.push(['e', submissionId, '', submission.pubkey])
tags.push(['p', submission.pubkey])
}
} else {
// Top-level comment on submission
tags.push(['e', submissionId, '', submission.pubkey])
tags.push(['p', submission.pubkey])
}
tags.push(['k', '1111'])
const eventTemplate: EventTemplate = {
kind: SUBMISSION_KINDS.SUBMISSION,
content,
tags,
created_at: Math.floor(Date.now() / 1000)
}
// Sign
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish
await this.relayHub.publishEvent(signedEvent)
// Handle locally - add to comments
const comment: SubmissionComment = {
id: signedEvent.id,
pubkey: signedEvent.pubkey,
created_at: signedEvent.created_at,
content: signedEvent.content,
rootId: submissionId,
parentId: parentCommentId || submissionId,
depth: 0,
replies: [],
votes: this.getDefaultVotes()
}
if (!this._comments.has(submissionId)) {
this._comments.set(submissionId, [])
}
this._comments.get(submissionId)!.push(comment)
// Update comment count on submission
const sub = this._submissions.get(submissionId)
if (sub) {
sub.commentCount = this.getCommentCount(submissionId)
}
return signedEvent.id
} finally {
this._isLoading.value = false
}
}
/**
* Find a comment by ID within a submission's comments
*/
private findComment(submissionId: string, commentId: string): SubmissionComment | undefined {
const comments = this._comments.get(submissionId) || []
return comments.find(c => c.id === commentId)
}
// ============================================================================ // ============================================================================
// Utilities // Utilities
// ============================================================================ // ============================================================================