Compare commits

..

10 commits

Author SHA1 Message Date
Patrick Mulligan
74ce584eff fix(nostr-feed): Use shadcn-vue Select for time range dropdown
Replace native HTML select with themed Select component for proper
dark mode support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:57:43 +01:00
Patrick Mulligan
b62ef19ced fix(nostr-feed): Use theme-aware colors for comment threading lines
Replace hardcoded Tailwind colors with semantic chart-* colors that
adapt to light/dark theme.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:54:07 +01:00
Patrick Mulligan
56033994b9 feat(nostr-feed): Add inline reply forms for comments
- Add inline reply form that appears below the comment being replied to
- Pass replyingToId and isSubmittingReply props through comment tree
- Add cancel-reply and submit-reply events for inline form handling
- Top composer now only shows for new top-level comments
- Better UX: no need to scroll to top to reply to nested comments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:47:52 +01:00
Patrick Mulligan
ffd8dac719 fix(nostr-feed): Fix comment threading lines styling
- Change border-* to bg-* classes so threading lines actually display
- Tighten spacing to match Lemmy-style threading (ml-0.5, pl-2, py-1)
- Rename borderColor to depthColor for accuracy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:39:39 +01:00
Patrick Mulligan
afdab94beb feat(nostr-feed): Add submission voting with persistence
- Implement downvote() for submissions using dislikeEvent
- Add refreshAllSubmissionVotes() to update all votes after EOSE
- Refresh submission votes after loading reactions in both subscribe methods
- Fixes vote state not displaying correctly on page load

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:31:13 +01:00
Patrick Mulligan
fa93cc56ba 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>
2026-01-01 20:25:35 +01:00
Patrick Mulligan
98e7e1ea89 fix(nostr-feed): Fix comment loading and deduplication
- Add subscribeToSubmission() to fetch submission + comments by ID
- Fix isComment check order - check before parseSubmission (which fails for comments)
- Update comment count on submission when comments are added
- Add duplicate comment detection to prevent double-display
- Update useSubmission composable to auto-subscribe on mount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:59:49 +01:00
Patrick Mulligan
66fa6459e7 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>
2026-01-01 19:33:26 +01:00
Patrick Mulligan
54d81b7c79 feat(nostr-feed): Add submission composer (Phase 2)
- Create SubmitComposer.vue with post type selector (text/link/media)
- Add debounced link preview fetching on URL input
- Wire up submission publishing via SubmissionService.createSubmission()
- Add /submit route with SubmitPage.vue wrapper
- Support community query param for pre-selection
- Include NSFW toggle and form validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:26:10 +01:00
Patrick Mulligan
d197c53afb feat(nostr-feed): Add submission detail view (Phase 4)
- Add SubmissionDetail.vue for full post view with content display
- Add SubmissionComment.vue for recursive threaded comments
- Add SubmissionDetailPage.vue route wrapper
- Add /submission/:id route to module
- Update SubmissionList share URL to use correct route
- Update test page to navigate to detail view on click
- Update plan document with Phase 4 completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:01:58 +01:00
13 changed files with 1918 additions and 42 deletions

View file

@ -78,30 +78,38 @@ Transform the nostr-feed module into a Reddit-style link aggregator with support
## Implementation Phases ## Implementation Phases
### Phase 1: Core Data Model (Current) ### Phase 1: Core Data Model
- [x] Create feature branch - [x] Create feature branch
- [x] Document plan - [x] Document plan
- [ ] Create `types/submission.ts` - Type definitions - [x] Create `types/submission.ts` - Type definitions
- [ ] Create `SubmissionService.ts` - Submission CRUD - [x] Create `SubmissionService.ts` - Submission CRUD
- [ ] Create `LinkPreviewService.ts` - OG tag fetching - [x] Create `LinkPreviewService.ts` - OG tag fetching
- [ ] Extend `FeedService.ts` - Handle kind 1111 - [x] Extend `FeedService.ts` - Handle kind 1111
### Phase 2: Post Creation ### Phase 2: Post Creation
- [ ] Create `SubmitComposer.vue` - Multi-type composer - [x] Create `SubmitComposer.vue` - Multi-type composer
- [ ] Add link preview on URL paste - [x] Add link preview on URL paste
- [ ] Integrate with pictrs for media upload - [x] Add NSFW toggle
- [ ] Add NSFW toggle - [x] Add route `/submit` for composer
- [ ] Integrate with pictrs for media upload (Future)
### Phase 3: Feed Display ### Phase 3: Feed Display
- [ ] Create `SubmissionCard.vue` - Link aggregator card - [x] Create `SubmissionRow.vue` - Link aggregator row (Reddit/Lemmy style)
- [ ] Create `VoteButtons.vue` - Up/down voting - [x] Create `VoteControls.vue` - Up/down voting
- [ ] Add feed sorting (hot, new, top, controversial) - [x] Create `SortTabs.vue` - Sort tabs (hot, new, top, controversial)
- [ ] Add score calculation - [x] Create `SubmissionList.vue` - Main feed container
- [x] Create `SubmissionThumbnail.vue` - Thumbnail display
- [x] Add feed sorting (hot, new, top, controversial)
- [x] Add score calculation
- [x] Create `LinkAggregatorTest.vue` - Test page with mock data & live mode
### Phase 4: Detail View ### Phase 4: Detail View
- [ ] Create `SubmissionDetail.vue` - Full post view - [x] Create `SubmissionDetail.vue` - Full post view with content display
- [ ] Integrate `ThreadedPost.vue` for comments - [x] Create `SubmissionComment.vue` - Recursive threaded comments
- [ ] Add comment sorting - [x] Create `SubmissionDetailPage.vue` - Route page wrapper
- [x] Add route `/submission/:id` for detail view
- [x] Add comment sorting (best, new, old, controversial)
- [x] Integrate comment submission via SubmissionService.createComment()
### Phase 5: Communities (Future) ### Phase 5: Communities (Future)
- [ ] Create `CommunityService.ts` - [ ] Create `CommunityService.ts`

