[Draft] feat(nostr-feed): Reddit-style link aggregator #9
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 `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`
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue