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
464f6ae98c
commit
624eff12ea
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">
|
||||
<!-- Vote buttons (inline style) -->
|
||||
<button
|
||||
class="p-1 text-muted-foreground hover:text-orange-500 transition-colors"
|
||||
:class="{ 'text-orange-500': comment.votes.userVote === 'upvote' }"
|
||||
:class="[
|
||||
'p-1 transition-colors',
|
||||
comment.votes.userVote === 'upvote'
|
||||
? 'text-orange-500'
|
||||
: 'text-muted-foreground hover:text-orange-500'
|
||||
]"
|
||||
:disabled="!isAuthenticated"
|
||||
@click="emit('upvote', comment)"
|
||||
>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-muted-foreground hover:text-blue-500 transition-colors"
|
||||
:class="{ 'text-blue-500': comment.votes.userVote === 'downvote' }"
|
||||
:class="[
|
||||
'p-1 transition-colors',
|
||||
comment.votes.userVote === 'downvote'
|
||||
? 'text-blue-500'
|
||||
: 'text-muted-foreground hover:text-blue-500'
|
||||
]"
|
||||
:disabled="!isAuthenticated"
|
||||
@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
|
||||
function onShare() {
|
||||
const url = window.location.href
|
||||
|
|
@ -473,6 +492,8 @@ function goBack() {
|
|||
:current-user-pubkey="currentUserPubkey"
|
||||
@toggle-collapse="toggleCollapse"
|
||||
@reply="startReply"
|
||||
@upvote="onCommentUpvote"
|
||||
@downvote="onCommentDownvote"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ export class SubmissionService extends BaseService {
|
|||
onEvent: (event: NostrEvent) => this.handleEvent(event),
|
||||
onEose: () => {
|
||||
this.debug('End of stored events for submission')
|
||||
// After loading comments, fetch their reactions
|
||||
this.fetchCommentReactions(submissionId)
|
||||
this._isLoading.value = false
|
||||
},
|
||||
onClose: () => {
|
||||
|
|
@ -417,7 +419,7 @@ export class SubmissionService extends BaseService {
|
|||
parentId: this.getParentId(event) || rootId,
|
||||
depth: 0,
|
||||
replies: [],
|
||||
votes: this.getDefaultVotes()
|
||||
votes: this.getCommentVotes(event.id)
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
|
|
@ -907,6 +926,68 @@ export class SubmissionService extends BaseService {
|
|||
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
|
||||
// ============================================================================
|
||||
|
|
@ -1163,6 +1244,67 @@ export class SubmissionService extends BaseService {
|
|||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue