feat(nostr-feed): Add comment voting with persistence

- Add fetchCommentReactions() to load reactions for all comments after EOSE
- Add updateCommentVotes() to refresh comment votes from ReactionService
- Add dislikeEvent() and undislikeEvent() to ReactionService for downvotes
- Update downvoteComment() to use ReactionService for persistence
- Fix vote button styling in SubmissionComment to properly show active state
- Wire up comment vote handlers in SubmissionDetail

🤖 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:25:35 +01:00
parent 464f6ae98c
commit 624eff12ea
4 changed files with 294 additions and 5 deletions

View file

@ -141,16 +141,24 @@ const isOwnComment = computed(() =>
<div class="flex items-center gap-1 mt-1 pl-5"> <div class="flex items-center gap-1 mt-1 pl-5">
<!-- Vote buttons (inline style) --> <!-- Vote buttons (inline style) -->
<button <button
class="p-1 text-muted-foreground hover:text-orange-500 transition-colors" :class="[
:class="{ 'text-orange-500': comment.votes.userVote === 'upvote' }" 'p-1 transition-colors',
comment.votes.userVote === 'upvote'
? 'text-orange-500'
: 'text-muted-foreground hover:text-orange-500'
]"
:disabled="!isAuthenticated" :disabled="!isAuthenticated"
@click="emit('upvote', comment)" @click="emit('upvote', comment)"
> >
<ChevronUp class="h-4 w-4" /> <ChevronUp class="h-4 w-4" />
</button> </button>
<button <button
class="p-1 text-muted-foreground hover:text-blue-500 transition-colors" :class="[
:class="{ 'text-blue-500': comment.votes.userVote === 'downvote' }" 'p-1 transition-colors',
comment.votes.userVote === 'downvote'
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500'
]"
:disabled="!isAuthenticated" :disabled="!isAuthenticated"
@click="emit('downvote', comment)" @click="emit('downvote', comment)"
> >

View file

