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
fb36caa0b2
commit
8f3d60fe47
8 changed files with 2539 additions and 0 deletions
|
|
@ -137,6 +137,11 @@ export const SERVICE_TOKENS = {
|
||||||
PROFILE_SERVICE: Symbol('profileService'),
|
PROFILE_SERVICE: Symbol('profileService'),
|
||||||
REACTION_SERVICE: Symbol('reactionService'),
|
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 services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
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 { FeedService } from './services/FeedService'
|
||||||
import { ProfileService } from './services/ProfileService'
|
import { ProfileService } from './services/ProfileService'
|
||||||
import { ReactionService } from './services/ReactionService'
|
import { ReactionService } from './services/ReactionService'
|
||||||
|
import { SubmissionService } from './services/SubmissionService'
|
||||||
|
import { LinkPreviewService } from './services/LinkPreviewService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr Feed Module Plugin
|
* Nostr Feed Module Plugin
|
||||||
|
|
@ -23,10 +25,14 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
const feedService = new FeedService()
|
const feedService = new FeedService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const submissionService = new SubmissionService()
|
||||||
|
const linkPreviewService = new LinkPreviewService()
|
||||||
|
|
||||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
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')
|
console.log('nostr-feed module: Services registered in DI container')
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
|
@ -43,6 +49,14 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
reactionService.initialize({
|
reactionService.initialize({
|
||||||
waitForDependencies: true,
|
waitForDependencies: true,
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
submissionService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
linkPreviewService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
console.log('nostr-feed module: Services initialized')
|
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