feat(nostr-feed): Add link aggregator core data model (Phase 1)
Implement Reddit-style link aggregator foundation using NIP-72 (Moderated Communities) and NIP-22 (Comments) specifications. New files: - types/submission.ts: Complete type definitions for submissions, voting, communities, link previews, and ranking algorithms - services/SubmissionService.ts: Core service for kind 1111 submission events with parsing, creation, voting, and comment threading - services/LinkPreviewService.ts: Open Graph metadata fetching with caching, CORS proxy support, and oEmbed fallbacks - composables/useSubmissions.ts: Vue composable for reactive submission state management - LINK_AGGREGATOR_PLAN.md: Implementation roadmap Features: - Three post types: link, media, self (text) - NIP-22 compliant community-scoped posts - NIP-92 media attachments with imeta tags - Hot/Top/New/Controversial ranking algorithms - Threaded comment support - Upvote/downvote via NIP-25 reactions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4a95258acf
commit
037f46fa6b
8 changed files with 2539 additions and 0 deletions
|
|
@ -137,6 +137,11 @@ export const SERVICE_TOKENS = {
|
|||
PROFILE_SERVICE: Symbol('profileService'),
|
||||
REACTION_SERVICE: Symbol('reactionService'),
|
||||
|
||||
// Link aggregator services
|
||||
SUBMISSION_SERVICE: Symbol('submissionService'),
|
||||
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
|
||||
COMMUNITY_SERVICE: Symbol('communityService'),
|
||||
|
||||
// Nostr metadata services
|
||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||
|
||||
|
|
|
|||
168
src/modules/nostr-feed/LINK_AGGREGATOR_PLAN.md
Normal file
168
src/modules/nostr-feed/LINK_AGGREGATOR_PLAN.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Link Aggregator Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform the nostr-feed module into a Reddit-style link aggregator with support for:
|
||||
- **Link posts** - External URLs with Open Graph previews
|
||||
- **Media posts** - Images/videos with inline display
|
||||
- **Self posts** - Text/markdown content
|
||||
|
||||
## NIP Compliance
|
||||
|
||||
| NIP | Purpose | Usage |
|
||||
|-----|---------|-------|
|
||||
| NIP-72 | Moderated Communities | Community definitions (kind 34550), approvals (kind 4550) |
|
||||
| NIP-22 | Comments | Community posts (kind 1111) with scoped threading |
|
||||
| NIP-92 | Media Attachments | `imeta` tags for media metadata |
|
||||
| NIP-94 | File Metadata | Reference for media fields |
|
||||
| NIP-25 | Reactions | Upvote (`+`) / Downvote (`-`) |
|
||||
| NIP-10 | Reply Threading | Fallback for kind 1 compatibility |
|
||||
|
||||
## Event Structure
|
||||
|
||||
### Submission (kind 1111)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "<self-post body or link description>",
|
||||
"tags": [
|
||||
// Community scope (NIP-72 + NIP-22)
|
||||
["A", "34550:<community-pubkey>:<community-d>", "<relay>"],
|
||||
["a", "34550:<community-pubkey>:<community-d>", "<relay>"],
|
||||
["K", "34550"],
|
||||
["k", "34550"],
|
||||
["P", "<community-pubkey>"],
|
||||
["p", "<community-pubkey>"],
|
||||
|
||||
// Submission metadata
|
||||
["title", "<post title>"],
|
||||
["post-type", "link|media|self"],
|
||||
|
||||
// Link post fields
|
||||
["r", "<url>"],
|
||||
["preview-title", "<og:title>"],
|
||||
["preview-description", "<og:description>"],
|
||||
["preview-image", "<og:image>"],
|
||||
["preview-site-name", "<og:site_name>"],
|
||||
|
||||
// Media post fields (NIP-92)
|
||||
["imeta", "url <url>", "m <mime>", "dim <WxH>", "blurhash <hash>", "alt <desc>"],
|
||||
|
||||
// Common
|
||||
["t", "<hashtag>"],
|
||||
["nsfw", "true|false"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Comment on Submission (kind 1111)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "<comment text>",
|
||||
"tags": [
|
||||
// Root scope (the community)
|
||||
["A", "34550:<community-pubkey>:<community-d>", "<relay>"],
|
||||
["K", "34550"],
|
||||
["P", "<community-pubkey>"],
|
||||
|
||||
// Parent (the submission or parent comment)
|
||||
["e", "<parent-event-id>", "<relay>", "<parent-pubkey>"],
|
||||
["k", "1111"],
|
||||
["p", "<parent-pubkey>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Data Model (Current)
|
||||
- [x] Create feature branch
|
||||
- [x] Document plan
|
||||
- [ ] Create `types/submission.ts` - Type definitions
|
||||
- [ ] Create `SubmissionService.ts` - Submission CRUD
|
||||
- [ ] Create `LinkPreviewService.ts` - OG tag fetching
|
||||
- [ ] Extend `FeedService.ts` - Handle kind 1111
|
||||
|
||||
### Phase 2: Post Creation
|
||||
- [ ] Create `SubmitComposer.vue` - Multi-type composer
|
||||
- [ ] Add link preview on URL paste
|
||||
- [ ] Integrate with pictrs for media upload
|
||||
- [ ] Add NSFW toggle
|
||||
|
||||
### Phase 3: Feed Display
|
||||
- [ ] Create `SubmissionCard.vue` - Link aggregator card
|
||||
- [ ] Create `VoteButtons.vue` - Up/down voting
|
||||
- [ ] Add feed sorting (hot, new, top, controversial)
|
||||
- [ ] Add score calculation
|
||||
|
||||
### Phase 4: Detail View
|
||||
- [ ] Create `SubmissionDetail.vue` - Full post view
|
||||
- [ ] Integrate `ThreadedPost.vue` for comments
|
||||
- [ ] Add comment sorting
|
||||
|
||||
### Phase 5: Communities (Future)
|
||||
- [ ] Create `CommunityService.ts`
|
||||
- [ ] Create community browser
|
||||
- [ ] Add moderation queue
|
||||
|
||||
## Ranking Algorithms
|
||||
|
||||
### Hot Rank (Lemmy-style)
|
||||
```typescript
|
||||
function hotRank(score: number, createdAt: Date): number {
|
||||
const order = Math.log10(Math.max(Math.abs(score), 1))
|
||||
const sign = score > 0 ? 1 : score < 0 ? -1 : 0
|
||||
const seconds = (createdAt.getTime() - EPOCH.getTime()) / 1000
|
||||
return sign * order + seconds / 45000
|
||||
}
|
||||
```
|
||||
|
||||
### Controversy Rank
|
||||
```typescript
|
||||
function controversyRank(upvotes: number, downvotes: number): number {
|
||||
const total = upvotes + downvotes
|
||||
if (total === 0) return 0
|
||||
const magnitude = Math.pow(total, 0.8)
|
||||
const balance = total > 0 ? Math.min(upvotes, downvotes) / Math.max(upvotes, downvotes) : 0
|
||||
return magnitude * balance
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/modules/nostr-feed/
|
||||
├── types/
|
||||
│ └── submission.ts # NEW
|
||||
├── services/
|
||||
│ ├── FeedService.ts # MODIFY
|
||||
│ ├── SubmissionService.ts # NEW
|
||||
│ ├── LinkPreviewService.ts # NEW
|
||||
│ ├── CommunityService.ts # NEW (Phase 5)
|
||||
│ ├── ProfileService.ts # EXISTING
|
||||
│ └── ReactionService.ts # EXISTING (enhance for up/down)
|
||||
├── components/
|
||||
│ ├── SubmissionCard.vue # NEW (Phase 3)
|
||||
│ ├── SubmitComposer.vue # NEW (Phase 2)
|
||||
│ ├── SubmissionDetail.vue # NEW (Phase 4)
|
||||
│ ├── VoteButtons.vue # NEW (Phase 3)
|
||||
│ ├── ThreadedPost.vue # EXISTING (reuse)
|
||||
│ ├── NostrFeed.vue # EXISTING (modify)
|
||||
│ └── NoteComposer.vue # EXISTING
|
||||
├── composables/
|
||||
│ ├── useSubmissions.ts # NEW
|
||||
│ ├── useCommunities.ts # NEW (Phase 5)
|
||||
│ ├── useFeed.ts # EXISTING
|
||||
│ └── useReactions.ts # EXISTING
|
||||
└── config/
|
||||
└── content-filters.ts # MODIFY
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Backwards compatible** - Continue supporting kind 1 notes
|
||||
2. **Gradual adoption** - Add kind 1111 alongside existing
|
||||
3. **Feature flag** - Toggle between classic feed and link aggregator view
|
||||
309
src/modules/nostr-feed/composables/useSubmissions.ts
Normal file
309
src/modules/nostr-feed/composables/useSubmissions.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* useSubmissions Composable
|
||||
*
|
||||
* Provides reactive access to the SubmissionService for Reddit-style submissions.
|
||||
*/
|
||||
|
||||
import { computed, ref, onMounted, onUnmounted, watch } 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<SubmissionFeedConfig>
|
||||
}
|
||||
|
||||
export interface UseSubmissionsReturn {
|
||||
// State
|
||||
submissions: ReturnType<typeof computed<SubmissionWithMeta[]>>
|
||||
isLoading: ReturnType<typeof computed<boolean>>
|
||||
error: ReturnType<typeof computed<string | null>>
|
||||
|
||||
// Sorting
|
||||
currentSort: ReturnType<typeof ref<SortType>>
|
||||
currentTimeRange: ReturnType<typeof ref<TimeRange>>
|
||||
|
||||
// Actions
|
||||
subscribe: (config?: Partial<SubmissionFeedConfig>) => Promise<void>
|
||||
unsubscribe: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
createSubmission: (form: SubmissionForm) => Promise<string>
|
||||
upvote: (submissionId: string) => Promise<void>
|
||||
downvote: (submissionId: string) => Promise<void>
|
||||
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<LinkPreview>
|
||||
isPreviewLoading: (url: string) => boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composable
|
||||
// ============================================================================
|
||||
|
||||
export function useSubmissions(options: UseSubmissionsOptions = {}): UseSubmissionsReturn {
|
||||
const {
|
||||
autoSubscribe = true,
|
||||
config: initialConfig = {}
|
||||
} = options
|
||||
|
||||
// Inject services
|
||||
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
|
||||
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
|
||||
|
||||
// Local state
|
||||
const currentSort = ref<SortType>('hot')
|
||||
const currentTimeRange = ref<TimeRange>('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<SubmissionFeedConfig>): Promise<void> {
|
||||
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<void> {
|
||||
await submissionService?.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the feed
|
||||
*/
|
||||
async function refresh(): Promise<void> {
|
||||
submissionService?.clear()
|
||||
await subscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new submission
|
||||
*/
|
||||
async function createSubmission(form: SubmissionForm): Promise<string> {
|
||||
if (!submissionService) {
|
||||
throw new Error('SubmissionService not available')
|
||||
}
|
||||
return submissionService.createSubmission(form)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvote a submission
|
||||
*/
|
||||
async function upvote(submissionId: string): Promise<void> {
|
||||
if (!submissionService) {
|
||||
throw new Error('SubmissionService not available')
|
||||
}
|
||||
await submissionService.upvote(submissionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downvote a submission
|
||||
*/
|
||||
async function downvote(submissionId: string): Promise<void> {
|
||||
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<LinkPreview> {
|
||||
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<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
|
||||
|
||||
const submission = computed(() => submissionService?.getSubmission(submissionId))
|
||||
const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? [])
|
||||
|
||||
async function upvote(): Promise<void> {
|
||||
await submissionService?.upvote(submissionId)
|
||||
}
|
||||
|
||||
async function downvote(): Promise<void> {
|
||||
await submissionService?.downvote(submissionId)
|
||||
}
|
||||
|
||||
return {
|
||||
submission,
|
||||
comments,
|
||||
upvote,
|
||||
downvote
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { useFeed } from './composables/useFeed'
|
|||
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
|
||||
|
|
@ -23,10 +25,14 @@ 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
|
||||
|
|
@ -43,6 +49,14 @@ 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')
|
||||
|
|
|
|||
552
src/modules/nostr-feed/services/LinkPreviewService.ts
Normal file
552
src/modules/nostr-feed/services/LinkPreviewService.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
/**
|
||||
* LinkPreviewService
|
||||
*
|
||||
* Fetches Open Graph and meta tags from URLs to generate link previews.
|
||||
* Used when creating link submissions to embed preview data.
|
||||
*/
|
||||
|
||||
import { ref, 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<string, CacheEntry>())
|
||||
|
||||
// Cache TTL (15 minutes)
|
||||
private readonly CACHE_TTL = 15 * 60 * 1000
|
||||
|
||||
// Loading state per URL
|
||||
private _loading = reactive(new Map<string, boolean>())
|
||||
|
||||
// Error state per URL
|
||||
private _errors = reactive(new Map<string, string>())
|
||||
|
||||
// CORS proxy URL (configurable)
|
||||
private proxyUrl = ''
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
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<void> {
|
||||
this.cache.clear()
|
||||
this._loading.clear()
|
||||
this._errors.clear()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch link preview for a URL
|
||||
*/
|
||||
async fetchPreview(url: string): Promise<LinkPreview> {
|
||||
// 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<LinkPreview> {
|
||||
// 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<LinkPreview> {
|
||||
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<LinkPreview> {
|
||||
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<LinkPreview> {
|
||||
// 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<string, string | undefined> {
|
||||
const tags: Record<string, string | undefined> = {}
|
||||
|
||||
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<string, string | undefined> {
|
||||
const tags: Record<string, string | undefined> = {}
|
||||
|
||||
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<string, string | undefined> {
|
||||
const tags: Record<string, string | undefined> = {}
|
||||
|
||||
// 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<LinkPreview> {
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
957
src/modules/nostr-feed/services/SubmissionService.ts
Normal file
957
src/modules/nostr-feed/services/SubmissionService.ts
Normal file
|
|
@ -0,0 +1,957 @@
|
|||
/**
|
||||
* 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,
|
||||
type CommunityRef,
|
||||
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<string, SubmissionWithMeta>())
|
||||
private _comments = reactive(new Map<string, SubmissionComment[]>())
|
||||
private _isLoading = ref(false)
|
||||
private _error = ref<string | null>(null)
|
||||
|
||||
// Deduplication
|
||||
private seenEventIds = new Set<string>()
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
this.debug('App resumed')
|
||||
}
|
||||
|
||||
private onPause(): void {
|
||||
this.debug('App paused')
|
||||
}
|
||||
|
||||
protected async onDispose(): Promise<void> {
|
||||
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<void> {
|
||||
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')
|
||||
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<void> {
|
||||
if (this.currentUnsubscribe) {
|
||||
this.currentUnsubscribe()
|
||||
this.currentSubscription = null
|
||||
this.currentUnsubscribe = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number> = {
|
||||
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)
|
||||
|
||||
// Parse the submission
|
||||
const submission = this.parseSubmission(event)
|
||||
if (!submission) {
|
||||
this.debug('Failed to parse submission:', event.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a top-level submission or a comment
|
||||
if (this.isComment(event)) {
|
||||
this.handleCommentEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Create full submission with metadata
|
||||
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]
|
||||
|
||||
// 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.getDefaultVotes()
|
||||
}
|
||||
|
||||
// Add to comments map
|
||||
if (!this._comments.has(rootId)) {
|
||||
this._comments.set(rootId, [])
|
||||
}
|
||||
this._comments.get(rootId)!.push(comment)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 as const,
|
||||
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<Submission, 'postType' | 'url' | 'preview' | 'body'>
|
||||
): 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<Submission, 'postType' | 'media' | 'gallery' | 'body'>
|
||||
): 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<Submission, 'postType' | 'body'>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
this.requireAuth()
|
||||
|
||||
const userPubkey = this.authService.user.value?.pubkey
|
||||
const userPrivkey = this.authService.user.value?.prvkey
|
||||
|
||||
if (!userPubkey || !userPrivkey) {
|
||||
throw new Error('User keys not available')
|
||||
}
|
||||
|
||||
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<EventTemplate> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// TODO: Implement downvote using '-' reaction content
|
||||
// For now, this is a placeholder that mirrors the upvote logic
|
||||
const submission = this._submissions.get(submissionId)
|
||||
if (!submission) throw new Error('Submission not found')
|
||||
|
||||
this.debug('Downvote not yet implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<string, SubmissionComment>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
}
|
||||
5
src/modules/nostr-feed/types/index.ts
Normal file
5
src/modules/nostr-feed/types/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Types index - re-export all types from the module
|
||||
*/
|
||||
|
||||
export * from './submission'
|
||||
529
src/modules/nostr-feed/types/submission.ts
Normal file
529
src/modules/nostr-feed/types/submission.ts
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { Event as NostrEvent } from 'nostr-tools'
|
||||
|
||||
// ============================================================================
|
||||
// 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 interface SubmissionWithMeta extends 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:<pubkey>:<identifier>"
|
||||
*/
|
||||
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 <url>", "m <mime>", "dim <WxH>", ...]
|
||||
*/
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue