@@ -108,76 +108,121 @@
-
-
+
+
- Shipping & Contact
- Select shipping and provide your contact details
+ Shipping Information
+ Select your shipping zone
-
+
-
-
Shipping Zone
-
-
-
-
-
{{ zone.name }}
-
- {{ zone.countries?.join(', ') || 'Available' }}
-
- • No shipping required
-
-
-
- {{ zone.description }}
-
-
-
-
- {{ formatPrice(zone.cost, checkoutCart.currency) }}
-
-
shipping
-
+
+
+
+
+
{{ zone.name }}
+
+ {{ zone.countries?.join(', ') || 'Available' }}
+
+ • No shipping required
+
+
+
+ {{ zone.description }}
+
+
+
+
+ {{ formatPrice(zone.cost, checkoutCart.currency) }}
+
+
shipping
-
-
+
+
This merchant hasn't configured shipping zones yet.
Please contact the merchant for shipping information.
-
-
+
+
+
+ Confirm Order
+
+
+
+
-
-
-
Shipping Address *
-
-
Required for physical delivery
+
+
+
+ Contact & Payment Information
+ Provide your details for order processing
+
+
+
+
+
+
Email (optional)
+
+
Merchant may not use email
+
+
+
+
Alternative Npub (optional)
+
+
Different Npub for communication
+
+
+
+
+
+ {{ selectedShippingZone?.requiresPhysicalShipping !== false ? 'Shipping Address' : 'Contact Address (optional)' }}
+
+
+
+ {{ selectedShippingZone?.requiresPhysicalShipping !== false
+ ? 'Required for physical delivery'
+ : 'Optional for digital items or pickup' }}
+
-
Message to Merchant (optional)
-
-
-
-
-
- Additional contact info (optional)
-
-
-
-
-
Contact Address
-
-
Optional for digital items or pickup
+
+
+
Payment Method
+
+
-
-
-
-
Email
-
-
Merchant may not use email
-
-
-
-
Alternative Npub
-
-
Different Npub for communication
-
-
-
-
-
-
-
- ⚡
- Payment via Lightning Network
+
-
⚡
Place Order - {{ formatPrice(orderTotal, checkoutCart.currency) }}
-
- {{ orderValidationMessage }}
+
+ Shipping address is required for physical delivery
@@ -266,12 +279,12 @@ import { useRoute } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { auth } from '@/composables/useAuthService'
-import {
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -280,14 +293,8 @@ import { Label } from '@/components/ui/label'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import {
Package,
- CheckCircle,
- ChevronDown
+ CheckCircle
} from 'lucide-vue-next'
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger
-} from '@/components/ui/collapsible'
const route = useRoute()
const marketStore = useMarketStore()
@@ -296,10 +303,11 @@ const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
// State
const isLoading = ref(true)
const error = ref
(null)
+const orderConfirmed = ref(false)
const orderPlaced = ref(false)
const isPlacingOrder = ref(false)
const selectedShippingZone = ref(null)
-const showOptionalContact = ref(false)
+const paymentMethod = ref('ln')
// Form data
const contactData = ref({
@@ -309,7 +317,12 @@ const contactData = ref({
message: ''
})
-// TODO: Add BTC Onchain and Cashu payment options in the future
+// Payment methods
+const paymentMethods = [
+ { label: 'Lightning Network', value: 'ln' },
+ { label: 'BTC Onchain', value: 'btc' },
+ { label: 'Cashu', value: 'cashu' }
+]
// Computed
const stallId = computed(() => route.params.stallId as string)
@@ -328,7 +341,7 @@ const currentStall = computed(() => {
const orderSubtotal = computed(() => {
if (!checkoutCart.value?.products) return 0
- return checkoutCart.value.products.reduce((total, item) =>
+ return checkoutCart.value.products.reduce((total, item) =>
total + (item.product.price * item.quantity), 0
)
})
@@ -342,22 +355,22 @@ const orderTotal = computed(() => {
// Get shipping zones from the current stall
const availableShippingZones = computed(() => {
if (!currentStall.value) return []
-
+
// Use standardized shipping property from domain model
const zones = currentStall.value.shipping || []
-
+
// Ensure zones have required properties and determine shipping requirements
return zones.map(zone => {
const zoneName = zone.name || 'Shipping Zone'
const lowerName = zoneName.toLowerCase()
-
+
// Determine if this zone requires physical shipping
- const requiresPhysicalShipping = zone.requiresPhysicalShipping !== false &&
- !lowerName.includes('digital') &&
- !lowerName.includes('pickup') &&
- !lowerName.includes('download') &&
+ const requiresPhysicalShipping = zone.requiresPhysicalShipping !== false &&
+ !lowerName.includes('digital') &&
+ !lowerName.includes('pickup') &&
+ !lowerName.includes('download') &&
zone.cost > 0 // Free usually means digital or pickup
-
+
return {
id: zone.id || zoneName.toLowerCase().replace(/\s+/g, '-'),
name: zoneName,
@@ -370,41 +383,26 @@ const availableShippingZones = computed(() => {
})
})
-// Determine if shipping address is required
-const requiresShippingAddress = computed(() => {
- return selectedShippingZone.value?.requiresPhysicalShipping !== false
-})
-
-// Validation for placing order
-const canPlaceOrder = computed(() => {
- // Must select shipping zone if zones are available
- if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
- return false
- }
- // Must provide address if physical shipping is required
- if (requiresShippingAddress.value && !contactData.value.address) {
- return false
- }
- return true
-})
-
-const orderValidationMessage = computed(() => {
- if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
- return 'Please select a shipping zone'
- }
- if (requiresShippingAddress.value && !contactData.value.address) {
- return 'Shipping address is required for physical delivery'
- }
- return ''
-})
-
// Methods
const selectShippingZone = (zone: any) => {
selectedShippingZone.value = zone
}
+const confirmOrder = () => {
+ // Allow proceeding if no shipping zones are available (e.g., for digital goods)
+ if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
+ error.value = 'Please select a shipping zone'
+ return
+ }
+ orderConfirmed.value = true
+}
+
const placeOrder = async () => {
- if (!canPlaceOrder.value) {
+ // Only require shipping address if selected zone requires physical shipping
+ const requiresShippingAddress = selectedShippingZone.value?.requiresPhysicalShipping !== false
+
+ if (requiresShippingAddress && !contactData.value.address) {
+ error.value = 'Shipping address is required for this delivery method'
return
}
@@ -428,14 +426,14 @@ const placeOrder = async () => {
hasPubkey: !!authService.user.value?.pubkey,
nostrPubkey: authService.user.value?.pubkey
})
-
+
// Try to get pubkey from main auth first, fallback to auth service
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
-
+
if (!auth.isAuthenticated.value) {
throw new Error('You must be logged in to place an order')
}
-
+
if (!userPubkey) {
throw new Error('Nostr identity required: Please configure your Nostr public key in your profile settings to place orders.')
}
@@ -466,7 +464,7 @@ const placeOrder = async () => {
currency: checkoutCart.value.currency,
requiresPhysicalShipping: false
},
- paymentMethod: 'lightning' as const,
+ paymentMethod: paymentMethod.value === 'ln' ? 'lightning' as const : 'btc_onchain' as const,
subtotal: orderSubtotal.value,
shippingCost: selectedShippingZone.value?.cost || 0,
total: orderTotal.value,
@@ -475,13 +473,13 @@ const placeOrder = async () => {
}
console.log('Creating order:', orderData)
-
+
// Create and place the order via the market store
const order = await marketStore.createAndPlaceOrder(orderData)
-
+
console.log('Order placed successfully:', order)
orderPlaced.value = true
-
+
} catch (err) {
console.error('Failed to place order:', err)
error.value = err instanceof Error ? err.message : 'Failed to place order'
@@ -507,12 +505,12 @@ onMounted(() => {
if (!cart || cart.id !== stallId.value) {
error.value = 'No checkout data found for this stall'
}
-
+
// Auto-select shipping zone if there's only one
if (availableShippingZones.value.length === 1) {
selectedShippingZone.value = availableShippingZones.value[0]
}
-
+
isLoading.value = false
})
-
+
\ No newline at end of file
diff --git a/src/modules/market/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue
index 44aac34..0a2b0f7 100644
--- a/src/modules/market/views/MarketDashboard.vue
+++ b/src/modules/market/views/MarketDashboard.vue
@@ -62,7 +62,6 @@
-
-
-
-
-
-
-
- {{ option.label }}
-
-
-
-
-
- ·
-
-
-
-
-
-
- {{ range.label }}
-
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmissionComment.vue b/src/modules/nostr-feed/components/SubmissionComment.vue
deleted file mode 100644
index ab52a9e..0000000
--- a/src/modules/nostr-feed/components/SubmissionComment.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ getDisplayName(comment.pubkey) }}
-
-
-
-
- {{ comment.votes.score }} {{ comment.votes.score === 1 ? 'point' : 'points' }}
-
-
-
-
- {{ formatTime(comment.created_at) }}
-
-
-
-
- ({{ replyCount }} {{ replyCount === 1 ? 'child' : 'children' }})
-
-
-
-
-
-
-
- {{ comment.content }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- reply
-
-
-
-
-
- report
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cancel
-
-
-
- Reply
-
-
-
-
-
-
-
- emit('submit-reply', commentId, text)"
- @upvote="emit('upvote', $event)"
- @downvote="emit('downvote', $event)"
- />
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmissionDetail.vue b/src/modules/nostr-feed/components/SubmissionDetail.vue
deleted file mode 100644
index 1b00bbe..0000000
--- a/src/modules/nostr-feed/components/SubmissionDetail.vue
+++ /dev/null
@@ -1,553 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ submission?.title || 'Loading...' }}
-
-
- {{ communityName }}
-
-
-
-
-
-
-
-
-
- Loading submission...
-
-
-
-
-
-
{{ error }}
-
- Go Back
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ submission.title }}
-
-
-
-
-
- NSFW
-
-
- {{ submission.flair }}
-
-
-
-
-
- submitted {{ formatTime(submission.created_at) }}
- by
-
- {{ getDisplayName(submission.pubkey) }}
-
-
- to
- {{ communityName }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ mediaSubmission.body }}
-
-
-
-
-
-
- {{ selfSubmission.body }}
-
-
-
-
-
-
-
- {{ submission.commentCount }} comments
-
-
-
- share
-
-
-
- save
-
-
-
- report
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ commentError }}
-
-
-
-
-
- Comment
-
-
-
-
-
-
-
-
- Sort by:
-
- Best
- New
- Old
- Controversial
-
-
-
-
- No comments yet. Be the first to comment!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Submission not found
-
- Go Back
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmissionList.vue b/src/modules/nostr-feed/components/SubmissionList.vue
deleted file mode 100644
index f4adea4..0000000
--- a/src/modules/nostr-feed/components/SubmissionList.vue
+++ /dev/null
@@ -1,235 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Loading submissions...
-
-
-
-
-
{{ error }}
-
- Try again
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Loading more...
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmissionRow.vue b/src/modules/nostr-feed/components/SubmissionRow.vue
deleted file mode 100644
index 3f21620..0000000
--- a/src/modules/nostr-feed/components/SubmissionRow.vue
+++ /dev/null
@@ -1,247 +0,0 @@
-
-
-
-
-
-
- {{ rank }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ submission.title }}
-
-
-
-
- ({{ domain }})
-
-
-
-
- ({{ postTypeLabel }})
-
-
-
-
-
-
-
-
-
- NSFW
-
-
- {{ submission.flair }}
-
-
-
-
-
- submitted {{ timeAgo }}
- by
- {{ authorName }}
-
- to
- {{ communityName }}
-
-
-
-
-
-
-
-
- {{ submission.commentCount }} comments
-
-
-
-
-
- share
-
-
-
-
-
- {{ submission.isSaved ? 'saved' : 'save' }}
-
-
-
-
-
- hide
-
-
-
-
-
- report
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmissionThumbnail.vue b/src/modules/nostr-feed/components/SubmissionThumbnail.vue
deleted file mode 100644
index 5735327..0000000
--- a/src/modules/nostr-feed/components/SubmissionThumbnail.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
-
-
-
-
- NSFW
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/SubmitComposer.vue b/src/modules/nostr-feed/components/SubmitComposer.vue
deleted file mode 100644
index fcfd6bf..0000000
--- a/src/modules/nostr-feed/components/SubmitComposer.vue
+++ /dev/null
@@ -1,406 +0,0 @@
-
-
-
-
-
Create Post
-
-
-
-
-
-
You must be logged in to create a post
-
-
-
-
-
-
-
- Text
-
-
-
- Link
-
-
-
- Image
-
-
-
-
-
-
diff --git a/src/modules/nostr-feed/components/VoteControls.vue b/src/modules/nostr-feed/components/VoteControls.vue
deleted file mode 100644
index 689cc9c..0000000
--- a/src/modules/nostr-feed/components/VoteControls.vue
+++ /dev/null
@@ -1,107 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ displayScore }}
-
-
-
-
-
-
-
-
diff --git a/src/modules/nostr-feed/composables/useSubmissions.ts b/src/modules/nostr-feed/composables/useSubmissions.ts
deleted file mode 100644
index 82dd155..0000000
--- a/src/modules/nostr-feed/composables/useSubmissions.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-/**
- * useSubmissions Composable
- *
- * Provides reactive access to the SubmissionService for Reddit-style submissions.
- */
-
-import { computed, ref, onMounted, onUnmounted, watch, type Ref, type ComputedRef } from 'vue'
-import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
-import type { SubmissionService } from '../services/SubmissionService'
-import type { LinkPreviewService } from '../services/LinkPreviewService'
-import type {
- SubmissionWithMeta,
- SubmissionForm,
- SubmissionFeedConfig,
- SubmissionComment,
- SortType,
- TimeRange,
- LinkPreview
-} from '../types/submission'
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface UseSubmissionsOptions {
- /** Auto-subscribe on mount */
- autoSubscribe?: boolean
- /** Feed configuration */
- config?: Partial
-}
-
-export interface UseSubmissionsReturn {
- // State
- submissions: ComputedRef
- isLoading: ComputedRef
- error: ComputedRef
-
- // Sorting
- currentSort: Ref
- currentTimeRange: Ref
-
- // Actions
- subscribe: (config?: Partial) => Promise
- unsubscribe: () => Promise
- refresh: () => Promise
- createSubmission: (form: SubmissionForm) => Promise
- upvote: (submissionId: string) => Promise
- downvote: (submissionId: string) => Promise
- setSort: (sort: SortType, timeRange?: TimeRange) => void
-
- // Getters
- getSubmission: (id: string) => SubmissionWithMeta | undefined
- getComments: (submissionId: string) => SubmissionComment[]
- getThreadedComments: (submissionId: string) => SubmissionComment[]
-
- // Link preview
- fetchLinkPreview: (url: string) => Promise
- isPreviewLoading: (url: string) => boolean
-}
-
-// ============================================================================
-// Composable
-// ============================================================================
-
-export function useSubmissions(options: UseSubmissionsOptions = {}): UseSubmissionsReturn {
- const {
- autoSubscribe = true,
- config: initialConfig = {}
- } = options
-
- // Inject services
- const submissionService = tryInjectService(SERVICE_TOKENS.SUBMISSION_SERVICE)
- const linkPreviewService = tryInjectService(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
-
- // Local state
- const currentSort = ref('hot')
- const currentTimeRange = ref('day')
-
- // Default feed config
- const defaultConfig: SubmissionFeedConfig = {
- sort: 'hot',
- timeRange: 'day',
- includeNsfw: false,
- limit: 50,
- ...initialConfig
- }
-
- // Computed values from service
- const submissions = computed(() => {
- if (!submissionService) return []
- return submissionService.getSortedSubmissions(currentSort.value)
- })
-
- const isLoading = computed(() => submissionService?.isLoading.value ?? false)
- const error = computed(() => submissionService?.error.value ?? null)
-
- // ============================================================================
- // Actions
- // ============================================================================
-
- /**
- * Subscribe to submissions feed
- */
- async function subscribe(config?: Partial): Promise {
- if (!submissionService) {
- console.warn('SubmissionService not available')
- return
- }
-
- const feedConfig: SubmissionFeedConfig = {
- ...defaultConfig,
- ...config,
- sort: currentSort.value,
- timeRange: currentTimeRange.value
- }
-
- await submissionService.subscribe(feedConfig)
- }
-
- /**
- * Unsubscribe from feed
- */
- async function unsubscribe(): Promise {
- await submissionService?.unsubscribe()
- }
-
- /**
- * Refresh the feed
- */
- async function refresh(): Promise {
- submissionService?.clear()
- await subscribe()
- }
-
- /**
- * Create a new submission
- */
- async function createSubmission(form: SubmissionForm): Promise {
- if (!submissionService) {
- throw new Error('SubmissionService not available')
- }
- return submissionService.createSubmission(form)
- }
-
- /**
- * Upvote a submission
- */
- async function upvote(submissionId: string): Promise {
- if (!submissionService) {
- throw new Error('SubmissionService not available')
- }
- await submissionService.upvote(submissionId)
- }
-
- /**
- * Downvote a submission
- */
- async function downvote(submissionId: string): Promise {
- if (!submissionService) {
- throw new Error('SubmissionService not available')
- }
- await submissionService.downvote(submissionId)
- }
-
- /**
- * Change sort order
- */
- function setSort(sort: SortType, timeRange?: TimeRange): void {
- currentSort.value = sort
- if (timeRange) {
- currentTimeRange.value = timeRange
- }
- }
-
- // ============================================================================
- // Getters
- // ============================================================================
-
- /**
- * Get a single submission by ID
- */
- function getSubmission(id: string): SubmissionWithMeta | undefined {
- return submissionService?.getSubmission(id)
- }
-
- /**
- * Get comments for a submission
- */
- function getComments(submissionId: string): SubmissionComment[] {
- return submissionService?.getComments(submissionId) ?? []
- }
-
- /**
- * Get threaded comments for a submission
- */
- function getThreadedComments(submissionId: string): SubmissionComment[] {
- return submissionService?.getThreadedComments(submissionId) ?? []
- }
-
- // ============================================================================
- // Link Preview
- // ============================================================================
-
- /**
- * Fetch link preview for a URL
- */
- async function fetchLinkPreview(url: string): Promise {
- if (!linkPreviewService) {
- return {
- url,
- domain: new URL(url).hostname.replace(/^www\./, '')
- }
- }
- return linkPreviewService.fetchPreview(url)
- }
-
- /**
- * Check if preview is loading
- */
- function isPreviewLoading(url: string): boolean {
- return linkPreviewService?.isLoading(url) ?? false
- }
-
- // ============================================================================
- // Lifecycle
- // ============================================================================
-
- // Watch for sort changes and re-sort
- watch([currentSort, currentTimeRange], async () => {
- // Re-subscribe with new sort if needed for time-based filtering
- if (currentSort.value === 'top') {
- await subscribe()
- }
- })
-
- // Auto-subscribe on mount
- onMounted(() => {
- if (autoSubscribe) {
- subscribe()
- }
- })
-
- // Cleanup on unmount
- onUnmounted(() => {
- unsubscribe()
- })
-
- // ============================================================================
- // Return
- // ============================================================================
-
- return {
- // State
- submissions,
- isLoading,
- error,
-
- // Sorting
- currentSort,
- currentTimeRange,
-
- // Actions
- subscribe,
- unsubscribe,
- refresh,
- createSubmission,
- upvote,
- downvote,
- setSort,
-
- // Getters
- getSubmission,
- getComments,
- getThreadedComments,
-
- // Link preview
- fetchLinkPreview,
- isPreviewLoading
- }
-}
-
-// ============================================================================
-// Single Submission Hook
-// ============================================================================
-
-/**
- * Hook for working with a single submission
- */
-export function useSubmission(submissionId: string) {
- const submissionService = tryInjectService(SERVICE_TOKENS.SUBMISSION_SERVICE)
-
- const isLoading = ref(false)
- const error = ref(null)
-
- const submission = computed(() => submissionService?.getSubmission(submissionId))
- const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? [])
-
- async function subscribe(): Promise {
- 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 {
- await submissionService?.upvote(submissionId)
- }
-
- async function downvote(): Promise {
- await submissionService?.downvote(submissionId)
- }
-
- // Subscribe on mount
- onMounted(() => {
- subscribe()
- })
-
- return {
- submission,
- comments,
- isLoading: computed(() => isLoading.value || submissionService?.isLoading.value || false),
- error: computed(() => error.value || submissionService?.error.value || null),
- subscribe,
- upvote,
- downvote
- }
-}
diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts
index af179fd..9967476 100644
--- a/src/modules/nostr-feed/index.ts
+++ b/src/modules/nostr-feed/index.ts
@@ -2,20 +2,10 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue'
-import SubmissionList from './components/SubmissionList.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 SortTabs from './components/SortTabs.vue'
import { useFeed } from './composables/useFeed'
-import { useSubmissions, useSubmission } from './composables/useSubmissions'
import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService'
-import { SubmissionService } from './services/SubmissionService'
-import { LinkPreviewService } from './services/LinkPreviewService'
/**
* Nostr Feed Module Plugin
@@ -26,27 +16,6 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0',
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) {
console.log('nostr-feed module: Starting installation...')
@@ -54,14 +23,10 @@ export const nostrFeedModule: ModulePlugin = {
const feedService = new FeedService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
- const submissionService = new SubmissionService()
- const linkPreviewService = new LinkPreviewService()
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
- container.provide(SERVICE_TOKENS.SUBMISSION_SERVICE, submissionService)
- container.provide(SERVICE_TOKENS.LINK_PREVIEW_SERVICE, linkPreviewService)
console.log('nostr-feed module: Services registered in DI container')
// Initialize services
@@ -78,39 +43,21 @@ export const nostrFeedModule: ModulePlugin = {
reactionService.initialize({
waitForDependencies: true,
maxRetries: 3
- }),
- submissionService.initialize({
- waitForDependencies: true,
- maxRetries: 3
- }),
- linkPreviewService.initialize({
- waitForDependencies: true,
- maxRetries: 3
})
])
console.log('nostr-feed module: Services initialized')
// Register components globally
app.component('NostrFeed', NostrFeed)
- app.component('SubmissionList', SubmissionList)
console.log('nostr-feed module: Installation complete')
},
components: {
- NostrFeed,
- SubmissionList,
- SubmissionRow,
- SubmissionDetail,
- SubmissionComment,
- SubmitComposer,
- VoteControls,
- SortTabs
+ NostrFeed
},
composables: {
- useFeed,
- useSubmissions,
- useSubmission
+ useFeed
}
}
diff --git a/src/modules/nostr-feed/services/LinkPreviewService.ts b/src/modules/nostr-feed/services/LinkPreviewService.ts
deleted file mode 100644
index e018aa8..0000000
--- a/src/modules/nostr-feed/services/LinkPreviewService.ts
+++ /dev/null
@@ -1,552 +0,0 @@
-/**
- * LinkPreviewService
- *
- * Fetches Open Graph and meta tags from URLs to generate link previews.
- * Used when creating link submissions to embed preview data.
- */
-
-import { reactive } from 'vue'
-import { BaseService } from '@/core/base/BaseService'
-import type { LinkPreview } from '../types/submission'
-import { extractDomain } from '../types/submission'
-
-// ============================================================================
-// Types
-// ============================================================================
-
-interface CacheEntry {
- preview: LinkPreview
- timestamp: number
-}
-
-// ============================================================================
-// Service Definition
-// ============================================================================
-
-export class LinkPreviewService extends BaseService {
- protected readonly metadata = {
- name: 'LinkPreviewService',
- version: '1.0.0',
- dependencies: []
- }
-
- // Cache for previews (URL -> preview)
- private cache = reactive(new Map())
-
- // Cache TTL (15 minutes)
- private readonly CACHE_TTL = 15 * 60 * 1000
-
- // Loading state per URL
- private _loading = reactive(new Map())
-
- // Error state per URL
- private _errors = reactive(new Map())
-
- // CORS proxy URL (configurable)
- private proxyUrl = ''
-
- // ============================================================================
- // Lifecycle
- // ============================================================================
-
- protected async onInitialize(): Promise {
- console.log('LinkPreviewService: Initializing...')
-
- // Try to get proxy URL from environment
- this.proxyUrl = import.meta.env.VITE_CORS_PROXY_URL || ''
-
- // Clean expired cache entries periodically
- setInterval(() => this.cleanCache(), this.CACHE_TTL)
-
- console.log('LinkPreviewService: Initialization complete')
- }
-
- protected async onDispose(): Promise {
- this.cache.clear()
- this._loading.clear()
- this._errors.clear()
- }
-
- // ============================================================================
- // Public API
- // ============================================================================
-
- /**
- * Fetch link preview for a URL
- */
- async fetchPreview(url: string): Promise {
- // Normalize URL
- const normalizedUrl = this.normalizeUrl(url)
-
- // Check cache
- const cached = this.getCachedPreview(normalizedUrl)
- if (cached) {
- return cached
- }
-
- // Check if already loading
- if (this._loading.get(normalizedUrl)) {
- // Wait for existing request
- return this.waitForPreview(normalizedUrl)
- }
-
- // Mark as loading
- this._loading.set(normalizedUrl, true)
- this._errors.delete(normalizedUrl)
-
- try {
- const preview = await this.doFetch(normalizedUrl)
-
- // Cache the result
- this.cache.set(normalizedUrl, {
- preview,
- timestamp: Date.now()
- })
-
- return preview
-
- } catch (error) {
- const message = error instanceof Error ? error.message : 'Failed to fetch preview'
- this._errors.set(normalizedUrl, message)
-
- // Return minimal preview on error
- return {
- url: normalizedUrl,
- domain: extractDomain(normalizedUrl)
- }
-
- } finally {
- this._loading.set(normalizedUrl, false)
- }
- }
-
- /**
- * Get cached preview if available and not expired
- */
- getCachedPreview(url: string): LinkPreview | null {
- const cached = this.cache.get(url)
- if (!cached) return null
-
- // Check if expired
- if (Date.now() - cached.timestamp > this.CACHE_TTL) {
- this.cache.delete(url)
- return null
- }
-
- return cached.preview
- }
-
- /**
- * Check if URL is currently loading
- */
- isLoading(url: string): boolean {
- return this._loading.get(url) || false
- }
-
- /**
- * Get error for URL
- */
- getError(url: string): string | null {
- return this._errors.get(url) || null
- }
-
- /**
- * Clear cache for a specific URL or all
- */
- clearCache(url?: string): void {
- if (url) {
- this.cache.delete(url)
- } else {
- this.cache.clear()
- }
- }
-
- // ============================================================================
- // Fetching
- // ============================================================================
-
- /**
- * Perform the actual fetch
- */
- private async doFetch(url: string): Promise {
- // Try different methods in order of preference
-
- // 1. Try direct fetch (works for same-origin or CORS-enabled sites)
- try {
- return await this.fetchDirect(url)
- } catch (directError) {
- this.debug('Direct fetch failed:', directError)
- }
-
- // 2. Try CORS proxy if configured
- if (this.proxyUrl) {
- try {
- return await this.fetchViaProxy(url)
- } catch (proxyError) {
- this.debug('Proxy fetch failed:', proxyError)
- }
- }
-
- // 3. Try oEmbed for supported sites
- try {
- return await this.fetchOembed(url)
- } catch (oembedError) {
- this.debug('oEmbed fetch failed:', oembedError)
- }
-
- // 4. Return basic preview with just the domain
- return {
- url,
- domain: extractDomain(url)
- }
- }
-
- /**
- * Direct fetch (may fail due to CORS)
- */
- private async fetchDirect(url: string): Promise {
- const response = await fetch(url, {
- method: 'GET',
- headers: {
- 'Accept': 'text/html'
- }
- })
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`)
- }
-
- const html = await response.text()
- return this.parseHtml(url, html)
- }
-
- /**
- * Fetch via CORS proxy
- */
- private async fetchViaProxy(url: string): Promise {
- const proxyUrl = `${this.proxyUrl}${encodeURIComponent(url)}`
-
- const response = await fetch(proxyUrl, {
- method: 'GET',
- headers: {
- 'Accept': 'text/html'
- }
- })
-
- if (!response.ok) {
- throw new Error(`Proxy HTTP ${response.status}`)
- }
-
- const html = await response.text()
- return this.parseHtml(url, html)
- }
-
- /**
- * Try oEmbed for supported providers
- */
- private async fetchOembed(url: string): Promise {
- // oEmbed providers and their endpoints
- const providers = [
- {
- pattern: /youtube\.com\/watch|youtu\.be/,
- endpoint: 'https://www.youtube.com/oembed'
- },
- {
- pattern: /twitter\.com|x\.com/,
- endpoint: 'https://publish.twitter.com/oembed'
- },
- {
- pattern: /vimeo\.com/,
- endpoint: 'https://vimeo.com/api/oembed.json'
- }
- ]
-
- const provider = providers.find(p => p.pattern.test(url))
- if (!provider) {
- throw new Error('No oEmbed provider for URL')
- }
-
- const oembedUrl = `${provider.endpoint}?url=${encodeURIComponent(url)}&format=json`
- const response = await fetch(oembedUrl)
-
- if (!response.ok) {
- throw new Error(`oEmbed HTTP ${response.status}`)
- }
-
- const data = await response.json()
-
- return {
- url,
- domain: extractDomain(url),
- title: data.title,
- description: data.description || data.author_name,
- image: data.thumbnail_url,
- siteName: data.provider_name,
- type: data.type,
- videoUrl: data.html?.includes('iframe') ? url : undefined
- }
- }
-
- // ============================================================================
- // HTML Parsing
- // ============================================================================
-
- /**
- * Parse HTML to extract Open Graph and meta tags
- */
- private parseHtml(url: string, html: string): LinkPreview {
- const preview: LinkPreview = {
- url,
- domain: extractDomain(url)
- }
-
- // Create a DOM parser
- const parser = new DOMParser()
- const doc = parser.parseFromString(html, 'text/html')
-
- // Extract Open Graph tags
- const ogTags = this.extractOgTags(doc)
-
- // Extract Twitter Card tags (fallback)
- const twitterTags = this.extractTwitterTags(doc)
-
- // Extract standard meta tags (fallback)
- const metaTags = this.extractMetaTags(doc)
-
- // Merge with priority: OG > Twitter > Meta > Title
- preview.title = ogTags.title || twitterTags.title || metaTags.title || this.extractTitle(doc)
- preview.description = ogTags.description || twitterTags.description || metaTags.description
- preview.image = ogTags.image || twitterTags.image
- preview.siteName = ogTags.siteName || twitterTags.site
- preview.type = ogTags.type
- preview.videoUrl = ogTags.video
- preview.favicon = this.extractFavicon(doc, url)
-
- return preview
- }
-
- /**
- * Extract Open Graph tags
- */
- private extractOgTags(doc: Document): Record {
- const tags: Record = {}
-
- const ogMetas = doc.querySelectorAll('meta[property^="og:"]')
- ogMetas.forEach(meta => {
- const property = meta.getAttribute('property')?.replace('og:', '')
- const content = meta.getAttribute('content')
- if (property && content) {
- switch (property) {
- case 'title':
- tags.title = content
- break
- case 'description':
- tags.description = content
- break
- case 'image':
- tags.image = content
- break
- case 'site_name':
- tags.siteName = content
- break
- case 'type':
- tags.type = content
- break
- case 'video':
- case 'video:url':
- tags.video = content
- break
- }
- }
- })
-
- return tags
- }
-
- /**
- * Extract Twitter Card tags
- */
- private extractTwitterTags(doc: Document): Record {
- const tags: Record = {}
-
- const twitterMetas = doc.querySelectorAll('meta[name^="twitter:"]')
- twitterMetas.forEach(meta => {
- const name = meta.getAttribute('name')?.replace('twitter:', '')
- const content = meta.getAttribute('content')
- if (name && content) {
- switch (name) {
- case 'title':
- tags.title = content
- break
- case 'description':
- tags.description = content
- break
- case 'image':
- case 'image:src':
- tags.image = content
- break
- case 'site':
- tags.site = content
- break
- }
- }
- })
-
- return tags
- }
-
- /**
- * Extract standard meta tags
- */
- private extractMetaTags(doc: Document): Record {
- const tags: Record = {}
-
- // Description
- const descMeta = doc.querySelector('meta[name="description"]')
- if (descMeta) {
- tags.description = descMeta.getAttribute('content') || undefined
- }
-
- // Title from meta
- const titleMeta = doc.querySelector('meta[name="title"]')
- if (titleMeta) {
- tags.title = titleMeta.getAttribute('content') || undefined
- }
-
- return tags
- }
-
- /**
- * Extract page title
- */
- private extractTitle(doc: Document): string | undefined {
- return doc.querySelector('title')?.textContent || undefined
- }
-
- /**
- * Extract favicon URL
- */
- private extractFavicon(doc: Document, pageUrl: string): string | undefined {
- // Try various link rel types
- const selectors = [
- 'link[rel="icon"]',
- 'link[rel="shortcut icon"]',
- 'link[rel="apple-touch-icon"]'
- ]
-
- for (const selector of selectors) {
- const link = doc.querySelector(selector)
- const href = link?.getAttribute('href')
- if (href) {
- return this.resolveUrl(href, pageUrl)
- }
- }
-
- // Default favicon location
- return this.resolveUrl('/favicon.ico', pageUrl)
- }
-
- // ============================================================================
- // Utilities
- // ============================================================================
-
- /**
- * Normalize URL
- */
- private normalizeUrl(url: string): string {
- let normalized = url.trim()
-
- // Add protocol if missing
- if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
- normalized = 'https://' + normalized
- }
-
- return normalized
- }
-
- /**
- * Resolve relative URL to absolute
- */
- private resolveUrl(href: string, base: string): string {
- try {
- return new URL(href, base).toString()
- } catch {
- return href
- }
- }
-
- /**
- * Wait for an in-flight preview request
- */
- private async waitForPreview(url: string): Promise {
- // Poll until loading is done
- while (this._loading.get(url)) {
- await new Promise(resolve => setTimeout(resolve, 100))
- }
-
- // Return cached result or error
- const cached = this.getCachedPreview(url)
- if (cached) return cached
-
- return {
- url,
- domain: extractDomain(url)
- }
- }
-
- /**
- * Clean expired cache entries
- */
- private cleanCache(): void {
- const now = Date.now()
-
- for (const [url, entry] of this.cache) {
- if (now - entry.timestamp > this.CACHE_TTL) {
- this.cache.delete(url)
- }
- }
- }
-
- // ============================================================================
- // Validation
- // ============================================================================
-
- /**
- * Check if URL is valid
- */
- isValidUrl(url: string): boolean {
- try {
- const parsed = new URL(this.normalizeUrl(url))
- return parsed.protocol === 'http:' || parsed.protocol === 'https:'
- } catch {
- return false
- }
- }
-
- /**
- * Check if URL is likely to be media
- */
- isMediaUrl(url: string): boolean {
- const mediaExtensions = [
- '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
- '.mp4', '.webm', '.mov', '.avi',
- '.mp3', '.wav', '.ogg', '.flac'
- ]
-
- const lowerUrl = url.toLowerCase()
- return mediaExtensions.some(ext => lowerUrl.includes(ext))
- }
-
- /**
- * Guess media type from URL
- */
- guessMediaType(url: string): 'image' | 'video' | 'audio' | 'other' {
- const lowerUrl = url.toLowerCase()
-
- if (/\.(jpg|jpeg|png|gif|webp|svg)/.test(lowerUrl)) return 'image'
- if (/\.(mp4|webm|mov|avi)/.test(lowerUrl)) return 'video'
- if (/\.(mp3|wav|ogg|flac)/.test(lowerUrl)) return 'audio'
-
- return 'other'
- }
-}
diff --git a/src/modules/nostr-feed/services/ReactionService.ts b/src/modules/nostr-feed/services/ReactionService.ts
index 24adfb7..09a7e2f 100644
--- a/src/modules/nostr-feed/services/ReactionService.ts
+++ b/src/modules/nostr-feed/services/ReactionService.ts
@@ -427,124 +427,6 @@ export class ReactionService extends BaseService {
}
}
- /**
- * Send a dislike reaction to an event
- */
- async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise {
- 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 {
- 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
*/
diff --git a/src/modules/nostr-feed/services/SubmissionService.ts b/src/modules/nostr-feed/services/SubmissionService.ts
deleted file mode 100644
index b3f5658..0000000
--- a/src/modules/nostr-feed/services/SubmissionService.ts
+++ /dev/null
@@ -1,1362 +0,0 @@
-/**
- * SubmissionService
- *
- * Handles Reddit-style submissions (link, media, self posts) using NIP-72 and NIP-22.
- * Submissions are kind 1111 events scoped to a community with structured metadata.
- */
-
-import { ref, reactive, computed } from 'vue'
-import { BaseService } from '@/core/base/BaseService'
-import { injectService, SERVICE_TOKENS } from '@/core/di-container'
-import { eventBus } from '@/core/event-bus'
-import { finalizeEvent, type EventTemplate } from 'nostr-tools'
-import type { Event as NostrEvent, Filter } from 'nostr-tools'
-
-import {
- SUBMISSION_KINDS,
- type Submission,
- type LinkSubmission,
- type MediaSubmission,
- type SelfSubmission,
- type SubmissionType,
- type SubmissionWithMeta,
- type SubmissionVotes,
- type SubmissionRanking,
- type SubmissionForm,
- type LinkSubmissionForm,
- type MediaSubmissionForm,
- type SelfSubmissionForm,
- type SubmissionFeedConfig,
- type SubmissionComment,
- type LinkPreview,
- type MediaAttachment,
- parseCommunityRef,
- formatCommunityRef,
- extractDomain,
- parseImetaTag,
- buildImetaTag,
- calculateHotRank,
- calculateControversyRank
-} from '../types/submission'
-
-// ============================================================================
-// Service Definition
-// ============================================================================
-
-export class SubmissionService extends BaseService {
- protected readonly metadata = {
- name: 'SubmissionService',
- version: '1.0.0',
- dependencies: ['RelayHub', 'AuthService']
- }
-
- // Injected services
- protected relayHub: any = null
- protected authService: any = null
- protected visibilityService: any = null
- protected reactionService: any = null
- protected linkPreviewService: any = null
-
- // State
- private _submissions = reactive(new Map())
- private _comments = reactive(new Map())
- private _isLoading = ref(false)
- private _error = ref(null)
-
- // Deduplication
- private seenEventIds = new Set()
-
- // Subscription management
- private currentSubscription: string | null = null
- private currentUnsubscribe: (() => void) | null = null
-
- // Public reactive state
- public readonly submissions = computed(() => Array.from(this._submissions.values()))
- public readonly isLoading = computed(() => this._isLoading.value)
- public readonly error = computed(() => this._error.value)
-
- // ============================================================================
- // Lifecycle
- // ============================================================================
-
- protected async onInitialize(): Promise {
- console.log('SubmissionService: Starting initialization...')
-
- this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
- this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
- this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
- this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
-
- if (!this.relayHub) {
- throw new Error('RelayHub service not available')
- }
-
- // Register with visibility service
- if (this.visibilityService) {
- this.visibilityService.registerService(
- 'SubmissionService',
- this.onResume.bind(this),
- this.onPause.bind(this)
- )
- }
-
- console.log('SubmissionService: Initialization complete')
- }
-
- private async onResume(): Promise {
- this.debug('App resumed')
- }
-
- private onPause(): void {
- this.debug('App paused')
- }
-
- protected async onDispose(): Promise {
- await this.unsubscribe()
- this._submissions.clear()
- this._comments.clear()
- this.seenEventIds.clear()
- }
-
- // ============================================================================
- // Subscription Management
- // ============================================================================
-
- /**
- * Subscribe to submissions based on feed configuration
- */
- async subscribe(config: SubmissionFeedConfig): Promise {
- await this.unsubscribe()
-
- this._isLoading.value = true
- this._error.value = null
-
- try {
- if (!this.relayHub?.isConnected) {
- await this.relayHub?.connect()
- }
-
- const subscriptionId = `submissions-${Date.now()}`
- const filters = this.buildFilters(config)
-
- this.debug('Subscribing with filters:', filters)
-
- const unsubscribe = this.relayHub.subscribe({
- id: subscriptionId,
- filters,
- onEvent: (event: NostrEvent) => this.handleEvent(event),
- onEose: () => {
- this.debug('End of stored events')
- // Refresh all submission votes from loaded reactions
- this.refreshAllSubmissionVotes()
- this._isLoading.value = false
- },
- onClose: () => {
- this.debug('Subscription closed')
- }
- })
-
- 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'
- this._isLoading.value = false
- throw err
- }
- }
-
- /**
- * Unsubscribe from current feed
- */
- async unsubscribe(): Promise {
- if (this.currentUnsubscribe) {
- this.currentUnsubscribe()
- this.currentSubscription = null
- this.currentUnsubscribe = null
- }
- }
-
- /**
- * Subscribe to a specific submission and its comments
- */
- async subscribeToSubmission(submissionId: string): Promise {
- 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
- */
- private buildFilters(config: SubmissionFeedConfig): Filter[] {
- const filters: Filter[] = []
-
- // Main submissions filter
- const submissionFilter: Filter = {
- kinds: [SUBMISSION_KINDS.SUBMISSION],
- limit: config.limit
- }
-
- // Community filter
- if (config.community) {
- submissionFilter['#a'] = [formatCommunityRef(config.community)]
- }
-
- // Author filter
- if (config.authors?.length) {
- submissionFilter.authors = config.authors
- }
-
- // Hashtag filter
- if (config.hashtags?.length) {
- submissionFilter['#t'] = config.hashtags
- }
-
- // Time range for "top" sort
- if (config.sort === 'top' && config.timeRange) {
- const now = Math.floor(Date.now() / 1000)
- const ranges: Record = {
- hour: 3600,
- day: 86400,
- week: 604800,
- month: 2592000,
- year: 31536000,
- all: 0
- }
- if (ranges[config.timeRange] > 0) {
- submissionFilter.since = now - ranges[config.timeRange]
- }
- }
-
- filters.push(submissionFilter)
-
- // Also subscribe to reactions for these submissions
- filters.push({
- kinds: [SUBMISSION_KINDS.REACTION],
- limit: 1000
- })
-
- // And deletions
- filters.push({
- kinds: [SUBMISSION_KINDS.DELETION]
- })
-
- return filters
- }
-
- // ============================================================================
- // Event Handling
- // ============================================================================
-
- /**
- * Handle incoming Nostr event
- */
- private handleEvent(event: NostrEvent): void {
- switch (event.kind) {
- case SUBMISSION_KINDS.SUBMISSION:
- this.handleSubmissionEvent(event)
- break
- case SUBMISSION_KINDS.REACTION:
- // Route to reaction service
- this.reactionService?.handleReactionEvent(event)
- break
- case SUBMISSION_KINDS.DELETION:
- this.handleDeletionEvent(event)
- break
- }
- }
-
- /**
- * Handle kind 1111 submission event
- */
- private handleSubmissionEvent(event: NostrEvent): void {
- // Deduplication
- if (this.seenEventIds.has(event.id)) {
- return
- }
- 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
- const submission = this.parseSubmission(event)
- if (!submission) {
- this.debug('Failed to parse submission:', event.id)
- return
- }
-
- // Create full submission with metadata
- const submissionWithMeta = this.enrichSubmission(submission)
- this._submissions.set(submission.id, submissionWithMeta)
-
- // Emit event
- eventBus.emit('submission:new', { submission: submissionWithMeta }, 'nostr-feed')
- }
-
- /**
- * Check if event is a comment (has parent that's not the community)
- */
- private isComment(event: NostrEvent): boolean {
- const tags = event.tags || []
-
- // Get root scope (A tag) and parent (a/e tag)
- const rootTag = tags.find(t => t[0] === 'A')
- const parentETag = tags.find(t => t[0] === 'e')
- const parentATag = tags.find(t => t[0] === 'a' && t[1] !== rootTag?.[1])
-
- // If parent e-tag exists and points to a different event, it's a comment
- if (parentETag) {
- return true
- }
-
- // If parent a-tag differs from root A-tag, it's a comment
- if (parentATag && rootTag && parentATag[1] !== rootTag[1]) {
- return true
- }
-
- return false
- }
-
- /**
- * Handle comment on a submission
- */
- private handleCommentEvent(event: NostrEvent): void {
- // Find the root submission ID
- const rootTag = event.tags.find(t => t[0] === 'E') || event.tags.find(t => t[0] === 'e')
- if (!rootTag) return
-
- 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
- const comment: SubmissionComment = {
- id: event.id,
- pubkey: event.pubkey,
- created_at: event.created_at,
- content: event.content,
- rootId,
- parentId: this.getParentId(event) || rootId,
- depth: 0,
- replies: [],
- votes: this.getCommentVotes(event.id)
- }
-
- // Add to comments map
- if (!this._comments.has(rootId)) {
- this._comments.set(rootId, [])
- }
- 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
- }
- }
-
- /**
- * Get parent event ID from tags
- */
- private getParentId(event: NostrEvent): string | null {
- const eTag = event.tags.find(t => t[0] === 'e')
- return eTag ? eTag[1] : null
- }
-
- /**
- * Handle deletion event
- */
- private handleDeletionEvent(event: NostrEvent): void {
- const kTag = event.tags.find(t => t[0] === 'k')
- const deletedKind = kTag?.[1]
-
- // Route reaction deletions to reaction service
- if (deletedKind === '7') {
- this.reactionService?.handleDeletionEvent(event)
- return
- }
-
- // Handle submission/comment deletions
- if (deletedKind === '1111') {
- const eTags = event.tags.filter(t => t[0] === 'e')
- for (const eTag of eTags) {
- const eventId = eTag[1]
- const submission = this._submissions.get(eventId)
-
- // Only delete if from the same author
- if (submission && submission.pubkey === event.pubkey) {
- this._submissions.delete(eventId)
- this.seenEventIds.delete(eventId)
- }
- }
- }
- }
-
- // ============================================================================
- // Parsing
- // ============================================================================
-
- /**
- * Parse a Nostr event into a Submission
- */
- private parseSubmission(event: NostrEvent): Submission | null {
- const tags = event.tags || []
-
- // Extract required fields
- const titleTag = tags.find(t => t[0] === 'title')
- const postTypeTag = tags.find(t => t[0] === 'post-type')
-
- // Title is required
- const title = titleTag?.[1]
- if (!title) {
- this.debug('Submission missing title:', event.id)
- return null
- }
-
- // Determine post type
- const postType = (postTypeTag?.[1] as SubmissionType) || this.inferPostType(event)
-
- // Extract common fields
- const base = {
- id: event.id,
- pubkey: event.pubkey,
- created_at: event.created_at,
- kind: SUBMISSION_KINDS.SUBMISSION,
- tags,
- title,
- communityRef: this.extractCommunityRef(tags),
- hashtags: this.extractHashtags(tags),
- nsfw: tags.some(t => t[0] === 'nsfw' || (t[0] === 'content-warning')),
- flair: tags.find(t => t[0] === 'flair')?.[1]
- }
-
- // Build type-specific submission
- switch (postType) {
- case 'link':
- return this.parseLinkSubmission(event, base)
- case 'media':
- return this.parseMediaSubmission(event, base)
- case 'self':
- default:
- return this.parseSelfSubmission(event, base)
- }
- }
-
- /**
- * Infer post type from event content/tags
- */
- private inferPostType(event: NostrEvent): SubmissionType {
- const tags = event.tags || []
-
- // Check for URL tag
- const urlTag = tags.find(t => t[0] === 'r')
- if (urlTag) return 'link'
-
- // Check for media (imeta tag)
- const imetaTag = tags.find(t => t[0] === 'imeta')
- if (imetaTag) return 'media'
-
- // Check for URL in content
- const urlRegex = /https?:\/\/[^\s]+/
- if (urlRegex.test(event.content)) {
- // Check if it's a media URL
- const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm', '.mov']
- const match = event.content.match(urlRegex)
- if (match && mediaExtensions.some(ext => match[0].toLowerCase().includes(ext))) {
- return 'media'
- }
- return 'link'
- }
-
- return 'self'
- }
-
- /**
- * Parse link submission
- */
- private parseLinkSubmission(
- event: NostrEvent,
- base: Omit
- ): LinkSubmission {
- const tags = event.tags || []
-
- // Get URL from 'r' tag or content
- const urlTag = tags.find(t => t[0] === 'r')
- let url = urlTag?.[1] || ''
-
- if (!url) {
- const urlMatch = event.content.match(/https?:\/\/[^\s]+/)
- url = urlMatch?.[0] || ''
- }
-
- // Build preview from tags
- const preview: LinkPreview = {
- url,
- domain: extractDomain(url),
- title: tags.find(t => t[0] === 'preview-title')?.[1],
- description: tags.find(t => t[0] === 'preview-description')?.[1],
- image: tags.find(t => t[0] === 'preview-image')?.[1],
- siteName: tags.find(t => t[0] === 'preview-site-name')?.[1]
- }
-
- return {
- ...base,
- postType: 'link',
- url,
- preview,
- body: event.content
- }
- }
-
- /**
- * Parse media submission
- */
- private parseMediaSubmission(
- event: NostrEvent,
- base: Omit
- ): MediaSubmission {
- const tags = event.tags || []
-
- // Parse imeta tags
- const imetaTags = tags.filter(t => t[0] === 'imeta')
- const mediaAttachments = imetaTags
- .map(t => parseImetaTag(t))
- .filter((m): m is MediaAttachment => m !== null)
-
- // If no imeta, try to extract from content
- if (mediaAttachments.length === 0) {
- const urlMatch = event.content.match(/https?:\/\/[^\s]+/)
- if (urlMatch) {
- mediaAttachments.push({ url: urlMatch[0] })
- }
- }
-
- const [primary, ...gallery] = mediaAttachments
-
- return {
- ...base,
- postType: 'media',
- media: primary || { url: '' },
- gallery: gallery.length > 0 ? gallery : undefined,
- body: event.content
- }
- }
-
- /**
- * Parse self/text submission
- */
- private parseSelfSubmission(
- event: NostrEvent,
- base: Omit
- ): SelfSubmission {
- return {
- ...base,
- postType: 'self',
- body: event.content
- }
- }
-
- /**
- * Extract community reference from tags
- */
- private extractCommunityRef(tags: string[][]): string | undefined {
- const aTag = tags.find(t => t[0] === 'A' || t[0] === 'a')
- return aTag?.[1]
- }
-
- /**
- * Extract hashtags from tags
- */
- private extractHashtags(tags: string[][]): string[] {
- return tags.filter(t => t[0] === 't').map(t => t[1])
- }
-
- // ============================================================================
- // Enrichment
- // ============================================================================
-
- /**
- * Enrich submission with votes, ranking, etc.
- */
- private enrichSubmission(submission: Submission): SubmissionWithMeta {
- const votes = this.getSubmissionVotes(submission.id)
- const ranking = this.calculateRanking(submission, votes)
-
- return {
- ...submission,
- votes,
- ranking,
- commentCount: this.getCommentCount(submission.id),
- isSaved: false, // TODO: implement saved posts
- isHidden: false,
- approvalStatus: null
- }
- }
-
- /**
- * Get votes for a submission
- */
- private getSubmissionVotes(submissionId: string): SubmissionVotes {
- // Get from reaction service if available
- if (this.reactionService) {
- const reactions = this.reactionService.getEventReactions(submissionId)
- 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()
- }
-
- /**
- * Get default votes object
- */
- private getDefaultVotes(): SubmissionVotes {
- return {
- upvotes: 0,
- downvotes: 0,
- score: 0,
- userVote: null
- }
- }
-
- /**
- * 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
- */
- private calculateRanking(submission: Submission, votes: SubmissionVotes): SubmissionRanking {
- return {
- hotRank: calculateHotRank(votes.score, submission.created_at),
- controversyRank: calculateControversyRank(votes.upvotes, votes.downvotes),
- scaledRank: 0 // TODO: implement community scaling
- }
- }
-
- /**
- * Get comment count for a submission
- */
- private getCommentCount(submissionId: string): number {
- return this._comments.get(submissionId)?.length || 0
- }
-
- // ============================================================================
- // Submission Creation
- // ============================================================================
-
- /**
- * Create a new submission
- */
- async createSubmission(form: SubmissionForm): Promise {
- 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')
- }
-
- try {
- this._isLoading.value = true
-
- // Build event
- const eventTemplate = await this.buildSubmissionEvent(form)
-
- // Sign
- const privkeyBytes = this.hexToUint8Array(userPrivkey)
- const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
-
- // Publish
- await this.relayHub.publishEvent(signedEvent)
-
- // Handle locally
- this.handleSubmissionEvent(signedEvent)
-
- return signedEvent.id
-
- } finally {
- this._isLoading.value = false
- }
- }
-
- /**
- * Build event template from submission form
- */
- private async buildSubmissionEvent(form: SubmissionForm): Promise {
- const tags: string[][] = []
-
- // Title (required)
- tags.push(['title', form.title])
-
- // Post type
- tags.push(['post-type', form.postType])
-
- // Community scope (if provided)
- if (form.communityRef) {
- const ref = parseCommunityRef(form.communityRef)
- if (ref) {
- tags.push(['A', form.communityRef])
- tags.push(['a', form.communityRef])
- tags.push(['K', '34550'])
- tags.push(['k', '34550'])
- tags.push(['P', ref.pubkey])
- tags.push(['p', ref.pubkey])
- }
- }
-
- // NSFW
- if (form.nsfw) {
- tags.push(['nsfw', 'true'])
- }
-
- // Flair
- if (form.flair) {
- tags.push(['flair', form.flair])
- }
-
- let content = ''
-
- // Type-specific fields
- switch (form.postType) {
- case 'link': {
- const linkForm = form as LinkSubmissionForm
- tags.push(['r', linkForm.url])
-
- // Fetch and add preview if available
- if (this.linkPreviewService) {
- try {
- const preview = await this.linkPreviewService.fetchPreview(linkForm.url)
- if (preview.title) tags.push(['preview-title', preview.title])
- if (preview.description) tags.push(['preview-description', preview.description])
- if (preview.image) tags.push(['preview-image', preview.image])
- if (preview.siteName) tags.push(['preview-site-name', preview.siteName])
- } catch (err) {
- this.debug('Failed to fetch link preview:', err)
- }
- }
-
- content = linkForm.body || ''
- break
- }
-
- case 'media': {
- const mediaForm = form as MediaSubmissionForm
-
- // Handle file upload or URL
- let mediaUrl: string
- if (typeof mediaForm.media === 'string') {
- mediaUrl = mediaForm.media
- } else {
- // TODO: Upload file and get URL
- throw new Error('File upload not yet implemented')
- }
-
- // Add imeta tag
- const imetaTag = buildImetaTag({
- url: mediaUrl,
- alt: mediaForm.alt
- })
- tags.push(imetaTag)
-
- content = mediaForm.body || ''
- break
- }
-
- case 'self': {
- const selfForm = form as SelfSubmissionForm
- content = selfForm.body
- break
- }
- }
-
- return {
- kind: SUBMISSION_KINDS.SUBMISSION,
- content,
- tags,
- created_at: Math.floor(Date.now() / 1000)
- }
- }
-
- // ============================================================================
- // Voting
- // ============================================================================
-
- /**
- * Upvote a submission
- */
- async upvote(submissionId: string): Promise {
- const submission = this._submissions.get(submissionId)
- if (!submission) throw new Error('Submission not found')
-
- if (submission.votes.userVote === 'upvote') {
- // Remove upvote
- await this.reactionService?.unlikeEvent(submissionId)
- } else {
- // Add upvote (reaction service handles removing existing downvote)
- await this.reactionService?.likeEvent(
- submissionId,
- submission.pubkey,
- SUBMISSION_KINDS.SUBMISSION
- )
- }
-
- // Refresh votes
- this.refreshSubmissionVotes(submissionId)
- }
-
- /**
- * Downvote a submission
- */
- async downvote(submissionId: string): Promise {
- const submission = this._submissions.get(submissionId)
- if (!submission) throw new Error('Submission not found')
-
- 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)
- }
-
- /**
- * Refresh votes for a submission
- */
- private refreshSubmissionVotes(submissionId: string): void {
- const submission = this._submissions.get(submissionId)
- if (!submission) return
-
- submission.votes = this.getSubmissionVotes(submissionId)
- 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 {
- 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 {
- 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
- // ============================================================================
-
- /**
- * Get sorted submissions
- */
- getSortedSubmissions(
- sort: 'hot' | 'new' | 'top' | 'controversial' = 'hot'
- ): SubmissionWithMeta[] {
- const submissions = Array.from(this._submissions.values())
-
- switch (sort) {
- case 'hot':
- return submissions.sort((a, b) => b.ranking.hotRank - a.ranking.hotRank)
- case 'new':
- return submissions.sort((a, b) => b.created_at - a.created_at)
- case 'top':
- return submissions.sort((a, b) => b.votes.score - a.votes.score)
- case 'controversial':
- return submissions.sort((a, b) => b.ranking.controversyRank - a.ranking.controversyRank)
- default:
- return submissions
- }
- }
-
- // ============================================================================
- // Comments
- // ============================================================================
-
- /**
- * Get comments for a submission
- */
- getComments(submissionId: string): SubmissionComment[] {
- return this._comments.get(submissionId) || []
- }
-
- /**
- * Get threaded comments
- */
- getThreadedComments(submissionId: string): SubmissionComment[] {
- const comments = this.getComments(submissionId)
- return this.buildCommentTree(comments)
- }
-
- /**
- * Build comment tree from flat list
- */
- private buildCommentTree(comments: SubmissionComment[]): SubmissionComment[] {
- const commentMap = new Map()
- const rootComments: SubmissionComment[] = []
-
- // Create map
- comments.forEach(comment => {
- commentMap.set(comment.id, { ...comment, replies: [], depth: 0 })
- })
-
- // Build tree
- comments.forEach(comment => {
- const current = commentMap.get(comment.id)!
-
- if (comment.parentId === comment.rootId) {
- // Top-level comment
- rootComments.push(current)
- } else {
- // Nested reply
- const parent = commentMap.get(comment.parentId)
- if (parent) {
- current.depth = parent.depth + 1
- parent.replies.push(current)
- } else {
- // Parent not found, treat as root
- rootComments.push(current)
- }
- }
- })
-
- // Sort by score
- const sortByScore = (a: SubmissionComment, b: SubmissionComment) =>
- b.votes.score - a.votes.score
-
- rootComments.sort(sortByScore)
- rootComments.forEach(comment => this.sortReplies(comment, sortByScore))
-
- return rootComments
- }
-
- /**
- * Recursively sort replies
- */
- private sortReplies(
- comment: SubmissionComment,
- compareFn: (a: SubmissionComment, b: SubmissionComment) => number
- ): void {
- if (comment.replies.length > 0) {
- comment.replies.sort(compareFn)
- comment.replies.forEach(reply => this.sortReplies(reply, compareFn))
- }
- }
-
- /**
- * 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 {
- 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 {
- 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((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
- // ============================================================================
-
- /**
- * Convert hex string to Uint8Array
- */
- private hexToUint8Array(hex: string): Uint8Array {
- const bytes = new Uint8Array(hex.length / 2)
- for (let i = 0; i < hex.length; i += 2) {
- bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
- }
- return bytes
- }
-
- /**
- * Get a single submission by ID
- */
- getSubmission(id: string): SubmissionWithMeta | undefined {
- return this._submissions.get(id)
- }
-
- /**
- * Clear all data
- */
- clear(): void {
- this._submissions.clear()
- this._comments.clear()
- this.seenEventIds.clear()
- this._error.value = null
- }
-}
diff --git a/src/modules/nostr-feed/types/index.ts b/src/modules/nostr-feed/types/index.ts
deleted file mode 100644
index e6b40a8..0000000
--- a/src/modules/nostr-feed/types/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/**
- * Types index - re-export all types from the module
- */
-
-export * from './submission'
diff --git a/src/modules/nostr-feed/types/submission.ts b/src/modules/nostr-feed/types/submission.ts
deleted file mode 100644
index ae7e0ad..0000000
--- a/src/modules/nostr-feed/types/submission.ts
+++ /dev/null
@@ -1,528 +0,0 @@
-/**
- * Link Aggregator Types
- *
- * Implements Reddit-style submissions using NIP-72 (Communities) and NIP-22 (Comments).
- * Submissions are kind 1111 events scoped to a community with structured metadata.
- */
-
-
-// ============================================================================
-// Constants
-// ============================================================================
-
-/** Nostr event kinds used by the link aggregator */
-export const SUBMISSION_KINDS = {
- /** Community definition (NIP-72) */
- COMMUNITY: 34550,
- /** Submission/comment (NIP-22) */
- SUBMISSION: 1111,
- /** Moderator approval (NIP-72) */
- APPROVAL: 4550,
- /** Reaction/vote (NIP-25) */
- REACTION: 7,
- /** Deletion (NIP-09) */
- DELETION: 5,
- /** File metadata (NIP-94) - for media references */
- FILE_METADATA: 1063
-} as const
-
-/** Submission post types */
-export type SubmissionType = 'link' | 'media' | 'self'
-
-/** Vote types for reactions */
-export type VoteType = 'upvote' | 'downvote' | null
-
-/** Feed sort options */
-export type SortType = 'hot' | 'new' | 'top' | 'controversial'
-
-/** Time range for "top" sorting */
-export type TimeRange = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'
-
-// ============================================================================
-// Link Preview Types
-// ============================================================================
-
-/** Open Graph metadata extracted from a URL */
-export interface LinkPreview {
- /** The original URL */
- url: string
- /** og:title or page title */
- title?: string
- /** og:description or meta description */
- description?: string
- /** og:image URL */
- image?: string
- /** og:site_name */
- siteName?: string
- /** og:type (article, video, etc.) */
- type?: string
- /** og:video for video embeds */
- videoUrl?: string
- /** Favicon URL */
- favicon?: string
- /** Domain extracted from URL */
- domain: string
-}
-
-// ============================================================================
-// Media Types (NIP-92 / NIP-94)
-// ============================================================================
-
-/** Media attachment metadata from imeta tag */
-export interface MediaAttachment {
- /** Media URL */
- url: string
- /** MIME type (e.g., "image/jpeg", "video/mp4") */
- mimeType?: string
- /** Dimensions in "WxH" format */
- dimensions?: string
- /** Width in pixels */
- width?: number
- /** Height in pixels */
- height?: number
- /** Blurhash for placeholder */
- blurhash?: string
- /** Alt text for accessibility */
- alt?: string
- /** SHA-256 hash of the file */
- hash?: string
- /** File size in bytes */
- size?: number
- /** Thumbnail URL */
- thumbnail?: string
- /** Fallback URLs */
- fallbacks?: string[]
-}
-
-/** Media type classification */
-export type MediaType = 'image' | 'video' | 'audio' | 'other'
-
-// ============================================================================
-// Submission Types
-// ============================================================================
-
-/** Base submission data shared by all post types */
-export interface SubmissionBase {
- /** Nostr event ID */
- id: string
- /** Author public key */
- pubkey: string
- /** Unix timestamp (seconds) */
- created_at: number
- /** Event kind (1111) */
- kind: typeof SUBMISSION_KINDS.SUBMISSION
- /** Raw event tags */
- tags: string[][]
- /** Submission title (required) */
- title: string
- /** Post type discriminator */
- postType: SubmissionType
- /** Community reference (a-tag format) */
- communityRef?: string
- /** Hashtags/topics */
- hashtags: string[]
- /** Whether marked NSFW */
- nsfw: boolean
- /** Flair/label for the post */
- flair?: string
-}
-
-/** Link submission with URL and preview */
-export interface LinkSubmission extends SubmissionBase {
- postType: 'link'
- /** External URL */
- url: string
- /** Link preview metadata */
- preview?: LinkPreview
- /** Optional body/description */
- body?: string
-}
-
-/** Media submission with attachments */
-export interface MediaSubmission extends SubmissionBase {
- postType: 'media'
- /** Primary media attachment */
- media: MediaAttachment
- /** Additional media attachments (gallery) */
- gallery?: MediaAttachment[]
- /** Caption/description */
- body?: string
-}
-
-/** Self/text submission */
-export interface SelfSubmission extends SubmissionBase {
- postType: 'self'
- /** Markdown body content */
- body: string
-}
-
-/** Union type for all submission types */
-export type Submission = LinkSubmission | MediaSubmission | SelfSubmission
-
-// ============================================================================
-// Voting & Scoring
-// ============================================================================
-
-/** Vote counts and user state for a submission */
-export interface SubmissionVotes {
- /** Total upvotes */
- upvotes: number
- /** Total downvotes */
- downvotes: number
- /** Net score (upvotes - downvotes) */
- score: number
- /** Current user's vote */
- userVote: VoteType
- /** User's vote event ID (for deletion) */
- userVoteId?: string
-}
-
-/** Ranking scores for sorting */
-export interface SubmissionRanking {
- /** Hot rank score (activity + recency) */
- hotRank: number
- /** Controversy rank (balanced voting) */
- controversyRank: number
- /** Scaled rank (amplifies smaller communities) */
- scaledRank: number
-}
-
-// ============================================================================
-// Full Submission with Metadata
-// ============================================================================
-
-/** Complete submission with all associated data */
-export type SubmissionWithMeta = Submission & {
- /** Vote counts and user state */
- votes: SubmissionVotes
- /** Ranking scores */
- ranking: SubmissionRanking
- /** Total comment count */
- commentCount: number
- /** Whether the submission is saved by current user */
- isSaved: boolean
- /** Whether hidden by current user */
- isHidden: boolean
- /** Approval status in moderated community */
- approvalStatus: 'pending' | 'approved' | 'rejected' | null
-}
-
-// ============================================================================
-// Comments
-// ============================================================================
-
-/** Comment on a submission (also kind 1111) */
-export interface SubmissionComment {
- /** Nostr event ID */
- id: string
- /** Author public key */
- pubkey: string
- /** Unix timestamp */
- created_at: number
- /** Comment text content */
- content: string
- /** Root submission ID */
- rootId: string
- /** Direct parent ID (submission or comment) */
- parentId: string
- /** Depth in comment tree (0 = top-level) */
- depth: number
- /** Child comments */
- replies: SubmissionComment[]
- /** Vote data */
- votes: SubmissionVotes
- /** Whether collapsed in UI */
- isCollapsed?: boolean
-}
-
-// ============================================================================
-// Community Types (NIP-72)
-// ============================================================================
-
-/** Community moderator */
-export interface CommunityModerator {
- pubkey: string
- relay?: string
- role: 'moderator' | 'admin'
-}
-
-/** Community definition (kind 34550) */
-export interface Community {
- /** Unique identifier (d-tag) */
- id: string
- /** Creator public key */
- pubkey: string
- /** Display name */
- name: string
- /** Description/about */
- description?: string
- /** Banner/header image URL */
- image?: string
- /** Icon/avatar URL */
- icon?: string
- /** List of moderators */
- moderators: CommunityModerator[]
- /** Rules (markdown) */
- rules?: string
- /** Preferred relays */
- relays: {
- author?: string
- requests?: string
- approvals?: string
- }
- /** Tags/topics */
- tags: string[]
- /** Whether posts require approval */
- requiresApproval: boolean
- /** Creation timestamp */
- created_at: number
-}
-
-/** Community reference (a-tag format) */
-export interface CommunityRef {
- /** "34550" */
- kind: string
- /** Community creator pubkey */
- pubkey: string
- /** Community d-tag identifier */
- identifier: string
- /** Relay hint */
- relay?: string
-}
-
-// ============================================================================
-// Form Types (for creating/editing)
-// ============================================================================
-
-/** Form data for creating a link submission */
-export interface LinkSubmissionForm {
- postType: 'link'
- title: string
- url: string
- body?: string
- communityRef?: string
- nsfw?: boolean
- flair?: string
-}
-
-/** Form data for creating a media submission */
-export interface MediaSubmissionForm {
- postType: 'media'
- title: string
- /** File to upload, or URL if already uploaded */
- media: File | string
- body?: string
- alt?: string
- communityRef?: string
- nsfw?: boolean
- flair?: string
-}
-
-/** Form data for creating a self/text submission */
-export interface SelfSubmissionForm {
- postType: 'self'
- title: string
- body: string
- communityRef?: string
- nsfw?: boolean
- flair?: string
-}
-
-/** Union type for submission forms */
-export type SubmissionForm = LinkSubmissionForm | MediaSubmissionForm | SelfSubmissionForm
-
-// ============================================================================
-// Feed Configuration
-// ============================================================================
-
-/** Configuration for fetching submissions */
-export interface SubmissionFeedConfig {
- /** Community to filter by (optional, null = all) */
- community?: CommunityRef | null
- /** Sort order */
- sort: SortType
- /** Time range for "top" sort */
- timeRange?: TimeRange
- /** Filter by post type */
- postTypes?: SubmissionType[]
- /** Include NSFW content */
- includeNsfw: boolean
- /** Maximum submissions to fetch */
- limit: number
- /** Author filter */
- authors?: string[]
- /** Hashtag filter */
- hashtags?: string[]
-}
-
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-/**
- * Parse an 'a' tag into a CommunityRef
- * Format: "34550::"
- */
-export function parseCommunityRef(aTag: string, relay?: string): CommunityRef | null {
- const parts = aTag.split(':')
- if (parts.length < 3 || parts[0] !== '34550') {
- return null
- }
- return {
- kind: parts[0],
- pubkey: parts[1],
- identifier: parts.slice(2).join(':'), // identifier may contain colons
- relay
- }
-}
-
-/**
- * Format a CommunityRef back to 'a' tag format
- */
-export function formatCommunityRef(ref: CommunityRef): string {
- return `${ref.kind}:${ref.pubkey}:${ref.identifier}`
-}
-
-/**
- * Extract domain from URL
- */
-export function extractDomain(url: string): string {
- try {
- const parsed = new URL(url)
- return parsed.hostname.replace(/^www\./, '')
- } catch {
- return url
- }
-}
-
-/**
- * Classify media type from MIME type
- */
-export function classifyMediaType(mimeType?: string): MediaType {
- if (!mimeType) return 'other'
- if (mimeType.startsWith('image/')) return 'image'
- if (mimeType.startsWith('video/')) return 'video'
- if (mimeType.startsWith('audio/')) return 'audio'
- return 'other'
-}
-
-/**
- * Parse imeta tag into MediaAttachment
- * Format: ["imeta", "url ", "m ", "dim ", ...]
- */
-export function parseImetaTag(tag: string[]): MediaAttachment | null {
- if (tag[0] !== 'imeta') return null
-
- const attachment: MediaAttachment = { url: '' }
-
- for (let i = 1; i < tag.length; i++) {
- const [key, ...valueParts] = tag[i].split(' ')
- const value = valueParts.join(' ')
-
- switch (key) {
- case 'url':
- attachment.url = value
- break
- case 'm':
- attachment.mimeType = value
- break
- case 'dim':
- attachment.dimensions = value
- const [w, h] = value.split('x').map(Number)
- if (!isNaN(w)) attachment.width = w
- if (!isNaN(h)) attachment.height = h
- break
- case 'blurhash':
- attachment.blurhash = value
- break
- case 'alt':
- attachment.alt = value
- break
- case 'x':
- attachment.hash = value
- break
- case 'size':
- attachment.size = parseInt(value, 10)
- break
- case 'thumb':
- attachment.thumbnail = value
- break
- case 'fallback':
- attachment.fallbacks = attachment.fallbacks || []
- attachment.fallbacks.push(value)
- break
- }
- }
-
- return attachment.url ? attachment : null
-}
-
-/**
- * Build imeta tag from MediaAttachment
- */
-export function buildImetaTag(media: MediaAttachment): string[] {
- const tag = ['imeta']
-
- tag.push(`url ${media.url}`)
- if (media.mimeType) tag.push(`m ${media.mimeType}`)
- if (media.dimensions) tag.push(`dim ${media.dimensions}`)
- else if (media.width && media.height) tag.push(`dim ${media.width}x${media.height}`)
- if (media.blurhash) tag.push(`blurhash ${media.blurhash}`)
- if (media.alt) tag.push(`alt ${media.alt}`)
- if (media.hash) tag.push(`x ${media.hash}`)
- if (media.size) tag.push(`size ${media.size}`)
- if (media.thumbnail) tag.push(`thumb ${media.thumbnail}`)
- media.fallbacks?.forEach(fb => tag.push(`fallback ${fb}`))
-
- return tag
-}
-
-// ============================================================================
-// Ranking Algorithms
-// ============================================================================
-
-/** Epoch for hot rank calculation (Unix timestamp) */
-const HOT_RANK_EPOCH = 1134028003 // Dec 8, 2005 (Reddit's epoch)
-
-/**
- * Calculate hot rank score (Reddit/Lemmy style)
- * Higher scores for posts with more upvotes that are newer
- */
-export function calculateHotRank(score: number, createdAt: number): number {
- const order = Math.log10(Math.max(Math.abs(score), 1))
- const sign = score > 0 ? 1 : score < 0 ? -1 : 0
- const seconds = createdAt - HOT_RANK_EPOCH
- return sign * order + seconds / 45000
-}
-
-/**
- * Calculate controversy rank
- * Higher scores for posts with balanced up/down votes
- */
-export function calculateControversyRank(upvotes: number, downvotes: number): number {
- const total = upvotes + downvotes
- if (total === 0) return 0
-
- const magnitude = Math.pow(total, 0.8)
- const balance = Math.min(upvotes, downvotes) / Math.max(upvotes, downvotes, 1)
-
- return magnitude * balance
-}
-
-/**
- * Calculate confidence score (Wilson score interval lower bound)
- * Used for "best" comment sorting
- */
-export function calculateConfidence(upvotes: number, downvotes: number): number {
- const n = upvotes + downvotes
- if (n === 0) return 0
-
- const z = 1.96 // 95% confidence
- const p = upvotes / n
-
- const left = p + (z * z) / (2 * n)
- const right = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * n)) / n)
- const under = 1 + (z * z) / n
-
- return (left - right) / under
-}
diff --git a/src/modules/nostr-feed/views/SubmissionDetailPage.vue b/src/modules/nostr-feed/views/SubmissionDetailPage.vue
deleted file mode 100644
index 5350651..0000000
--- a/src/modules/nostr-feed/views/SubmissionDetailPage.vue
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/src/modules/nostr-feed/views/SubmitPage.vue b/src/modules/nostr-feed/views/SubmitPage.vue
deleted file mode 100644
index 129208a..0000000
--- a/src/modules/nostr-feed/views/SubmitPage.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
diff --git a/src/pages/Home.vue b/src/pages/Home.vue
index 8dbbeac..23d5c4d 100644
--- a/src/pages/Home.vue
+++ b/src/pages/Home.vue
@@ -1,56 +1,268 @@
+
+
-
-
-
+
+
+
Feed
+
+
+
+ {{ activeFilterCount }} filters
+ All content
+
+
+
+
+
+
+
+
+
+
-
+