@ -143,6 +143,25 @@ async function onDownvote() {
} }
} }
// Handle comment voting
async function onCommentUpvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.upvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to upvote comment:', err)
}
}
async function onCommentDownvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.downvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to downvote comment:', err)
}
}
// Handle share // Handle share
function onShare() { function onShare() {
const url = window.location.href const url = window.location.href
@ -473,6 +492,8 @@ function goBack() {
:current-user-pubkey="currentUserPubkey" :current-user-pubkey="currentUserPubkey"
@toggle-collapse="toggleCollapse" @toggle-collapse="toggleCollapse"
@reply="startReply" @reply="startReply"
@upvote="onCommentUpvote"
@downvote="onCommentDownvote"
/> />
</template> </template>
</section> </section>

View file

@ -427,6 +427,124 @@ export class ReactionService extends BaseService {
} }
} }
/**
* Send a dislike reaction to an event
*/
async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already disliked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
throw new Error('Already disliked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '-', // Dislike reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to dislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a dislike from an event using NIP-09 deletion events
*/
async undislikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
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 user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) {
throw new Error('No dislike reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to remove dislike:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/** /**
* Helper function to convert hex string to Uint8Array * Helper function to convert hex string to Uint8Array
*/ */

View file

@ -229,6 +229,8 @@ export class SubmissionService extends BaseService {
onEvent: (event: NostrEvent) => this.handleEvent(event), onEvent: (event: NostrEvent) => this.handleEvent(event),
onEose: () => { onEose: () => {
this.debug('End of stored events for submission') this.debug('End of stored events for submission')
// After loading comments, fetch their reactions
this.fetchCommentReactions(submissionId)
this._isLoading.value = false this._isLoading.value = false
}, },
onClose: () => { onClose: () => {
@ -417,7 +419,7 @@ export class SubmissionService extends BaseService {
parentId: this.getParentId(event) || rootId, parentId: this.getParentId(event) || rootId,
depth: 0, depth: 0,
replies: [], replies: [],
votes: this.getDefaultVotes() votes: this.getCommentVotes(event.id)
} }
// Add to comments map // Add to comments map
@ -701,6 +703,23 @@ export class SubmissionService extends BaseService {
} }
} }
/**
* Get votes for a comment from reaction service
*/
private getCommentVotes(commentId: string): SubmissionVotes {
if (this.reactionService) {
const reactions = this.reactionService.getEventReactions(commentId)
return {
upvotes: reactions.likes || 0,
downvotes: reactions.dislikes || 0,
score: (reactions.likes || 0) - (reactions.dislikes || 0),
userVote: reactions.userHasLiked ? 'upvote' : reactions.userHasDisliked ? 'downvote' : null,
userVoteId: reactions.userReactionId
}
}
return this.getDefaultVotes()
}
/** /**
* Calculate ranking scores * Calculate ranking scores
*/ */
@ -907,6 +926,68 @@ export class SubmissionService extends BaseService {
submission.ranking = this.calculateRanking(submission, submission.votes) submission.ranking = this.calculateRanking(submission, submission.votes)
} }
/**
* Upvote a comment
*/
async upvoteComment(submissionId: string, commentId: string): Promise<void> {
const comment = this.findComment(submissionId, commentId)
if (!comment) throw new Error('Comment not found')
if (comment.votes.userVote === 'upvote') {
// Remove upvote
await this.reactionService?.unlikeEvent(commentId)
comment.votes.userVote = null
comment.votes.upvotes = Math.max(0, comment.votes.upvotes - 1)
} else {
// Add upvote
await this.reactionService?.likeEvent(
commentId,
comment.pubkey,
SUBMISSION_KINDS.SUBMISSION
)
// If was downvoted, remove downvote
if (comment.votes.userVote === 'downvote') {
comment.votes.downvotes = Math.max(0, comment.votes.downvotes - 1)
}
comment.votes.userVote = 'upvote'
comment.votes.upvotes += 1
}
// Update score
comment.votes.score = comment.votes.upvotes - comment.votes.downvotes
}
/**
* Downvote a comment
*/
async downvoteComment(submissionId: string, commentId: string): Promise<void> {
const comment = this.findComment(submissionId, commentId)
if (!comment) throw new Error('Comment not found')
if (comment.votes.userVote === 'downvote') {
// Remove downvote
await this.reactionService?.undislikeEvent(commentId)
comment.votes.userVote = null
comment.votes.downvotes = Math.max(0, comment.votes.downvotes - 1)
} else {
// Add downvote (ReactionService keeps only latest reaction per user)
await this.reactionService?.dislikeEvent(
commentId,
comment.pubkey,
SUBMISSION_KINDS.SUBMISSION
)
// If was upvoted, update local count
if (comment.votes.userVote === 'upvote') {
comment.votes.upvotes = Math.max(0, comment.votes.upvotes - 1)
}
comment.votes.userVote = 'downvote'
comment.votes.downvotes += 1
}
// Update score
comment.votes.score = comment.votes.upvotes - comment.votes.downvotes
}
// ============================================================================ // ============================================================================
// Sorting // Sorting
// ============================================================================ // ============================================================================
@ -1163,6 +1244,67 @@ export class SubmissionService extends BaseService {
return comments.find(c => c.id === commentId) return comments.find(c => c.id === commentId)
} }
/**
* Fetch reactions for all comments of a submission
*/
private async fetchCommentReactions(submissionId: string): Promise<void> {
const comments = this._comments.get(submissionId)
if (!comments || comments.length === 0) return
const commentIds = comments.map(c => c.id)
this.debug('Fetching reactions for comments:', commentIds.length)
// Subscribe to reactions for all comment IDs
const filters: Filter[] = [
{
kinds: [SUBMISSION_KINDS.REACTION],
'#e': commentIds
}
]
return new Promise<void>((resolve) => {
const subscriptionId = `comment-reactions-${submissionId}-${Date.now()}`
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters,
onEvent: (event: NostrEvent) => {
// Route to reaction service
this.reactionService?.handleReactionEvent(event)
},
onEose: () => {
this.debug('End of comment reactions')
// Update all comment votes after reactions are loaded
this.updateCommentVotes(submissionId)
unsubscribe()
resolve()
},
onClose: () => {
resolve()
}
})
// Timeout fallback
setTimeout(() => {
unsubscribe()
resolve()
}, 5000)
})
}
/**
* Update votes for all comments from reaction service
*/
private updateCommentVotes(submissionId: string): void {
const comments = this._comments.get(submissionId)
if (!comments) return
for (const comment of comments) {
comment.votes = this.getCommentVotes(comment.id)
}
}
// ============================================================================ // ============================================================================
// Utilities // Utilities
// ============================================================================ // ============================================================================