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:
parent
43c762fdf9
commit
9c8abe2f5c
3 changed files with 205 additions and 11 deletions
|
|
@ -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 `SubmissionDetailPage.vue` - Route page wrapper
|
||||
- [x] Add route `/submission/:id` for detail view
|
||||
- [ ] Add comment sorting (pending)
|
||||
- [ ] Integrate comment submission (pending)
|
||||
- [x] Add comment sorting (best, new, old, controversial)
|
||||
- [x] Integrate comment submission via SubmissionService.createComment()
|
||||
|
||||
### Phase 5: Communities (Future)
|
||||
- [ ] Create `CommunityService.ts`
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import SubmissionCommentComponent from './SubmissionComment.vue'
|
|||
import { useSubmission, useSubmissions } from '../composables/useSubmissions'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ProfileService } from '../services/ProfileService'
|
||||
import type { SubmissionService } from '../services/SubmissionService'
|
||||
import type {
|
||||
SubmissionWithMeta,
|
||||
SubmissionComment as SubmissionCommentType,
|
||||
|
|
@ -45,9 +46,13 @@ interface Props {
|
|||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
|
||||
// Comment sort options
|
||||
type CommentSort = 'best' | 'new' | 'old' | 'controversial'
|
||||
|
||||
// Inject services
|
||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
|
||||
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
|
||||
|
||||
// Use submission composable
|
||||
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 commentText = ref('')
|
||||
const isSubmittingComment = ref(false)
|
||||
const commentError = ref<string | null>(null)
|
||||
|
||||
// Comment sorting state
|
||||
const commentSort = ref<CommentSort>('best')
|
||||
|
||||
// Collapsed comments state
|
||||
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
|
||||
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
|
||||
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
|
||||
|
|
@ -154,18 +171,21 @@ function cancelReply() {
|
|||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!commentText.value.trim() || !isAuthenticated.value) return
|
||||
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
|
||||
|
||||
isSubmittingComment.value = true
|
||||
commentError.value = null
|
||||
|
||||
try {
|
||||
// TODO: Implement comment submission via SubmissionService
|
||||
console.log('Submit comment:', {
|
||||
text: commentText.value,
|
||||
parentId: replyingTo.value?.id || props.submissionId
|
||||
})
|
||||
await submissionService.createComment(
|
||||
props.submissionId,
|
||||
commentText.value.trim(),
|
||||
replyingTo.value?.id
|
||||
)
|
||||
cancelReply()
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to submit comment:', err)
|
||||
commentError.value = err.message || 'Failed to post comment'
|
||||
} finally {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<!-- Comment error -->
|
||||
<div v-if="commentError" class="mt-2 text-sm text-destructive">
|
||||
{{ commentError }}
|
||||
</div>
|
||||
<div class="flex justify-end mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -430,12 +454,26 @@ onMounted(() => {
|
|||
|
||||
<!-- Comments section -->
|
||||
<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!
|
||||
</div>
|
||||
|
||||
<!-- Recursive comment rendering -->
|
||||
<template v-for="comment in comments" :key="comment.id">
|
||||
<template v-for="comment in sortedComments" :key="comment.id">
|
||||
<SubmissionCommentComponent
|
||||
:comment="comment"
|
||||
:depth="0"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue