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:
parent
98e7e1ea89
commit
fa93cc56ba
4 changed files with 294 additions and 5 deletions
|
|
@ -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)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue