diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 32221f5..f9d65f9 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -137,6 +137,11 @@ export const SERVICE_TOKENS = { PROFILE_SERVICE: Symbol('profileService'), REACTION_SERVICE: Symbol('reactionService'), + // Link aggregator services + SUBMISSION_SERVICE: Symbol('submissionService'), + LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), + COMMUNITY_SERVICE: Symbol('communityService'), + // Nostr metadata services NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), diff --git a/src/modules/nostr-feed/LINK_AGGREGATOR_PLAN.md b/src/modules/nostr-feed/LINK_AGGREGATOR_PLAN.md new file mode 100644 index 0000000..6bdf31e --- /dev/null +++ b/src/modules/nostr-feed/LINK_AGGREGATOR_PLAN.md @@ -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": "", + "tags": [ + // Community scope (NIP-72 + NIP-22) + ["A", "34550::", ""], + ["a", "34550::", ""], + ["K", "34550"], + ["k", "34550"], + ["P", ""], + ["p", ""], + + // Submission metadata + ["title", ""], + ["post-type", "link|media|self"], + + // Link post fields + ["r", ""], + ["preview-title", ""], + ["preview-description", ""], + ["preview-image", ""], + ["preview-site-name", ""], + + // Media post fields (NIP-92) + ["imeta", "url ", "m ", "dim ", "blurhash ", "alt "], + + // Common + ["t", ""], + ["nsfw", "true|false"] + ] +} +``` + +### Comment on Submission (kind 1111) + +```jsonc +{ + "kind": 1111, + "content": "", + "tags": [ + // Root scope (the community) + ["A", "34550::", ""], + ["K", "34550"], + ["P", ""], + + // Parent (the submission or parent comment) + ["e", "", "", ""], + ["k", "1111"], + ["p", ""] + ] +} +``` + +## 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 diff --git a/src/modules/nostr-feed/composables/useSubmissions.ts b/src/modules/nostr-feed/composables/useSubmissions.ts new file mode 100644 index 0000000..241ad40 --- /dev/null +++ b/src/modules/nostr-feed/composables/useSubmissions.ts @@ -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 +} + +export interface UseSubmissionsReturn { + // State + submissions: ReturnType> + isLoading: ReturnType> + error: ReturnType> + + // Sorting + currentSort: ReturnType> + currentTimeRange: ReturnType> + + // Actions + subscribe: (config?: Partial) => Promise + unsubscribe: () => Promise + refresh: () => Promise + createSubmission: (form: SubmissionForm) => Promise + upvote: (submissionId: string) => Promise + downvote: (submissionId: string) => Promise + setSort: (sort: SortType, timeRange?: TimeRange) => void + + // Getters + getSubmission: (id: string) => SubmissionWithMeta | undefined + getComments: (submissionId: string) => SubmissionComment[] + getThreadedComments: (submissionId: string) => SubmissionComment[] + + // Link preview + fetchLinkPreview: (url: string) => Promise + isPreviewLoading: (url: string) => boolean +} + +// ============================================================================ +// Composable +// ============================================================================ + +export function useSubmissions(options: UseSubmissionsOptions = {}): UseSubmissionsReturn { + const { + autoSubscribe = true, + config: initialConfig = {} + } = options + + // Inject services + const submissionService = tryInjectService(SERVICE_TOKENS.SUBMISSION_SERVICE) + const linkPreviewService = tryInjectService(SERVICE_TOKENS.LINK_PREVIEW_SERVICE) + + // Local state + const currentSort = ref('hot') + const currentTimeRange = ref('day') + + // Default feed config + const defaultConfig: SubmissionFeedConfig = { + sort: 'hot', + timeRange: 'day', + includeNsfw: false, + limit: 50, + ...initialConfig + } + + // Computed values from service + const submissions = computed(() => { + if (!submissionService) return [] + return submissionService.getSortedSubmissions(currentSort.value) + }) + + const isLoading = computed(() => submissionService?.isLoading.value ?? false) + const error = computed(() => submissionService?.error.value ?? null) + + // ============================================================================ + // Actions + // ============================================================================ + + /** + * Subscribe to submissions feed + */ + async function subscribe(config?: Partial): Promise { + if (!submissionService) { + console.warn('SubmissionService not available') + return + } + + const feedConfig: SubmissionFeedConfig = { + ...defaultConfig, + ...config, + sort: currentSort.value, + timeRange: currentTimeRange.value + } + + await submissionService.subscribe(feedConfig) + } + + /** + * Unsubscribe from feed + */ + async function unsubscribe(): Promise { + await submissionService?.unsubscribe() + } + + /** + * Refresh the feed + */ + async function refresh(): Promise { + submissionService?.clear() + await subscribe() + } + + /** + * Create a new submission + */ + async function createSubmission(form: SubmissionForm): Promise { + if (!submissionService) { + throw new Error('SubmissionService not available') + } + return submissionService.createSubmission(form) + } + + /** + * Upvote a submission + */ + async function upvote(submissionId: string): Promise { + if (!submissionService) { + throw new Error('SubmissionService not available') + } + await submissionService.upvote(submissionId) + } + + /** + * Downvote a submission + */ + async function downvote(submissionId: string): Promise { + if (!submissionService) { + throw new Error('SubmissionService not available') + } + await submissionService.downvote(submissionId) + } + + /** + * Change sort order + */ + function setSort(sort: SortType, timeRange?: TimeRange): void { + currentSort.value = sort + if (timeRange) { + currentTimeRange.value = timeRange + } + } + + // ============================================================================ + // Getters + // ============================================================================ + + /** + * Get a single submission by ID + */ + function getSubmission(id: string): SubmissionWithMeta | undefined { + return submissionService?.getSubmission(id) + } + + /** + * Get comments for a submission + */ + function getComments(submissionId: string): SubmissionComment[] { + return submissionService?.getComments(submissionId) ?? [] + } + + /** + * Get threaded comments for a submission + */ + function getThreadedComments(submissionId: string): SubmissionComment[] { + return submissionService?.getThreadedComments(submissionId) ?? [] + } + + // ============================================================================ + // Link Preview + // ============================================================================ + + /** + * Fetch link preview for a URL + */ + async function fetchLinkPreview(url: string): Promise { + if (!linkPreviewService) { + return { + url, + domain: new URL(url).hostname.replace(/^www\./, '') + } + } + return linkPreviewService.fetchPreview(url) + } + + /** + * Check if preview is loading + */ + function isPreviewLoading(url: string): boolean { + return linkPreviewService?.isLoading(url) ?? false + } + + // ============================================================================ + // Lifecycle + // ============================================================================ + + // Watch for sort changes and re-sort + watch([currentSort, currentTimeRange], async () => { + // Re-subscribe with new sort if needed for time-based filtering + if (currentSort.value === 'top') { + await subscribe() + } + }) + + // Auto-subscribe on mount + onMounted(() => { + if (autoSubscribe) { + subscribe() + } + }) + + // Cleanup on unmount + onUnmounted(() => { + unsubscribe() + }) + + // ============================================================================ + // Return + // ============================================================================ + + return { + // State + submissions, + isLoading, + error, + + // Sorting + currentSort, + currentTimeRange, + + // Actions + subscribe, + unsubscribe, + refresh, + createSubmission, + upvote, + downvote, + setSort, + + // Getters + getSubmission, + getComments, + getThreadedComments, + + // Link preview + fetchLinkPreview, + isPreviewLoading + } +} + +// ============================================================================ +// Single Submission Hook +// ============================================================================ + +/** + * Hook for working with a single submission + */ +export function useSubmission(submissionId: string) { + const submissionService = tryInjectService(SERVICE_TOKENS.SUBMISSION_SERVICE) + + const submission = computed(() => submissionService?.getSubmission(submissionId)) + const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? []) + + async function upvote(): Promise { + await submissionService?.upvote(submissionId) + } + + async function downvote(): Promise { + await submissionService?.downvote(submissionId) + } + + return { + submission, + comments, + upvote, + downvote + } +} diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts index 9967476..7e26d7b 100644 --- a/src/modules/nostr-feed/index.ts +++ b/src/modules/nostr-feed/index.ts @@ -6,6 +6,8 @@ import { useFeed } from './composables/useFeed' import { FeedService } from './services/FeedService' import { ProfileService } from './services/ProfileService' import { ReactionService } from './services/ReactionService' +import { SubmissionService } from './services/SubmissionService' +import { LinkPreviewService } from './services/LinkPreviewService' /** * Nostr Feed Module Plugin @@ -23,10 +25,14 @@ export const nostrFeedModule: ModulePlugin = { const feedService = new FeedService() const profileService = new ProfileService() const reactionService = new ReactionService() + const submissionService = new SubmissionService() + const linkPreviewService = new LinkPreviewService() container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService) container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) + container.provide(SERVICE_TOKENS.SUBMISSION_SERVICE, submissionService) + container.provide(SERVICE_TOKENS.LINK_PREVIEW_SERVICE, linkPreviewService) console.log('nostr-feed module: Services registered in DI container') // Initialize services @@ -43,6 +49,14 @@ export const nostrFeedModule: ModulePlugin = { reactionService.initialize({ waitForDependencies: true, maxRetries: 3 + }), + submissionService.initialize({ + waitForDependencies: true, + maxRetries: 3 + }), + linkPreviewService.initialize({ + waitForDependencies: true, + maxRetries: 3 }) ]) console.log('nostr-feed module: Services initialized') diff --git a/src/modules/nostr-feed/services/LinkPreviewService.ts b/src/modules/nostr-feed/services/LinkPreviewService.ts new file mode 100644 index 0000000..0f4f4dc --- /dev/null +++ b/src/modules/nostr-feed/services/LinkPreviewService.ts @@ -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()) + + // Cache TTL (15 minutes) + private readonly CACHE_TTL = 15 * 60 * 1000 + + // Loading state per URL + private _loading = reactive(new Map()) + + // Error state per URL + private _errors = reactive(new Map()) + + // CORS proxy URL (configurable) + private proxyUrl = '' + + // ============================================================================ + // Lifecycle + // ============================================================================ + + protected async onInitialize(): Promise { + console.log('LinkPreviewService: Initializing...') + + // Try to get proxy URL from environment + this.proxyUrl = import.meta.env.VITE_CORS_PROXY_URL || '' + + // Clean expired cache entries periodically + setInterval(() => this.cleanCache(), this.CACHE_TTL) + + console.log('LinkPreviewService: Initialization complete') + } + + protected async onDispose(): Promise { + this.cache.clear() + this._loading.clear() + this._errors.clear() + } + + // ============================================================================ + // Public API + // ============================================================================ + + /** + * Fetch link preview for a URL + */ + async fetchPreview(url: string): Promise { + // Normalize URL + const normalizedUrl = this.normalizeUrl(url) + + // Check cache + const cached = this.getCachedPreview(normalizedUrl) + if (cached) { + return cached + } + + // Check if already loading + if (this._loading.get(normalizedUrl)) { + // Wait for existing request + return this.waitForPreview(normalizedUrl) + } + + // Mark as loading + this._loading.set(normalizedUrl, true) + this._errors.delete(normalizedUrl) + + try { + const preview = await this.doFetch(normalizedUrl) + + // Cache the result + this.cache.set(normalizedUrl, { + preview, + timestamp: Date.now() + }) + + return preview + + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch preview' + this._errors.set(normalizedUrl, message) + + // Return minimal preview on error + return { + url: normalizedUrl, + domain: extractDomain(normalizedUrl) + } + + } finally { + this._loading.set(normalizedUrl, false) + } + } + + /** + * Get cached preview if available and not expired + */ + getCachedPreview(url: string): LinkPreview | null { + const cached = this.cache.get(url) + if (!cached) return null + + // Check if expired + if (Date.now() - cached.timestamp > this.CACHE_TTL) { + this.cache.delete(url) + return null + } + + return cached.preview + } + + /** + * Check if URL is currently loading + */ + isLoading(url: string): boolean { + return this._loading.get(url) || false + } + + /** + * Get error for URL + */ + getError(url: string): string | null { + return this._errors.get(url) || null + } + + /** + * Clear cache for a specific URL or all + */ + clearCache(url?: string): void { + if (url) { + this.cache.delete(url) + } else { + this.cache.clear() + } + } + + // ============================================================================ + // Fetching + // ============================================================================ + + /** + * Perform the actual fetch + */ + private async doFetch(url: string): Promise { + // Try different methods in order of preference + + // 1. Try direct fetch (works for same-origin or CORS-enabled sites) + try { + return await this.fetchDirect(url) + } catch (directError) { + this.debug('Direct fetch failed:', directError) + } + + // 2. Try CORS proxy if configured + if (this.proxyUrl) { + try { + return await this.fetchViaProxy(url) + } catch (proxyError) { + this.debug('Proxy fetch failed:', proxyError) + } + } + + // 3. Try oEmbed for supported sites + try { + return await this.fetchOembed(url) + } catch (oembedError) { + this.debug('oEmbed fetch failed:', oembedError) + } + + // 4. Return basic preview with just the domain + return { + url, + domain: extractDomain(url) + } + } + + /** + * Direct fetch (may fail due to CORS) + */ + private async fetchDirect(url: string): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html' + } + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const html = await response.text() + return this.parseHtml(url, html) + } + + /** + * Fetch via CORS proxy + */ + private async fetchViaProxy(url: string): Promise { + const proxyUrl = `${this.proxyUrl}${encodeURIComponent(url)}` + + const response = await fetch(proxyUrl, { + method: 'GET', + headers: { + 'Accept': 'text/html' + } + }) + + if (!response.ok) { + throw new Error(`Proxy HTTP ${response.status}`) + } + + const html = await response.text() + return this.parseHtml(url, html) + } + + /** + * Try oEmbed for supported providers + */ + private async fetchOembed(url: string): Promise { + // oEmbed providers and their endpoints + const providers = [ + { + pattern: /youtube\.com\/watch|youtu\.be/, + endpoint: 'https://www.youtube.com/oembed' + }, + { + pattern: /twitter\.com|x\.com/, + endpoint: 'https://publish.twitter.com/oembed' + }, + { + pattern: /vimeo\.com/, + endpoint: 'https://vimeo.com/api/oembed.json' + } + ] + + const provider = providers.find(p => p.pattern.test(url)) + if (!provider) { + throw new Error('No oEmbed provider for URL') + } + + const oembedUrl = `${provider.endpoint}?url=${encodeURIComponent(url)}&format=json` + const response = await fetch(oembedUrl) + + if (!response.ok) { + throw new Error(`oEmbed HTTP ${response.status}`) + } + + const data = await response.json() + + return { + url, + domain: extractDomain(url), + title: data.title, + description: data.description || data.author_name, + image: data.thumbnail_url, + siteName: data.provider_name, + type: data.type, + videoUrl: data.html?.includes('iframe') ? url : undefined + } + } + + // ============================================================================ + // HTML Parsing + // ============================================================================ + + /** + * Parse HTML to extract Open Graph and meta tags + */ + private parseHtml(url: string, html: string): LinkPreview { + const preview: LinkPreview = { + url, + domain: extractDomain(url) + } + + // Create a DOM parser + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + + // Extract Open Graph tags + const ogTags = this.extractOgTags(doc) + + // Extract Twitter Card tags (fallback) + const twitterTags = this.extractTwitterTags(doc) + + // Extract standard meta tags (fallback) + const metaTags = this.extractMetaTags(doc) + + // Merge with priority: OG > Twitter > Meta > Title + preview.title = ogTags.title || twitterTags.title || metaTags.title || this.extractTitle(doc) + preview.description = ogTags.description || twitterTags.description || metaTags.description + preview.image = ogTags.image || twitterTags.image + preview.siteName = ogTags.siteName || twitterTags.site + preview.type = ogTags.type + preview.videoUrl = ogTags.video + preview.favicon = this.extractFavicon(doc, url) + + return preview + } + + /** + * Extract Open Graph tags + */ + private extractOgTags(doc: Document): Record { + const tags: Record = {} + + const ogMetas = doc.querySelectorAll('meta[property^="og:"]') + ogMetas.forEach(meta => { + const property = meta.getAttribute('property')?.replace('og:', '') + const content = meta.getAttribute('content') + if (property && content) { + switch (property) { + case 'title': + tags.title = content + break + case 'description': + tags.description = content + break + case 'image': + tags.image = content + break + case 'site_name': + tags.siteName = content + break + case 'type': + tags.type = content + break + case 'video': + case 'video:url': + tags.video = content + break + } + } + }) + + return tags + } + + /** + * Extract Twitter Card tags + */ + private extractTwitterTags(doc: Document): Record { + const tags: Record = {} + + const twitterMetas = doc.querySelectorAll('meta[name^="twitter:"]') + twitterMetas.forEach(meta => { + const name = meta.getAttribute('name')?.replace('twitter:', '') + const content = meta.getAttribute('content') + if (name && content) { + switch (name) { + case 'title': + tags.title = content + break + case 'description': + tags.description = content + break + case 'image': + case 'image:src': + tags.image = content + break + case 'site': + tags.site = content + break + } + } + }) + + return tags + } + + /** + * Extract standard meta tags + */ + private extractMetaTags(doc: Document): Record { + const tags: Record = {} + + // Description + const descMeta = doc.querySelector('meta[name="description"]') + if (descMeta) { + tags.description = descMeta.getAttribute('content') || undefined + } + + // Title from meta + const titleMeta = doc.querySelector('meta[name="title"]') + if (titleMeta) { + tags.title = titleMeta.getAttribute('content') || undefined + } + + return tags + } + + /** + * Extract page title + */ + private extractTitle(doc: Document): string | undefined { + return doc.querySelector('title')?.textContent || undefined + } + + /** + * Extract favicon URL + */ + private extractFavicon(doc: Document, pageUrl: string): string | undefined { + // Try various link rel types + const selectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]' + ] + + for (const selector of selectors) { + const link = doc.querySelector(selector) + const href = link?.getAttribute('href') + if (href) { + return this.resolveUrl(href, pageUrl) + } + } + + // Default favicon location + return this.resolveUrl('/favicon.ico', pageUrl) + } + + // ============================================================================ + // Utilities + // ============================================================================ + + /** + * Normalize URL + */ + private normalizeUrl(url: string): string { + let normalized = url.trim() + + // Add protocol if missing + if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { + normalized = 'https://' + normalized + } + + return normalized + } + + /** + * Resolve relative URL to absolute + */ + private resolveUrl(href: string, base: string): string { + try { + return new URL(href, base).toString() + } catch { + return href + } + } + + /** + * Wait for an in-flight preview request + */ + private async waitForPreview(url: string): Promise { + // Poll until loading is done + while (this._loading.get(url)) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + // Return cached result or error + const cached = this.getCachedPreview(url) + if (cached) return cached + + return { + url, + domain: extractDomain(url) + } + } + + /** + * Clean expired cache entries + */ + private cleanCache(): void { + const now = Date.now() + + for (const [url, entry] of this.cache) { + if (now - entry.timestamp > this.CACHE_TTL) { + this.cache.delete(url) + } + } + } + + // ============================================================================ + // Validation + // ============================================================================ + + /** + * Check if URL is valid + */ + isValidUrl(url: string): boolean { + try { + const parsed = new URL(this.normalizeUrl(url)) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } + } + + /** + * Check if URL is likely to be media + */ + isMediaUrl(url: string): boolean { + const mediaExtensions = [ + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', + '.mp4', '.webm', '.mov', '.avi', + '.mp3', '.wav', '.ogg', '.flac' + ] + + const lowerUrl = url.toLowerCase() + return mediaExtensions.some(ext => lowerUrl.includes(ext)) + } + + /** + * Guess media type from URL + */ + guessMediaType(url: string): 'image' | 'video' | 'audio' | 'other' { + const lowerUrl = url.toLowerCase() + + if (/\.(jpg|jpeg|png|gif|webp|svg)/.test(lowerUrl)) return 'image' + if (/\.(mp4|webm|mov|avi)/.test(lowerUrl)) return 'video' + if (/\.(mp3|wav|ogg|flac)/.test(lowerUrl)) return 'audio' + + return 'other' + } +} diff --git a/src/modules/nostr-feed/services/SubmissionService.ts b/src/modules/nostr-feed/services/SubmissionService.ts new file mode 100644 index 0000000..2cd0f21 --- /dev/null +++ b/src/modules/nostr-feed/services/SubmissionService.ts @@ -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()) + private _comments = reactive(new Map()) + private _isLoading = ref(false) + private _error = ref(null) + + // Deduplication + private seenEventIds = new Set() + + // Subscription management + private currentSubscription: string | null = null + private currentUnsubscribe: (() => void) | null = null + + // Public reactive state + public readonly submissions = computed(() => Array.from(this._submissions.values())) + public readonly isLoading = computed(() => this._isLoading.value) + public readonly error = computed(() => this._error.value) + + // ============================================================================ + // Lifecycle + // ============================================================================ + + protected async onInitialize(): Promise { + console.log('SubmissionService: Starting initialization...') + + this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) + this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) + this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) + + if (!this.relayHub) { + throw new Error('RelayHub service not available') + } + + // Register with visibility service + if (this.visibilityService) { + this.visibilityService.registerService( + 'SubmissionService', + this.onResume.bind(this), + this.onPause.bind(this) + ) + } + + console.log('SubmissionService: Initialization complete') + } + + private async onResume(): Promise { + this.debug('App resumed') + } + + private onPause(): void { + this.debug('App paused') + } + + protected async onDispose(): Promise { + await this.unsubscribe() + this._submissions.clear() + this._comments.clear() + this.seenEventIds.clear() + } + + // ============================================================================ + // Subscription Management + // ============================================================================ + + /** + * Subscribe to submissions based on feed configuration + */ + async subscribe(config: SubmissionFeedConfig): Promise { + await this.unsubscribe() + + this._isLoading.value = true + this._error.value = null + + try { + if (!this.relayHub?.isConnected) { + await this.relayHub?.connect() + } + + const subscriptionId = `submissions-${Date.now()}` + const filters = this.buildFilters(config) + + this.debug('Subscribing with filters:', filters) + + const unsubscribe = this.relayHub.subscribe({ + id: subscriptionId, + filters, + onEvent: (event: NostrEvent) => this.handleEvent(event), + onEose: () => { + this.debug('End of stored events') + this._isLoading.value = false + }, + onClose: () => { + this.debug('Subscription closed') + } + }) + + this.currentSubscription = subscriptionId + this.currentUnsubscribe = unsubscribe + + // Timeout fallback + setTimeout(() => { + if (this._isLoading.value && this.currentSubscription === subscriptionId) { + this._isLoading.value = false + } + }, 10000) + + } catch (err) { + this._error.value = err instanceof Error ? err.message : 'Failed to subscribe' + this._isLoading.value = false + throw err + } + } + + /** + * Unsubscribe from current feed + */ + async unsubscribe(): Promise { + if (this.currentUnsubscribe) { + this.currentUnsubscribe() + this.currentSubscription = null + this.currentUnsubscribe = null + } + } + + /** + * Build Nostr filters from feed configuration + */ + private buildFilters(config: SubmissionFeedConfig): Filter[] { + const filters: Filter[] = [] + + // Main submissions filter + const submissionFilter: Filter = { + kinds: [SUBMISSION_KINDS.SUBMISSION], + limit: config.limit + } + + // Community filter + if (config.community) { + submissionFilter['#a'] = [formatCommunityRef(config.community)] + } + + // Author filter + if (config.authors?.length) { + submissionFilter.authors = config.authors + } + + // Hashtag filter + if (config.hashtags?.length) { + submissionFilter['#t'] = config.hashtags + } + + // Time range for "top" sort + if (config.sort === 'top' && config.timeRange) { + const now = Math.floor(Date.now() / 1000) + const ranges: Record = { + hour: 3600, + day: 86400, + week: 604800, + month: 2592000, + year: 31536000, + all: 0 + } + if (ranges[config.timeRange] > 0) { + submissionFilter.since = now - ranges[config.timeRange] + } + } + + filters.push(submissionFilter) + + // Also subscribe to reactions for these submissions + filters.push({ + kinds: [SUBMISSION_KINDS.REACTION], + limit: 1000 + }) + + // And deletions + filters.push({ + kinds: [SUBMISSION_KINDS.DELETION] + }) + + return filters + } + + // ============================================================================ + // Event Handling + // ============================================================================ + + /** + * Handle incoming Nostr event + */ + private handleEvent(event: NostrEvent): void { + switch (event.kind) { + case SUBMISSION_KINDS.SUBMISSION: + this.handleSubmissionEvent(event) + break + case SUBMISSION_KINDS.REACTION: + // Route to reaction service + this.reactionService?.handleReactionEvent(event) + break + case SUBMISSION_KINDS.DELETION: + this.handleDeletionEvent(event) + break + } + } + + /** + * Handle kind 1111 submission event + */ + private handleSubmissionEvent(event: NostrEvent): void { + // Deduplication + if (this.seenEventIds.has(event.id)) { + return + } + this.seenEventIds.add(event.id) + + // 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 + ): LinkSubmission { + const tags = event.tags || [] + + // Get URL from 'r' tag or content + const urlTag = tags.find(t => t[0] === 'r') + let url = urlTag?.[1] || '' + + if (!url) { + const urlMatch = event.content.match(/https?:\/\/[^\s]+/) + url = urlMatch?.[0] || '' + } + + // Build preview from tags + const preview: LinkPreview = { + url, + domain: extractDomain(url), + title: tags.find(t => t[0] === 'preview-title')?.[1], + description: tags.find(t => t[0] === 'preview-description')?.[1], + image: tags.find(t => t[0] === 'preview-image')?.[1], + siteName: tags.find(t => t[0] === 'preview-site-name')?.[1] + } + + return { + ...base, + postType: 'link', + url, + preview, + body: event.content + } + } + + /** + * Parse media submission + */ + private parseMediaSubmission( + event: NostrEvent, + base: Omit + ): MediaSubmission { + const tags = event.tags || [] + + // Parse imeta tags + const imetaTags = tags.filter(t => t[0] === 'imeta') + const mediaAttachments = imetaTags + .map(t => parseImetaTag(t)) + .filter((m): m is MediaAttachment => m !== null) + + // If no imeta, try to extract from content + if (mediaAttachments.length === 0) { + const urlMatch = event.content.match(/https?:\/\/[^\s]+/) + if (urlMatch) { + mediaAttachments.push({ url: urlMatch[0] }) + } + } + + const [primary, ...gallery] = mediaAttachments + + return { + ...base, + postType: 'media', + media: primary || { url: '' }, + gallery: gallery.length > 0 ? gallery : undefined, + body: event.content + } + } + + /** + * Parse self/text submission + */ + private parseSelfSubmission( + event: NostrEvent, + base: Omit + ): SelfSubmission { + return { + ...base, + postType: 'self', + body: event.content + } + } + + /** + * Extract community reference from tags + */ + private extractCommunityRef(tags: string[][]): string | undefined { + const aTag = tags.find(t => t[0] === 'A' || t[0] === 'a') + return aTag?.[1] + } + + /** + * Extract hashtags from tags + */ + private extractHashtags(tags: string[][]): string[] { + return tags.filter(t => t[0] === 't').map(t => t[1]) + } + + // ============================================================================ + // Enrichment + // ============================================================================ + + /** + * Enrich submission with votes, ranking, etc. + */ + private enrichSubmission(submission: Submission): SubmissionWithMeta { + const votes = this.getSubmissionVotes(submission.id) + const ranking = this.calculateRanking(submission, votes) + + return { + ...submission, + votes, + ranking, + commentCount: this.getCommentCount(submission.id), + isSaved: false, // TODO: implement saved posts + isHidden: false, + approvalStatus: null + } + } + + /** + * Get votes for a submission + */ + private getSubmissionVotes(submissionId: string): SubmissionVotes { + // Get from reaction service if available + if (this.reactionService) { + const reactions = this.reactionService.getEventReactions(submissionId) + return { + upvotes: reactions.likes || 0, + downvotes: reactions.dislikes || 0, + score: (reactions.likes || 0) - (reactions.dislikes || 0), + userVote: reactions.userHasLiked ? 'upvote' : reactions.userHasDisliked ? 'downvote' : null, + userVoteId: reactions.userReactionId + } + } + return this.getDefaultVotes() + } + + /** + * Get default votes object + */ + private getDefaultVotes(): SubmissionVotes { + return { + upvotes: 0, + downvotes: 0, + score: 0, + userVote: null + } + } + + /** + * Calculate ranking scores + */ + private calculateRanking(submission: Submission, votes: SubmissionVotes): SubmissionRanking { + return { + hotRank: calculateHotRank(votes.score, submission.created_at), + controversyRank: calculateControversyRank(votes.upvotes, votes.downvotes), + scaledRank: 0 // TODO: implement community scaling + } + } + + /** + * Get comment count for a submission + */ + private getCommentCount(submissionId: string): number { + return this._comments.get(submissionId)?.length || 0 + } + + // ============================================================================ + // Submission Creation + // ============================================================================ + + /** + * Create a new submission + */ + async createSubmission(form: SubmissionForm): Promise { + this.requireAuth() + + const userPubkey = this.authService.user.value?.pubkey + const userPrivkey = this.authService.user.value?.prvkey + + if (!userPubkey || !userPrivkey) { + throw new Error('User keys not available') + } + + try { + this._isLoading.value = true + + // Build event + const eventTemplate = await this.buildSubmissionEvent(form) + + // Sign + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + // Publish + await this.relayHub.publishEvent(signedEvent) + + // Handle locally + this.handleSubmissionEvent(signedEvent) + + return signedEvent.id + + } finally { + this._isLoading.value = false + } + } + + /** + * Build event template from submission form + */ + private async buildSubmissionEvent(form: SubmissionForm): Promise { + const tags: string[][] = [] + + // Title (required) + tags.push(['title', form.title]) + + // Post type + tags.push(['post-type', form.postType]) + + // Community scope (if provided) + if (form.communityRef) { + const ref = parseCommunityRef(form.communityRef) + if (ref) { + tags.push(['A', form.communityRef]) + tags.push(['a', form.communityRef]) + tags.push(['K', '34550']) + tags.push(['k', '34550']) + tags.push(['P', ref.pubkey]) + tags.push(['p', ref.pubkey]) + } + } + + // NSFW + if (form.nsfw) { + tags.push(['nsfw', 'true']) + } + + // Flair + if (form.flair) { + tags.push(['flair', form.flair]) + } + + let content = '' + + // Type-specific fields + switch (form.postType) { + case 'link': { + const linkForm = form as LinkSubmissionForm + tags.push(['r', linkForm.url]) + + // Fetch and add preview if available + if (this.linkPreviewService) { + try { + const preview = await this.linkPreviewService.fetchPreview(linkForm.url) + if (preview.title) tags.push(['preview-title', preview.title]) + if (preview.description) tags.push(['preview-description', preview.description]) + if (preview.image) tags.push(['preview-image', preview.image]) + if (preview.siteName) tags.push(['preview-site-name', preview.siteName]) + } catch (err) { + this.debug('Failed to fetch link preview:', err) + } + } + + content = linkForm.body || '' + break + } + + case 'media': { + const mediaForm = form as MediaSubmissionForm + + // Handle file upload or URL + let mediaUrl: string + if (typeof mediaForm.media === 'string') { + mediaUrl = mediaForm.media + } else { + // TODO: Upload file and get URL + throw new Error('File upload not yet implemented') + } + + // Add imeta tag + const imetaTag = buildImetaTag({ + url: mediaUrl, + alt: mediaForm.alt + }) + tags.push(imetaTag) + + content = mediaForm.body || '' + break + } + + case 'self': { + const selfForm = form as SelfSubmissionForm + content = selfForm.body + break + } + } + + return { + kind: SUBMISSION_KINDS.SUBMISSION, + content, + tags, + created_at: Math.floor(Date.now() / 1000) + } + } + + // ============================================================================ + // Voting + // ============================================================================ + + /** + * Upvote a submission + */ + async upvote(submissionId: string): Promise { + const submission = this._submissions.get(submissionId) + if (!submission) throw new Error('Submission not found') + + if (submission.votes.userVote === 'upvote') { + // Remove upvote + await this.reactionService?.unlikeEvent(submissionId) + } else { + // Add upvote (reaction service handles removing existing downvote) + await this.reactionService?.likeEvent( + submissionId, + submission.pubkey, + SUBMISSION_KINDS.SUBMISSION + ) + } + + // Refresh votes + this.refreshSubmissionVotes(submissionId) + } + + /** + * Downvote a submission + */ + async downvote(submissionId: string): Promise { + // 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() + 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 + } +} diff --git a/src/modules/nostr-feed/types/index.ts b/src/modules/nostr-feed/types/index.ts new file mode 100644 index 0000000..e6b40a8 --- /dev/null +++ b/src/modules/nostr-feed/types/index.ts @@ -0,0 +1,5 @@ +/** + * Types index - re-export all types from the module + */ + +export * from './submission' diff --git a/src/modules/nostr-feed/types/submission.ts b/src/modules/nostr-feed/types/submission.ts new file mode 100644 index 0000000..2310a3b --- /dev/null +++ b/src/modules/nostr-feed/types/submission.ts @@ -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::" + */ +export function parseCommunityRef(aTag: string, relay?: string): CommunityRef | null { + const parts = aTag.split(':') + if (parts.length < 3 || parts[0] !== '34550') { + return null + } + return { + kind: parts[0], + pubkey: parts[1], + identifier: parts.slice(2).join(':'), // identifier may contain colons + relay + } +} + +/** + * Format a CommunityRef back to 'a' tag format + */ +export function formatCommunityRef(ref: CommunityRef): string { + return `${ref.kind}:${ref.pubkey}:${ref.identifier}` +} + +/** + * Extract domain from URL + */ +export function extractDomain(url: string): string { + try { + const parsed = new URL(url) + return parsed.hostname.replace(/^www\./, '') + } catch { + return url + } +} + +/** + * Classify media type from MIME type + */ +export function classifyMediaType(mimeType?: string): MediaType { + if (!mimeType) return 'other' + if (mimeType.startsWith('image/')) return 'image' + if (mimeType.startsWith('video/')) return 'video' + if (mimeType.startsWith('audio/')) return 'audio' + return 'other' +} + +/** + * Parse imeta tag into MediaAttachment + * Format: ["imeta", "url ", "m ", "dim ", ...] + */ +export function parseImetaTag(tag: string[]): MediaAttachment | null { + if (tag[0] !== 'imeta') return null + + const attachment: MediaAttachment = { url: '' } + + for (let i = 1; i < tag.length; i++) { + const [key, ...valueParts] = tag[i].split(' ') + const value = valueParts.join(' ') + + switch (key) { + case 'url': + attachment.url = value + break + case 'm': + attachment.mimeType = value + break + case 'dim': + attachment.dimensions = value + const [w, h] = value.split('x').map(Number) + if (!isNaN(w)) attachment.width = w + if (!isNaN(h)) attachment.height = h + break + case 'blurhash': + attachment.blurhash = value + break + case 'alt': + attachment.alt = value + break + case 'x': + attachment.hash = value + break + case 'size': + attachment.size = parseInt(value, 10) + break + case 'thumb': + attachment.thumbnail = value + break + case 'fallback': + attachment.fallbacks = attachment.fallbacks || [] + attachment.fallbacks.push(value) + break + } + } + + return attachment.url ? attachment : null +} + +/** + * Build imeta tag from MediaAttachment + */ +export function buildImetaTag(media: MediaAttachment): string[] { + const tag = ['imeta'] + + tag.push(`url ${media.url}`) + if (media.mimeType) tag.push(`m ${media.mimeType}`) + if (media.dimensions) tag.push(`dim ${media.dimensions}`) + else if (media.width && media.height) tag.push(`dim ${media.width}x${media.height}`) + if (media.blurhash) tag.push(`blurhash ${media.blurhash}`) + if (media.alt) tag.push(`alt ${media.alt}`) + if (media.hash) tag.push(`x ${media.hash}`) + if (media.size) tag.push(`size ${media.size}`) + if (media.thumbnail) tag.push(`thumb ${media.thumbnail}`) + media.fallbacks?.forEach(fb => tag.push(`fallback ${fb}`)) + + return tag +} + +// ============================================================================ +// Ranking Algorithms +// ============================================================================ + +/** Epoch for hot rank calculation (Unix timestamp) */ +const HOT_RANK_EPOCH = 1134028003 // Dec 8, 2005 (Reddit's epoch) + +/** + * Calculate hot rank score (Reddit/Lemmy style) + * Higher scores for posts with more upvotes that are newer + */ +export function calculateHotRank(score: number, createdAt: number): number { + const order = Math.log10(Math.max(Math.abs(score), 1)) + const sign = score > 0 ? 1 : score < 0 ? -1 : 0 + const seconds = createdAt - HOT_RANK_EPOCH + return sign * order + seconds / 45000 +} + +/** + * Calculate controversy rank + * Higher scores for posts with balanced up/down votes + */ +export function calculateControversyRank(upvotes: number, downvotes: number): number { + const total = upvotes + downvotes + if (total === 0) return 0 + + const magnitude = Math.pow(total, 0.8) + const balance = Math.min(upvotes, downvotes) / Math.max(upvotes, downvotes, 1) + + return magnitude * balance +} + +/** + * Calculate confidence score (Wilson score interval lower bound) + * Used for "best" comment sorting + */ +export function calculateConfidence(upvotes: number, downvotes: number): number { + const n = upvotes + downvotes + if (n === 0) return 0 + + const z = 1.96 // 95% confidence + const p = upvotes / n + + const left = p + (z * z) / (2 * n) + const right = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * n)) / n) + const under = 1 + (z * z) / n + + return (left - right) / under +}