[Draft] feat(nostr-feed): Reddit-style link aggregator #9

Open
padreug wants to merge 19 commits from feature/link-aggregator into main
2 changed files with 721 additions and 0 deletions
Showing only changes of commit c0840912c7 - Show all commits

View file

@ -63,6 +63,12 @@ export async function createAppInstance() {
component: () => import('./pages/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/link-aggregator-test',
name: 'link-aggregator-test',
component: () => import('./pages/LinkAggregatorTest.vue'),
meta: { requiresAuth: false }
},
// Pre-register module routes
...moduleRoutes
]

View file

@ -0,0 +1,715 @@
<template>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-40 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">Link Aggregator Test</h1>
<p class="text-xs text-muted-foreground">Reddit/Lemmy-style feed UI</p>
</div>
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="flex items-center gap-1 text-sm">
<button
:class="[
'px-2 py-1 rounded text-xs',
viewMode === 'submissions' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'
]"
@click="viewMode = 'submissions'"
>
Submissions
</button>
<button
:class="[
'px-2 py-1 rounded text-xs',
viewMode === 'mock' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'
]"
@click="viewMode = 'mock'"
>
Mock Data
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Main content -->
<main class="max-w-4xl mx-auto">
<!-- Test Relay Connection & Composer Panel -->
<div class="p-4 border-b">
<button
@click="showComposer = !showComposer"
class="flex items-center gap-2 text-sm font-medium w-full"
>
<component :is="showComposer ? ChevronUp : ChevronDown" class="h-4 w-4" />
Test Relay & Submission Composer
<span
:class="[
'ml-2 px-2 py-0.5 rounded text-xs',
isConnected ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'
]"
>
{{ isConnected ? 'Connected' : 'Disconnected' }}
</span>
</button>
<div v-if="showComposer" class="mt-4 space-y-4">
<!-- Relay connection -->
<div class="flex items-center gap-2">
<input
v-model="testRelayUrl"
type="text"
placeholder="Relay URL"
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background"
:disabled="isConnected"
/>
<button
v-if="!isConnected"
@click="connectToRelay"
:disabled="isConnecting"
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
>
<Loader2 v-if="isConnecting" class="h-4 w-4 animate-spin" />
<Wifi v-else class="h-4 w-4" />
{{ isConnecting ? 'Connecting...' : 'Connect' }}
</button>
<button
v-else
@click="disconnectFromRelay"
class="px-4 py-2 text-sm bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 flex items-center gap-2"
>
<WifiOff class="h-4 w-4" />
Disconnect
</button>
</div>
<div v-if="connectionError" class="text-sm text-destructive">
{{ connectionError }}
</div>
<div v-if="publicKey" class="text-xs text-muted-foreground">
Pubkey: {{ publicKey.slice(0, 16) }}...{{ publicKey.slice(-8) }}
</div>
<!-- Submission form (only when connected) -->
<div v-if="isConnected" class="border rounded-lg p-4 space-y-3">
<div class="flex gap-2">
<button
@click="postType = 'self'"
:class="[
'px-3 py-1 text-sm rounded',
postType === 'self' ? 'bg-primary text-primary-foreground' : 'bg-muted'
]"
>
Self Post
</button>
<button
@click="postType = 'link'"
:class="[
'px-3 py-1 text-sm rounded',
postType === 'link' ? 'bg-primary text-primary-foreground' : 'bg-muted'
]"
>
Link Post
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="w-full px-3 py-2 text-sm border rounded-lg bg-background"
/>
<input
v-if="postType === 'link'"
v-model="url"
type="text"
placeholder="URL"
class="w-full px-3 py-2 text-sm border rounded-lg bg-background"
/>
<textarea
v-model="content"
:placeholder="postType === 'self' ? 'Post content...' : 'Optional description...'"
rows="3"
class="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none"
/>
<div class="flex items-center justify-between">
<button
@click="publishSubmission"
:disabled="isPublishing || !title"
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
>
<Loader2 v-if="isPublishing" class="h-4 w-4 animate-spin" />
<Send v-else class="h-4 w-4" />
{{ isPublishing ? 'Publishing...' : 'Publish to Relay' }}
</button>
<div v-if="publishResult" :class="['text-sm', publishResult.success ? 'text-green-600' : 'text-destructive']">
{{ publishResult.message }}
</div>
</div>
</div>
<!-- Received events -->
<div v-if="isConnected && receivedEvents.length > 0" class="border rounded-lg p-4">
<h4 class="text-sm font-medium mb-2">Received Kind 1111 Events ({{ receivedEvents.length }})</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div
v-for="event in receivedEvents"
:key="event.id"
class="text-xs p-2 bg-muted rounded"
>
<div class="font-mono text-muted-foreground">{{ event.id.slice(0, 24) }}...</div>
<div class="font-medium mt-1">
{{ event.tags.find(t => t[0] === 'title')?.[1] || '(no title)' }}
</div>
<div class="text-muted-foreground truncate">{{ event.content.slice(0, 100) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Real submissions from Nostr -->
<div v-if="viewMode === 'submissions'" class="p-4">
<div class="mb-4 p-3 bg-muted/50 rounded-lg text-sm">
<p class="font-medium">Live Mode</p>
<p class="text-muted-foreground text-xs mt-1">
Fetching kind 1111 submissions from connected relays.
If empty, no submissions exist yet.
</p>
</div>
<SubmissionList
:show-ranks="showRanks"
:show-time-range="true"
initial-sort="hot"
@submission-click="onSubmissionClick"
/>
</div>
<!-- Mock data demo -->
<div v-else class="p-4">
<div class="mb-4 p-3 bg-muted/50 rounded-lg text-sm">
<p class="font-medium">Mock Data Mode</p>
<p class="text-muted-foreground text-xs mt-1">
Displaying sample submissions to preview the UI.
</p>
</div>
<!-- Options -->
<div class="flex items-center gap-4 mb-4 text-sm">
<label class="flex items-center gap-2">
<input type="checkbox" v-model="showRanks" class="rounded" />
<span>Show ranks</span>
</label>
</div>
<!-- Sort tabs -->
<SortTabs
:current-sort="currentSort"
:current-time-range="currentTimeRange"
:show-time-range="true"
@update:sort="currentSort = $event"
@update:time-range="currentTimeRange = $event"
/>
<!-- Mock submission rows -->
<div class="divide-y divide-border">
<div
v-for="(submission, index) in sortedMockSubmissions"
:key="submission.id"
class="flex items-start gap-2 py-2 px-1 hover:bg-accent/30 transition-colors group"
>
<!-- Rank -->
<div v-if="showRanks" class="w-6 text-right text-sm text-muted-foreground font-medium pt-1">
{{ index + 1 }}
</div>
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
@upvote="onMockUpvote(submission)"
@downvote="onMockDownvote(submission)"
/>
<!-- Thumbnail -->
<SubmissionThumbnail
:src="submission.thumbnail"
:post-type="submission.postType"
:nsfw="submission.nsfw"
:size="70"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title row -->
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug cursor-pointer hover:underline">
{{ submission.title }}
</h3>
<span v-if="submission.domain" class="text-xs text-muted-foreground flex-shrink-0">
({{ submission.domain }})
</span>
<span v-if="submission.postType === 'self'" class="text-xs text-muted-foreground flex-shrink-0">
(self)
</span>
</div>
<!-- Badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-1 mt-0.5">
<span v-if="submission.nsfw" class="text-[10px] px-1 py-0 bg-destructive text-destructive-foreground rounded">
NSFW
</span>
<span v-if="submission.flair" class="text-[10px] px-1 py-0 bg-secondary text-secondary-foreground rounded">
{{ submission.flair }}
</span>
</div>
<!-- Metadata -->
<div class="text-xs text-muted-foreground mt-1">
<span>submitted {{ submission.timeAgo }}</span>
<span> by </span>
<span class="hover:underline cursor-pointer">{{ submission.author }}</span>
<template v-if="submission.community">
<span> to </span>
<span class="hover:underline cursor-pointer font-medium">{{ submission.community }}</span>
</template>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<button class="flex items-center gap-1 hover:text-foreground">
<MessageSquare class="h-3.5 w-3.5" />
<span>{{ submission.commentCount }} comments</span>
</button>
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
<Share2 class="h-3.5 w-3.5" />
<span>share</span>
</button>
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
<Bookmark class="h-3.5 w-3.5" />
<span>save</span>
</button>
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
<EyeOff class="h-3.5 w-3.5" />
<span>hide</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Floating back button -->
<div class="fixed bottom-6 left-6">
<button
@click="$router.push('/')"
class="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg hover:shadow-xl transition-all text-sm font-medium"
>
Back to Feed
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { MessageSquare, Share2, Bookmark, EyeOff, Send, Loader2, ChevronDown, ChevronUp, Wifi, WifiOff } from 'lucide-vue-next'
import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent } from 'nostr-tools'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import SubmissionList from '@/modules/nostr-feed/components/SubmissionList.vue'
import VoteControls from '@/modules/nostr-feed/components/VoteControls.vue'
import SortTabs from '@/modules/nostr-feed/components/SortTabs.vue'
import SubmissionThumbnail from '@/modules/nostr-feed/components/SubmissionThumbnail.vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SortType, TimeRange, VoteType } from '@/modules/nostr-feed/types/submission'
// View mode
const viewMode = ref<'submissions' | 'mock'>('mock')
// ===== Direct Relay Connection =====
const testRelayUrl = ref('ws://localhost:5000/nostrrelay/test')
const pool = new SimplePool()
const isConnected = ref(false)
const connectionError = ref<string | null>(null)
const isConnecting = ref(false)
// Composer state
const showComposer = ref(true)
const isPublishing = ref(false)
const publishResult = ref<{ success: boolean; message: string } | null>(null)
const receivedEvents = ref<NostrEvent[]>([])
// Form state
const postType = ref<'self' | 'link'>('self')
const title = ref('Test submission from Link Aggregator')
const content = ref('This is a test self post to verify kind 1111 submissions work correctly.')
const url = ref('https://example.com/test-article')
// Test keypair (generate new or use stored)
const privateKey = ref<Uint8Array | null>(null)
const publicKey = ref<string>('')
// Try to get user's key from auth service, otherwise generate test key
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
function initKeys() {
// Try to use authenticated user's key
if (authService?.user?.value?.prvkey) {
try {
privateKey.value = hexToBytes(authService.user.value.prvkey)
publicKey.value = authService.user.value.pubkey
console.log('Using authenticated user key:', publicKey.value.slice(0, 16) + '...')
return
} catch (e) {
console.warn('Failed to use auth key:', e)
}
}
// Generate test keypair
const stored = localStorage.getItem('test_nostr_privkey')
if (stored) {
try {
privateKey.value = hexToBytes(stored)
publicKey.value = getPublicKey(privateKey.value)
console.log('Using stored test key:', publicKey.value.slice(0, 16) + '...')
return
} catch (e) {
console.warn('Failed to restore stored key:', e)
}
}
// Generate new
privateKey.value = generateSecretKey()
publicKey.value = getPublicKey(privateKey.value)
localStorage.setItem('test_nostr_privkey', bytesToHex(privateKey.value))
console.log('Generated new test key:', publicKey.value.slice(0, 16) + '...')
}
async function connectToRelay() {
isConnecting.value = true
connectionError.value = null
try {
await pool.ensureRelay(testRelayUrl.value)
isConnected.value = true
console.log('Connected to relay:', testRelayUrl.value)
// Subscribe to kind 1111 events
subscribeToSubmissions()
} catch (err: any) {
connectionError.value = err.message || 'Failed to connect'
isConnected.value = false
console.error('Connection error:', err)
} finally {
isConnecting.value = false
}
}
function disconnectFromRelay() {
pool.close([testRelayUrl.value])
isConnected.value = false
receivedEvents.value = []
}
let activeSub: any = null
function subscribeToSubmissions() {
if (activeSub) {
activeSub.close()
}
receivedEvents.value = []
activeSub = pool.subscribeMany(
[testRelayUrl.value],
[{ kinds: [1111], limit: 50 }],
{
onevent(event: NostrEvent) {
console.log('Received event:', event)
// Add to beginning, avoid duplicates
if (!receivedEvents.value.find(e => e.id === event.id)) {
receivedEvents.value = [event, ...receivedEvents.value]
}
},
oneose() {
console.log('EOSE received')
}
}
)
}
async function publishSubmission() {
if (!privateKey.value || !isConnected.value) {
publishResult.value = { success: false, message: 'Not connected or no key' }
return
}
isPublishing.value = true
publishResult.value = null
try {
// Build kind 1111 event
const tags: string[][] = [
['title', title.value],
['post-type', postType.value]
]
let eventContent = ''
if (postType.value === 'link') {
tags.push(['r', url.value])
eventContent = content.value || `Link: ${url.value}`
} else {
eventContent = content.value
}
const eventTemplate = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags,
content: eventContent
}
const signedEvent = finalizeEvent(eventTemplate, privateKey.value)
console.log('Publishing event:', signedEvent)
// Publish
await Promise.any(pool.publish([testRelayUrl.value], signedEvent))
publishResult.value = {
success: true,
message: `Published! Event ID: ${signedEvent.id.slice(0, 16)}...`
}
// Refresh subscription
subscribeToSubmissions()
} catch (err: any) {
console.error('Publish error:', err)
publishResult.value = {
success: false,
message: err.message || 'Failed to publish'
}
} finally {
isPublishing.value = false
}
}
onMounted(() => {
initKeys()
})
onUnmounted(() => {
if (activeSub) activeSub.close()
pool.close([testRelayUrl.value])
})
// UI options
const showRanks = ref(true)
const currentSort = ref<SortType>('hot')
const currentTimeRange = ref<TimeRange>('day')
// Mock submission type
interface MockSubmission {
id: string
title: string
postType: 'link' | 'media' | 'self'
domain?: string
thumbnail?: string
author: string
community?: string
timeAgo: string
commentCount: number
votes: {
score: number
upvotes: number
downvotes: number
userVote: VoteType
}
nsfw?: boolean
flair?: string
created_at: number
}
// Mock data
const mockSubmissions = ref<MockSubmission[]>([
{
id: '1',
title: 'What are some cool and obscure data structures you know of?',
postType: 'self',
author: 'lil_shi',
community: 'Programming',
timeAgo: '10 hours ago',
commentCount: 29,
votes: { score: 84, upvotes: 92, downvotes: 8, userVote: null },
created_at: Date.now() / 1000 - 36000
},
{
id: '2',
title: "I haven't written code at all these holidays",
postType: 'self',
author: 'Matty_r',
community: 'Programming',
timeAgo: '1 day ago',
commentCount: 25,
votes: { score: 77, upvotes: 85, downvotes: 8, userVote: null },
created_at: Date.now() / 1000 - 86400
},
{
id: '3',
title: 'HDMI monitor for headless linux server',
postType: 'link',
domain: 'ttrp.network',
author: 'AldinTheMage',
community: 'Linux',
timeAgo: '19 hours ago',
commentCount: 19,
votes: { score: 28, upvotes: 32, downvotes: 4, userVote: null },
created_at: Date.now() / 1000 - 68400
},
{
id: '4',
title: '2026 is THE year of the linux desktop',
postType: 'self',
author: 'cm0002',
community: 'Linux',
timeAgo: '9 hours ago',
commentCount: 20,
votes: { score: 57, upvotes: 65, downvotes: 8, userVote: null },
flair: 'Discussion',
created_at: Date.now() / 1000 - 32400
},
{
id: '5',
title: 'NTFSPlus Becomes "NTFS" as Driver Moves Closer to Kernel Integration',
postType: 'link',
domain: 'itsfoss.com',
thumbnail: 'https://picsum.photos/seed/ntfs/200',
author: 'cm0002',
community: 'Linux',
timeAgo: '1 hour ago',
commentCount: 0,
votes: { score: 32, upvotes: 35, downvotes: 3, userVote: null },
flair: 'News',
created_at: Date.now() / 1000 - 3600
},
{
id: '6',
title: 'ReactOS Starts 2026 With Another "Major Step" Toward Windows NT6 Compatibility',
postType: 'link',
domain: 'phoronix.com',
thumbnail: 'https://picsum.photos/seed/reactos/200',
author: 'cm0002',
community: 'Linux',
timeAgo: '49 minutes ago',
commentCount: 0,
votes: { score: 15, upvotes: 17, downvotes: 2, userVote: null },
created_at: Date.now() / 1000 - 2940
},
{
id: '7',
title: 'Check out this mass adoption happening at a Bitcoin meetup',
postType: 'media',
thumbnail: 'https://picsum.photos/seed/bitcoin/200',
author: 'satoshi_fan',
community: 'Bitcoin',
timeAgo: '3 hours ago',
commentCount: 42,
votes: { score: 156, upvotes: 180, downvotes: 24, userVote: null },
created_at: Date.now() / 1000 - 10800
},
{
id: '8',
title: 'NSFW content should be behind a toggle [Meta Discussion]',
postType: 'self',
author: 'moderator_joe',
community: 'Meta',
timeAgo: '2 days ago',
commentCount: 89,
votes: { score: 234, upvotes: 290, downvotes: 56, userVote: null },
nsfw: true,
created_at: Date.now() / 1000 - 172800
}
])
// Sort mock submissions
const sortedMockSubmissions = computed(() => {
const sorted = [...mockSubmissions.value]
switch (currentSort.value) {
case 'new':
return sorted.sort((a, b) => b.created_at - a.created_at)
case 'top':
return sorted.sort((a, b) => b.votes.score - a.votes.score)
case 'controversial':
// Balance between up and down votes
const controversy = (s: MockSubmission) => {
const total = s.votes.upvotes + s.votes.downvotes
if (total === 0) return 0
const balance = Math.min(s.votes.upvotes, s.votes.downvotes) / Math.max(s.votes.upvotes, s.votes.downvotes)
return Math.pow(total, 0.8) * balance
}
return sorted.sort((a, b) => controversy(b) - controversy(a))
case 'hot':
default:
// Hot rank: score + recency
const hotRank = (s: MockSubmission) => {
const order = Math.log10(Math.max(Math.abs(s.votes.score), 1))
const sign = s.votes.score > 0 ? 1 : s.votes.score < 0 ? -1 : 0
return sign * order + s.created_at / 45000
}
return sorted.sort((a, b) => hotRank(b) - hotRank(a))
}
})
// Mock vote handlers
function onMockUpvote(submission: MockSubmission) {
if (submission.votes.userVote === 'upvote') {
// Remove upvote
submission.votes.score -= 1
submission.votes.upvotes -= 1
submission.votes.userVote = null
} else {
// Add upvote (remove downvote if exists)
if (submission.votes.userVote === 'downvote') {
submission.votes.score += 1
submission.votes.downvotes -= 1
}
submission.votes.score += 1
submission.votes.upvotes += 1
submission.votes.userVote = 'upvote'
}
}
function onMockDownvote(submission: MockSubmission) {
if (submission.votes.userVote === 'downvote') {
// Remove downvote
submission.votes.score += 1
submission.votes.downvotes -= 1
submission.votes.userVote = null
} else {
// Add downvote (remove upvote if exists)
if (submission.votes.userVote === 'upvote') {
submission.votes.score -= 1
submission.votes.upvotes -= 1
}
submission.votes.score -= 1
submission.votes.downvotes += 1
submission.votes.userVote = 'downvote'
}
}
// Real submission click handler
function onSubmissionClick(submission: any) {
console.log('Clicked submission:', submission)
// TODO: Navigate to detail view
}
</script>