View file

@ -6,6 +6,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { Flame, Clock, TrendingUp, Swords } from 'lucide-vue-next' import { Flame, Clock, TrendingUp, Swords } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { SortType, TimeRange } from '../types/submission' import type { SortType, TimeRange } from '../types/submission'
interface Props { interface Props {
@ -78,19 +85,23 @@ function selectTimeRange(range: TimeRange) {
<!-- Time range dropdown (for top) --> <!-- Time range dropdown (for top) -->
<template v-if="showTimeDropdown"> <template v-if="showTimeDropdown">
<span class="text-muted-foreground mx-1">·</span> <span class="text-muted-foreground mx-1">·</span>
<select <Select
:value="currentTimeRange" :model-value="currentTimeRange"
class="bg-transparent text-sm text-muted-foreground hover:text-foreground cursor-pointer border-none outline-none" @update:model-value="selectTimeRange($event as TimeRange)"
@change="selectTimeRange(($event.target as HTMLSelectElement).value as TimeRange)"
> >
<option <SelectTrigger class="h-auto w-auto gap-1 border-0 bg-transparent px-1 py-0.5 text-sm text-muted-foreground shadow-none hover:text-foreground focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="range in timeRangeOptions" v-for="range in timeRangeOptions"
:key="range.id" :key="range.id"
:value="range.id" :value="range.id"
> >
{{ range.label }} {{ range.label }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
</template> </template>
</div> </div>
</template> </template>

View file

@ -0,0 +1,276 @@
<script setup lang="ts">
/**
* SubmissionComment - Recursive comment component for submission threads
* Displays a single comment with vote controls and nested replies
*/
import { computed, ref } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send, X } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue'
import type { SubmissionComment as CommentType } from '../types/submission'
interface Props {
comment: CommentType
depth: number
collapsedComments: Set<string>
getDisplayName: (pubkey: string) => string
isAuthenticated: boolean
currentUserPubkey?: string | null
replyingToId?: string | null
isSubmittingReply?: boolean
}
interface Emits {
(e: 'toggle-collapse', commentId: string): void
(e: 'reply', comment: CommentType): void
(e: 'cancel-reply'): void
(e: 'submit-reply', commentId: string, text: string): void
(e: 'upvote', comment: CommentType): void
(e: 'downvote', comment: CommentType): void
}
const props = withDefaults(defineProps<Props>(), {
replyingToId: null,
isSubmittingReply: false
})
const emit = defineEmits<Emits>()
// Local reply text
const replyText = ref('')
// Is this comment being replied to
const isBeingRepliedTo = computed(() => props.replyingToId === props.comment.id)
// Handle reply click
function onReplyClick() {
emit('reply', props.comment)
}
// Submit the reply
function submitReply() {
if (!replyText.value.trim()) return
emit('submit-reply', props.comment.id, replyText.value.trim())
replyText.value = ''
}
// Cancel reply
function cancelReply() {
replyText.value = ''
emit('cancel-reply')
}
// Is this comment collapsed
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
// Has replies
const hasReplies = computed(() => props.comment.replies && props.comment.replies.length > 0)
// Count total nested replies
const replyCount = computed(() => {
const count = (c: CommentType): number => {
let total = c.replies?.length || 0
c.replies?.forEach(r => { total += count(r) })
return total
}
return count(props.comment)
})
// Format time
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Depth colors for threading lines (using theme-aware chart colors)
const depthColors = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
'bg-chart-5'
]
const depthColor = computed(() => depthColors[props.depth % depthColors.length])
// Is own comment
const isOwnComment = computed(() =>
props.currentUserPubkey && props.currentUserPubkey === props.comment.pubkey
)
</script>
<template>
<div
:class="[
'relative',
depth > 0 ? 'ml-0.5' : ''
]"
>
<!-- Threading line -->
<div
v-if="depth > 0"
:class="[
'absolute left-0 top-0 bottom-0 w-0.5',
depthColor,
'hover:w-1 transition-all cursor-pointer'
]"
@click="emit('toggle-collapse', comment.id)"
/>
<!-- Comment content -->
<div
:class="[
'py-1',
depth > 0 ? 'pl-2' : '',
'hover:bg-accent/20 transition-colors'
]"
>
<!-- Header row -->
<div class="flex items-center gap-2 text-xs">
<!-- Collapse toggle -->
<button
v-if="hasReplies"
class="text-muted-foreground hover:text-foreground"
@click="emit('toggle-collapse', comment.id)"
>
<ChevronDown v-if="!isCollapsed" class="h-3.5 w-3.5" />
<ChevronUp v-else class="h-3.5 w-3.5" />
</button>
<div v-else class="w-3.5" />
<!-- Author -->
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(comment.pubkey) }}
</span>
<!-- Score -->
<span class="text-muted-foreground">
{{ comment.votes.score }} {{ comment.votes.score === 1 ? 'point' : 'points' }}
</span>
<!-- Time -->
<span class="text-muted-foreground">
{{ formatTime(comment.created_at) }}
</span>
<!-- Collapsed indicator -->
<span v-if="isCollapsed && hasReplies" class="text-muted-foreground">
({{ replyCount }} {{ replyCount === 1 ? 'child' : 'children' }})
</span>
</div>
<!-- Content (hidden when collapsed) -->
<div v-if="!isCollapsed">
<!-- Comment body -->
<div class="mt-1 text-sm whitespace-pre-wrap leading-relaxed pl-5">
{{ comment.content }}
</div>
<!-- Actions -->
<div class="flex items-center gap-1 mt-1 pl-5">
<!-- Vote buttons (inline style) -->
<button
: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 transition-colors',
comment.votes.userVote === 'downvote'
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500'
]"
:disabled="!isAuthenticated"
@click="emit('downvote', comment)"
>
<ChevronDown class="h-4 w-4" />
</button>
<!-- Reply -->
<button
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
:disabled="!isAuthenticated"
@click="onReplyClick"
>
<Reply class="h-3 w-3" />
<span>reply</span>
</button>
<!-- Report (not for own comments) -->
<button
v-if="!isOwnComment"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Flag class="h-3 w-3" />
<span>report</span>
</button>
<!-- More options -->
<button
class="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<MoreHorizontal class="h-3 w-3" />
</button>
</div>
<!-- Inline reply form -->
<div v-if="isBeingRepliedTo" class="mt-2 pl-5">
<div class="border rounded-lg bg-background p-2">
<textarea
v-model="replyText"
placeholder="Write a reply..."
rows="3"
class="w-full px-2 py-1.5 text-sm bg-transparent resize-none focus:outline-none"
:disabled="isSubmittingReply"
/>
<div class="flex items-center justify-end gap-2 mt-1">
<button
class="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="cancelReply"
>
Cancel
</button>
<button
class="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!replyText.trim() || isSubmittingReply"
@click="submitReply"
>
<Send class="h-3 w-3" />
Reply
</button>
</div>
</div>
</div>
<!-- Nested replies -->
<div v-if="hasReplies" class="mt-1">
<SubmissionComment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:depth="depth + 1"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingReply"
@toggle-collapse="emit('toggle-collapse', $event)"
@reply="emit('reply', $event)"
@cancel-reply="emit('cancel-reply')"
@submit-reply="(commentId, text) => emit('submit-reply', commentId, text)"
@upvote="emit('upvote', $event)"
@downvote="emit('downvote', $event)"
/>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,541 @@
<script setup lang="ts">
/**
* SubmissionDetail - Full post view with comments
* Displays complete submission content and threaded comments
*/
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
MessageSquare,
Share2,
Bookmark,
Flag,
ExternalLink,
Image as ImageIcon,
FileText,
Loader2,
ChevronUp,
ChevronDown,
Reply,
Send
} from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import VoteControls from './VoteControls.vue'
import SubmissionCommentComponent from './SubmissionComment.vue'
import { useSubmission } 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,
LinkSubmission,
MediaSubmission,
SelfSubmission
} from '../types/submission'
interface Props {
/** Submission ID to display */
submissionId: string
}
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 - handles subscription automatically
const { submission, comments, upvote, downvote, isLoading, error } = useSubmission(props.submissionId)
// Comment composer state
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)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
const profile = profileService.getProfile(pubkey)
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
}
return `${pubkey.slice(0, 8)}...`
}
// Format timestamp
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Extract domain from URL
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace('www.', '')
} catch {
return url
}
}
// Cast submission to specific type
const linkSubmission = computed(() =>
submission.value?.postType === 'link' ? submission.value as LinkSubmission : null
)
const mediaSubmission = computed(() =>
submission.value?.postType === 'media' ? submission.value as MediaSubmission : null
)
const selfSubmission = computed(() =>
submission.value?.postType === 'self' ? submission.value as SelfSubmission : null
)
// Community name
const communityName = computed(() => {
if (!submission.value?.communityRef) return null
const parts = submission.value.communityRef.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Handle voting
async function onUpvote() {
if (!isAuthenticated.value) return
try {
await upvote()
} catch (err) {
console.error('Failed to upvote:', err)
}
}
async function onDownvote() {
if (!isAuthenticated.value) return
try {
await downvote()
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// 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
navigator.clipboard?.writeText(url)
// TODO: Show toast
}
// Computed for passing to comment components
const replyingToId = computed(() => replyingTo.value?.id || null)
// Handle comment reply - for inline replies to comments
function startReply(comment?: SubmissionCommentType) {
if (comment) {
// Replying to a comment - show inline form (handled by SubmissionComment)
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
showComposer.value = false // Hide top composer
} else {
// Top-level comment - show top composer
replyingTo.value = null
showComposer.value = true
}
commentText.value = ''
}
function cancelReply() {
showComposer.value = false
replyingTo.value = null
commentText.value = ''
}
// Submit top-level comment (from top composer)
async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
commentText.value.trim(),
null // Top-level comment
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit comment:', err)
commentError.value = err.message || 'Failed to post comment'
} finally {
isSubmittingComment.value = false
}
}
// Submit inline reply (from SubmissionComment's inline form)
async function submitReply(commentId: string, text: string) {
if (!text.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
text.trim(),
commentId
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit reply:', err)
commentError.value = err.message || 'Failed to post reply'
} finally {
isSubmittingComment.value = false
}
}
// Toggle comment collapse
function toggleCollapse(commentId: string) {
if (collapsedComments.value.has(commentId)) {
collapsedComments.value.delete(commentId)
} else {
collapsedComments.value.add(commentId)
}
// Trigger reactivity
collapsedComments.value = new Set(collapsedComments.value)
}
// Count total replies
function countReplies(comment: SubmissionCommentType): number {
let count = comment.replies?.length || 0
comment.replies?.forEach(reply => {
count += countReplies(reply)
})
return count
}
// Go back
function goBack() {
router.back()
}
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-40 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3">
<div class="flex items-center gap-3">
<Button variant="ghost" size="sm" @click="goBack" class="h-8 w-8 p-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-medium truncate">
{{ submission?.title || 'Loading...' }}
</h1>
<p v-if="communityName" class="text-xs text-muted-foreground">
{{ communityName }}
</p>
</div>
</div>
</div>
</header>
<!-- Loading state -->
<div v-if="isLoading && !submission" class="flex items-center justify-center py-16">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submission...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="max-w-4xl mx-auto p-4">
<div class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
<!-- Submission content -->
<main v-else-if="submission" class="max-w-4xl mx-auto">
<article class="p-4 border-b">
<!-- Post header with votes -->
<div class="flex gap-3">
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title -->
<h1 class="text-xl font-semibold leading-tight mb-2">
{{ submission.title }}
</h1>
<!-- Badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-2 mb-2">
<Badge v-if="submission.nsfw" variant="destructive" class="text-xs">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-xs">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata -->
<div class="text-sm text-muted-foreground mb-4">
<span>submitted {{ formatTime(submission.created_at) }}</span>
<span> by </span>
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(submission.pubkey) }}
</span>
<template v-if="communityName">
<span> to </span>
<span class="font-medium hover:underline cursor-pointer">{{ communityName }}</span>
</template>
</div>
<!-- Link post content -->
<div v-if="linkSubmission" class="mb-4">
<a
:href="linkSubmission.url"
target="_blank"
rel="noopener noreferrer"
class="block p-4 border rounded-lg hover:bg-accent/30 transition-colors group"
>
<div class="flex items-start gap-4">
<!-- Preview image -->
<div
v-if="linkSubmission.preview?.image"
class="flex-shrink-0 w-32 h-24 rounded overflow-hidden bg-muted"
>
<img
:src="linkSubmission.preview.image"
:alt="linkSubmission.preview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<div v-else class="flex-shrink-0 w-16 h-16 rounded bg-muted flex items-center justify-center">
<ExternalLink class="h-6 w-6 text-muted-foreground" />
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ extractDomain(linkSubmission.url) }}</span>
</div>
<h3 v-if="linkSubmission.preview?.title" class="font-medium text-sm group-hover:underline">
{{ linkSubmission.preview.title }}
</h3>
<p v-if="linkSubmission.preview?.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkSubmission.preview.description }}
</p>
</div>
</div>
</a>
<!-- Optional body -->
<div v-if="linkSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ linkSubmission.body }}
</div>
</div>
<!-- Media post content -->
<div v-if="mediaSubmission" class="mb-4">
<div class="rounded-lg overflow-hidden bg-muted">
<img
v-if="mediaSubmission.media.mimeType?.startsWith('image/')"
:src="mediaSubmission.media.url"
:alt="mediaSubmission.media.alt || ''"
class="max-w-full max-h-[600px] mx-auto"
/>
<video
v-else-if="mediaSubmission.media.mimeType?.startsWith('video/')"
:src="mediaSubmission.media.url"
controls
class="max-w-full max-h-[600px] mx-auto"
/>
<div v-else class="p-8 flex flex-col items-center justify-center">
<ImageIcon class="h-12 w-12 text-muted-foreground mb-2" />
<a
:href="mediaSubmission.media.url"
target="_blank"
class="text-sm text-primary hover:underline"
>
View media
</a>
</div>
</div>
<!-- Caption -->
<div v-if="mediaSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ mediaSubmission.body }}
</div>
</div>
<!-- Self post content -->
<div v-if="selfSubmission" class="mb-4">
<div class="text-sm whitespace-pre-wrap leading-relaxed">
{{ selfSubmission.body }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="startReply()"
>
<MessageSquare class="h-4 w-4" />
<span>{{ submission.commentCount }} comments</span>
</button>
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="onShare"
>
<Share2 class="h-4 w-4" />
<span>share</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Bookmark class="h-4 w-4" />
<span>save</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Flag class="h-4 w-4" />
<span>report</span>
</button>
</div>
</div>
</div>
</article>
<!-- Top-level comment composer (only for new comments, not replies) -->
<div v-if="isAuthenticated && !replyingTo" class="p-4 border-b">
<div class="flex gap-2">
<textarea
v-model="commentText"
placeholder="Write a comment..."
rows="3"
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"
:disabled="!commentText.trim() || isSubmittingComment"
@click="submitComment"
>
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
<Send v-else class="h-4 w-4 mr-2" />
Comment
</Button>
</div>
</div>
<!-- Comments section -->
<section class="divide-y divide-border">
<!-- 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 sortedComments" :key="comment.id">
<SubmissionCommentComponent
:comment="comment"
:depth="0"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingComment"
@toggle-collapse="toggleCollapse"
@reply="startReply"
@cancel-reply="cancelReply"
@submit-reply="submitReply"
@upvote="onCommentUpvote"
@downvote="onCommentDownvote"
/>
</template>
</section>
</main>
<!-- Not found state -->
<div v-else class="max-w-4xl mx-auto p-4">
<div class="text-center py-16">
<FileText class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Submission not found</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
</div>
</template>

View file

@ -120,7 +120,7 @@ function onSubmissionClick(submission: SubmissionWithMeta) {
// Handle share // Handle share
function onShare(submission: SubmissionWithMeta) { function onShare(submission: SubmissionWithMeta) {
// Copy link to clipboard or open share dialog // Copy link to clipboard or open share dialog
const url = `${window.location.origin}/post/${submission.id}` const url = `${window.location.origin}/submission/${submission.id}`
navigator.clipboard?.writeText(url) navigator.clipboard?.writeText(url)
// TODO: Show toast notification // TODO: Show toast notification
} }

View file

@ -0,0 +1,406 @@
<script setup lang="ts">
/**
* SubmitComposer - Create new submissions (link, media, self posts)
* Similar to Lemmy's Create Post form
*/
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
Link2,
FileText,
Image as ImageIcon,
Loader2,
ExternalLink,
X,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SubmissionService } from '../services/SubmissionService'
import type { LinkPreviewService } from '../services/LinkPreviewService'
import type {
LinkPreview,
SubmissionType,
LinkSubmissionForm,
SelfSubmissionForm,
SubmissionForm
} from '../types/submission'
interface Props {
/** Pre-selected community */
community?: string
/** Initial post type */
initialType?: SubmissionType
}
const props = withDefaults(defineProps<Props>(), {
initialType: 'self'
})
const emit = defineEmits<{
(e: 'submitted', submissionId: string): void
(e: 'cancel'): void
}>()
const router = useRouter()
// Services
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Form state
const postType = ref<SubmissionType>(props.initialType)
const title = ref('')
const url = ref('')
const body = ref('')
const thumbnailUrl = ref('')
const nsfw = ref(false)
// Link preview state
const linkPreview = ref<LinkPreview | null>(null)
const isLoadingPreview = ref(false)
const previewError = ref<string | null>(null)
// Submission state
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
// Validation
const isValid = computed(() => {
if (!title.value.trim()) return false
if (postType.value === 'link' && !url.value.trim()) return false
if (postType.value === 'self' && !body.value.trim()) return false
return true
})
// Debounced URL preview fetching
let previewTimeout: ReturnType<typeof setTimeout> | null = null
watch(url, (newUrl) => {
if (previewTimeout) {
clearTimeout(previewTimeout)
}
linkPreview.value = null
previewError.value = null
if (!newUrl.trim() || postType.value !== 'link') {
return
}
// Validate URL format
try {
new URL(newUrl)
} catch {
return
}
// Debounce the preview fetch
previewTimeout = setTimeout(async () => {
await fetchLinkPreview(newUrl)
}, 500)
})
async function fetchLinkPreview(urlToFetch: string) {
if (!linkPreviewService) {
previewError.value = 'Link preview service not available'
return
}
isLoadingPreview.value = true
previewError.value = null
try {
const preview = await linkPreviewService.fetchPreview(urlToFetch)
linkPreview.value = preview
} catch (err: any) {
console.error('Failed to fetch link preview:', err)
previewError.value = err.message || 'Failed to load preview'
} finally {
isLoadingPreview.value = false
}
}
function clearPreview() {
linkPreview.value = null
previewError.value = null
}
async function handleSubmit() {
if (!isValid.value || !isAuthenticated.value || !submissionService) return
isSubmitting.value = true
submitError.value = null
try {
let form: SubmissionForm
if (postType.value === 'link') {
const linkForm: LinkSubmissionForm = {
postType: 'link',
title: title.value.trim(),
url: url.value.trim(),
body: body.value.trim() || undefined,
communityRef: props.community,
nsfw: nsfw.value
}
form = linkForm
} else if (postType.value === 'self') {
const selfForm: SelfSubmissionForm = {
postType: 'self',
title: title.value.trim(),
body: body.value.trim(),
communityRef: props.community,
nsfw: nsfw.value
}
form = selfForm
} else if (postType.value === 'media') {
// TODO: Implement media submission with file upload
submitError.value = 'Media uploads not yet implemented'
return
} else {
submitError.value = 'Unknown post type'
return
}
const submissionId = await submissionService.createSubmission(form)
if (submissionId) {
emit('submitted', submissionId)
// Navigate to the new submission
router.push({ name: 'submission-detail', params: { id: submissionId } })
} else {
submitError.value = 'Failed to create submission'
}
} catch (err: any) {
console.error('Failed to submit:', err)
submitError.value = err.message || 'Failed to create submission'
} finally {
isSubmitting.value = false
}
}
function handleCancel() {
emit('cancel')
router.back()
}
function selectPostType(type: SubmissionType) {
postType.value = type
// Clear URL when switching away from link type
if (type !== 'link') {
url.value = ''
clearPreview()
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-4">
<h1 class="text-xl font-semibold mb-6">Create Post</h1>
<!-- Auth warning -->
<div v-if="!isAuthenticated" class="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div class="flex items-center gap-2 text-destructive">
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">You must be logged in to create a post</span>
</div>
</div>
<!-- Post type selector -->
<div class="flex gap-2 mb-6">
<Button
:variant="postType === 'self' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('self')"
>
<FileText class="h-4 w-4 mr-2" />
Text
</Button>
<Button
:variant="postType === 'link' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('link')"
>
<Link2 class="h-4 w-4 mr-2" />
Link
</Button>
<Button
:variant="postType === 'media' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('media')"
disabled
title="Coming soon"
>
<ImageIcon class="h-4 w-4 mr-2" />
Image
</Button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Title -->
<div>
<label class="block text-sm font-medium mb-1.5">
Title <span class="text-destructive">*</span>
</label>
<input
v-model="title"
type="text"
placeholder="An interesting title"
maxlength="300"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1 text-right">
{{ title.length }}/300
</div>
</div>
<!-- URL (for link posts) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
URL <span class="text-destructive">*</span>
</label>
<input
v-model="url"
type="url"
placeholder="https://example.com/article"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<!-- Link preview -->
<div v-if="isLoadingPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Loading preview...
</div>
</div>
<div v-else-if="previewError" class="mt-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ previewError }}
</div>
</div>
<div v-else-if="linkPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-start gap-3">
<!-- Preview image -->
<div v-if="linkPreview.image" class="flex-shrink-0 w-24 h-18 rounded overflow-hidden bg-muted">
<img
:src="linkPreview.image"
:alt="linkPreview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ linkPreview.domain }}</span>
<button
type="button"
class="ml-auto p-1 hover:bg-accent rounded"
@click="clearPreview"
>
<X class="h-3 w-3" />
</button>
</div>
<h4 v-if="linkPreview.title" class="font-medium text-sm line-clamp-2">
{{ linkPreview.title }}
</h4>
<p v-if="linkPreview.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkPreview.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Thumbnail URL (optional) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
Thumbnail URL
<span class="text-muted-foreground font-normal">(optional)</span>
</label>
<input
v-model="thumbnailUrl"
type="url"
placeholder="https://example.com/image.jpg"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
</div>
<!-- Body -->
<div>
<label class="block text-sm font-medium mb-1.5">
Body
<span v-if="postType === 'self'" class="text-destructive">*</span>
<span v-else class="text-muted-foreground font-normal">(optional)</span>
</label>
<textarea
v-model="body"
:placeholder="postType === 'self' ? 'Write your post content...' : 'Optional description or commentary...'"
rows="6"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-y min-h-[120px]"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1">
Markdown supported
</div>
</div>
<!-- NSFW toggle -->
<div class="flex items-center gap-2">
<input
id="nsfw"
v-model="nsfw"
type="checkbox"
class="rounded border-muted-foreground"
:disabled="!isAuthenticated"
/>
<label for="nsfw" class="text-sm font-medium cursor-pointer">
NSFW
</label>
<Badge v-if="nsfw" variant="destructive" class="text-xs">
Not Safe For Work
</Badge>
</div>
<!-- Error message -->
<div v-if="submitError" class="p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ submitError }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4">
<Button
type="submit"
:disabled="!isValid || isSubmitting || !isAuthenticated"
>
<Loader2 v-if="isSubmitting" class="h-4 w-4 animate-spin mr-2" />
Create
</Button>
<Button
type="button"
variant="outline"
@click="handleCancel"
>
Cancel
</Button>
</div>
</form>
</div>
</template>

View file

@ -289,9 +289,27 @@ export function useSubmissions(options: UseSubmissionsOptions = {}): UseSubmissi
export function useSubmission(submissionId: string) { export function useSubmission(submissionId: string) {
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE) const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const isLoading = ref(false)
const error = ref<string | null>(null)
const submission = computed(() => submissionService?.getSubmission(submissionId)) const submission = computed(() => submissionService?.getSubmission(submissionId))
const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? []) const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? [])
async function subscribe(): Promise<void> {
if (!submissionService) return
isLoading.value = true
error.value = null
try {
await submissionService.subscribeToSubmission(submissionId)
} catch (err: any) {
error.value = err.message || 'Failed to load submission'
} finally {
isLoading.value = false
}
}
async function upvote(): Promise<void> { async function upvote(): Promise<void> {
await submissionService?.upvote(submissionId) await submissionService?.upvote(submissionId)
} }
@ -300,9 +318,17 @@ export function useSubmission(submissionId: string) {
await submissionService?.downvote(submissionId) await submissionService?.downvote(submissionId)
} }
// Subscribe on mount
onMounted(() => {
subscribe()
})
return { return {
submission, submission,
comments, comments,
isLoading: computed(() => isLoading.value || submissionService?.isLoading.value || false),
error: computed(() => error.value || submissionService?.error.value || null),
subscribe,
upvote, upvote,
downvote downvote
} }

View file

@ -4,10 +4,13 @@ import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue' import NostrFeed from './components/NostrFeed.vue'
import SubmissionList from './components/SubmissionList.vue' import SubmissionList from './components/SubmissionList.vue'
import SubmissionRow from './components/SubmissionRow.vue' import SubmissionRow from './components/SubmissionRow.vue'
import SubmissionDetail from './components/SubmissionDetail.vue'
import SubmissionComment from './components/SubmissionComment.vue'
import SubmitComposer from './components/SubmitComposer.vue'
import VoteControls from './components/VoteControls.vue' import VoteControls from './components/VoteControls.vue'
import SortTabs from './components/SortTabs.vue' import SortTabs from './components/SortTabs.vue'
import { useFeed } from './composables/useFeed' import { useFeed } from './composables/useFeed'
import { useSubmissions } from './composables/useSubmissions' import { useSubmissions, useSubmission } from './composables/useSubmissions'
import { FeedService } from './services/FeedService' import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService' import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService' import { ReactionService } from './services/ReactionService'
@ -23,6 +26,27 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0', version: '1.0.0',
dependencies: ['base'], dependencies: ['base'],
routes: [
{
path: '/submission/:id',
name: 'submission-detail',
component: () => import('./views/SubmissionDetailPage.vue'),
meta: {
title: 'Submission',
requiresAuth: false
}
},
{
path: '/submit',
name: 'submit-post',
component: () => import('./views/SubmitPage.vue'),
meta: {
title: 'Create Post',
requiresAuth: true
}
}
],
async install(app: App) { async install(app: App) {
console.log('nostr-feed module: Starting installation...') console.log('nostr-feed module: Starting installation...')
@ -76,13 +100,17 @@ export const nostrFeedModule: ModulePlugin = {
NostrFeed, NostrFeed,
SubmissionList, SubmissionList,
SubmissionRow, SubmissionRow,
SubmissionDetail,
SubmissionComment,
SubmitComposer,
VoteControls, VoteControls,
SortTabs SortTabs
}, },
composables: { composables: {
useFeed, useFeed,
useSubmissions useSubmissions,
useSubmission
} }
} }

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

@ -148,6 +148,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') this.debug('End of stored events')
// Refresh all submission votes from loaded reactions
this.refreshAllSubmissionVotes()
this._isLoading.value = false this._isLoading.value = false
}, },
onClose: () => { onClose: () => {
@ -183,6 +185,82 @@ export class SubmissionService extends BaseService {
} }
} }
/**
* Subscribe to a specific submission and its comments
*/
async subscribeToSubmission(submissionId: string): Promise<void> {
this._isLoading.value = true
this._error.value = null
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
const subscriptionId = `submission-${submissionId}-${Date.now()}`
// Build filters for the submission and its comments
const filters: Filter[] = [
// The submission itself
{
kinds: [SUBMISSION_KINDS.SUBMISSION],
ids: [submissionId]
},
// Comments referencing this submission (via 'e' tag - parent reference)
{
kinds: [SUBMISSION_KINDS.SUBMISSION],
'#e': [submissionId]
},
// Comments referencing this submission (via 'E' tag - root reference)
{
kinds: [SUBMISSION_KINDS.SUBMISSION],
'#E': [submissionId]
},
// Reactions on the submission and comments
{
kinds: [SUBMISSION_KINDS.REACTION],
'#e': [submissionId]
}
]
this.debug('Subscribing to submission with filters:', filters)
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters,
onEvent: (event: NostrEvent) => this.handleEvent(event),
onEose: () => {
this.debug('End of stored events for submission')
// Refresh submission votes from loaded reactions
this.refreshSubmissionVotes(submissionId)
// After loading comments, fetch their reactions
this.fetchCommentReactions(submissionId)
this._isLoading.value = false
},
onClose: () => {
this.debug('Submission subscription closed')
}
})
// Store for cleanup (don't overwrite main subscription)
// For now, we'll manage this separately
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
// Timeout fallback
setTimeout(() => {
if (this._isLoading.value && this.currentSubscription === subscriptionId) {
this._isLoading.value = false
}
}, 10000)
} catch (err) {
this._error.value = err instanceof Error ? err.message : 'Failed to subscribe to submission'
this._isLoading.value = false
throw err
}
}
/** /**
* Build Nostr filters from feed configuration * Build Nostr filters from feed configuration
*/ */
@ -274,6 +352,12 @@ export class SubmissionService extends BaseService {
} }
this.seenEventIds.add(event.id) this.seenEventIds.add(event.id)
// Check if this is a top-level submission or a comment
if (this.isComment(event)) {
this.handleCommentEvent(event)
return
}
// Parse the submission // Parse the submission
const submission = this.parseSubmission(event) const submission = this.parseSubmission(event)
if (!submission) { if (!submission) {
@ -281,12 +365,6 @@ export class SubmissionService extends BaseService {
return return
} }
// Check if this is a top-level submission or a comment
if (this.isComment(event)) {
this.handleCommentEvent(event)
return
}
// Create full submission with metadata // Create full submission with metadata
const submissionWithMeta = this.enrichSubmission(submission) const submissionWithMeta = this.enrichSubmission(submission)
this._submissions.set(submission.id, submissionWithMeta) this._submissions.set(submission.id, submissionWithMeta)
@ -329,6 +407,12 @@ export class SubmissionService extends BaseService {
const rootId = rootTag[1] const rootId = rootTag[1]
// Check for duplicate comment
const existingComments = this._comments.get(rootId)
if (existingComments?.some(c => c.id === event.id)) {
return
}
// Parse as comment // Parse as comment
const comment: SubmissionComment = { const comment: SubmissionComment = {
id: event.id, id: event.id,
@ -339,7 +423,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
@ -347,6 +431,12 @@ export class SubmissionService extends BaseService {
this._comments.set(rootId, []) this._comments.set(rootId, [])
} }
this._comments.get(rootId)!.push(comment) this._comments.get(rootId)!.push(comment)
// Update comment count on the submission
const submission = this._submissions.get(rootId)
if (submission) {
submission.commentCount = this._comments.get(rootId)!.length
}
} }
/** /**
@ -617,6 +707,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
*/ */
@ -804,12 +911,23 @@ export class SubmissionService extends BaseService {
* Downvote a submission * Downvote a submission
*/ */
async downvote(submissionId: string): Promise<void> { async downvote(submissionId: string): Promise<void> {
// TODO: Implement downvote using '-' reaction content
// For now, this is a placeholder that mirrors the upvote logic
const submission = this._submissions.get(submissionId) const submission = this._submissions.get(submissionId)
if (!submission) throw new Error('Submission not found') if (!submission) throw new Error('Submission not found')
this.debug('Downvote not yet implemented') if (submission.votes.userVote === 'downvote') {
// Remove downvote
await this.reactionService?.undislikeEvent(submissionId)
} else {
// Add downvote (ReactionService keeps only latest reaction per user)
await this.reactionService?.dislikeEvent(
submissionId,
submission.pubkey,
SUBMISSION_KINDS.SUBMISSION
)
}
// Refresh votes
this.refreshSubmissionVotes(submissionId)
} }
/** /**
@ -823,6 +941,77 @@ export class SubmissionService extends BaseService {
submission.ranking = this.calculateRanking(submission, submission.votes) submission.ranking = this.calculateRanking(submission, submission.votes)
} }
/**
* Refresh votes for all submissions
*/
private refreshAllSubmissionVotes(): void {
for (const submissionId of this._submissions.keys()) {
this.refreshSubmissionVotes(submissionId)
}
}
/**
* 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
// ============================================================================ // ============================================================================
@ -923,6 +1112,223 @@ 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)
}
/**
* 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
// ============================================================================ // ============================================================================

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
/**
* SubmissionDetailPage - Page wrapper for submission detail view
* Extracts route params and passes to SubmissionDetail component
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import SubmissionDetail from '../components/SubmissionDetail.vue'
const route = useRoute()
const submissionId = computed(() => route.params.id as string)
</script>
<template>
<SubmissionDetail :submission-id="submissionId" />
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* SubmitPage - Page wrapper for submission composer
* Handles route query params for community pre-selection
*/
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SubmitComposer from '../components/SubmitComposer.vue'
const route = useRoute()
const router = useRouter()
// Get community from query param if provided (e.g., /submit?community=...)
const community = computed(() => route.query.community as string | undefined)
// Handle submission completion
function onSubmitted(submissionId: string) {
// Navigation is handled by SubmitComposer
console.log('Submission created:', submissionId)
}
// Handle cancel - go back
function onCancel() {
router.back()
}
</script>
<template>
<SubmitComposer
:community="community"
@submitted="onSubmitted"
@cancel="onCancel"
/>
</template>

View file

@ -321,6 +321,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { MessageSquare, Share2, Bookmark, EyeOff, Send, Loader2, ChevronDown, ChevronUp, Wifi, WifiOff } from 'lucide-vue-next' import { MessageSquare, Share2, Bookmark, EyeOff, Send, Loader2, ChevronDown, ChevronUp, Wifi, WifiOff } from 'lucide-vue-next'
import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent } from 'nostr-tools' import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent } from 'nostr-tools'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
@ -331,6 +332,8 @@ import SubmissionThumbnail from '@/modules/nostr-feed/components/SubmissionThumb
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SortType, TimeRange, VoteType } from '@/modules/nostr-feed/types/submission' import type { SortType, TimeRange, VoteType } from '@/modules/nostr-feed/types/submission'
const router = useRouter()
// View mode // View mode
const viewMode = ref<'submissions' | 'mock'>('mock') const viewMode = ref<'submissions' | 'mock'>('mock')
@ -710,6 +713,6 @@ function onMockDownvote(submission: MockSubmission) {
// Real submission click handler // Real submission click handler
function onSubmissionClick(submission: any) { function onSubmissionClick(submission: any) {
console.log('Clicked submission:', submission) console.log('Clicked submission:', submission)
// TODO: Navigate to detail view router.push({ name: 'submission-detail', params: { id: submission.id } })
} }
</script> </script>