refactor: Remove unused components and clean up identity management

- Delete IdentityDialog.vue, useIdentity.ts, useNostr.ts, useNostrFeed.ts, useNostrFeedPreloader.ts, useSocial.ts, and related Nostr client files to streamline the codebase.
- Consolidate identity management and feed handling logic to improve maintainability and reduce complexity.
- Ensure that the application remains functional while enhancing overall clarity for future development.
This commit is contained in:
padreug 2025-08-13 10:11:28 +02:00
parent 06bcc4b91e
commit b074cc4ca3
11 changed files with 299 additions and 2184 deletions

View file

@ -1,467 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Key, Download, Upload, Copy, Check } from 'lucide-vue-next'
import { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'
interface Props {
isOpen: boolean
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
// Form states
const importKey = ref('')
const usePassword = ref(false)
const password = ref('')
const confirmPassword = ref('')
const showPrivateKey = ref(false)
const copied = ref(false)
// Profile form
const profileForm = ref({
name: '',
display_name: '',
about: '',
picture: '',
website: '',
lud16: ''
})
const passwordsMatch = computed(() => {
if (!usePassword.value) return true
return password.value === confirmPassword.value
})
const canGenerate = computed(() => {
if (!usePassword.value) return true
return password.value.length >= 6 && passwordsMatch.value
})
const canImport = computed(() => {
return importKey.value.trim().length > 0 && canGenerate.value
})
async function handleGenerate() {
try {
const pwd = usePassword.value ? password.value : undefined
await identity.generateNewIdentity(pwd)
// Initialize profile form with current data
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
toast.success('Identity generated successfully!')
} catch (error) {
console.error('Failed to generate identity:', error)
toast.error('Failed to generate identity')
}
}
async function handleImport() {
try {
const pwd = usePassword.value ? password.value : undefined
await identity.importIdentity(importKey.value.trim(), pwd)
// Initialize profile form with current data
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
toast.success('Identity imported successfully!')
} catch (error) {
console.error('Failed to import identity:', error)
toast.error('Failed to import identity')
}
}
async function handleUpdateProfile() {
try {
// Filter out empty values
const profileData = Object.fromEntries(
Object.entries(profileForm.value).filter(([_, value]) => value.trim() !== '')
)
await identity.updateProfile(profileData)
toast.success('Profile updated successfully!')
} catch (error) {
console.error('Failed to update profile:', error)
toast.error('Failed to update profile')
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => copied.value = false, 2000)
toast.success('Copied to clipboard!')
} catch (error) {
toast.error('Failed to copy to clipboard')
}
}
function handleClose() {
emit('update:isOpen', false)
// Reset forms
importKey.value = ''
password.value = ''
confirmPassword.value = ''
usePassword.value = false
showPrivateKey.value = false
}
// Initialize profile form when dialog opens
function initializeProfileForm() {
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Key class="w-5 h-5" />
Nostr Identity
</DialogTitle>
<DialogDescription>
Manage your Nostr identity and profile
</DialogDescription>
</DialogHeader>
<Tabs default-value="identity" class="w-full" @update:model-value="initializeProfileForm">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="profile" :disabled="!identity.isAuthenticated.value">
Profile
</TabsTrigger>
</TabsList>
<TabsContent value="identity" class="space-y-4">
<!-- Current Identity Display -->
<div v-if="identity.isAuthenticated.value" class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="text-lg">Current Identity</CardTitle>
<CardDescription>Your active Nostr identity</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<Label class="text-sm font-medium">Public Key (npub)</Label>
<div class="flex items-center gap-2">
<Input
:model-value="identity.identityInfo.value?.npub || ''"
readonly
class="font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
@click="copyToClipboard(identity.identityInfo.value?.npub || '')"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">Private Key (nsec)</Label>
<Button
size="sm"
variant="ghost"
@click="showPrivateKey = !showPrivateKey"
>
{{ showPrivateKey ? 'Hide' : 'Show' }}
</Button>
</div>
<div class="flex items-center gap-2">
<Input
:type="showPrivateKey ? 'text' : 'password'"
:model-value="identity.currentIdentity.value?.nsec || ''"
readonly
class="font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
@click="copyToClipboard(identity.currentIdentity.value?.nsec || '')"
>
<component :is="copied ? Check : Copy" class="w-4 h-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
Keep your private key safe! Anyone with access can control your identity.
</p>
</div>
<Button variant="destructive" size="sm" @click="identity.signOut()">
Sign Out
</Button>
</CardContent>
</Card>
</div>
<!-- Identity Setup -->
<div v-else class="space-y-4">
<Tabs default-value="generate" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="generate">
<Download class="w-4 h-4 mr-2" />
Generate New
</TabsTrigger>
<TabsTrigger value="import">
<Upload class="w-4 h-4 mr-2" />
Import Existing
</TabsTrigger>
</TabsList>
<TabsContent value="generate" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generate New Identity</CardTitle>
<CardDescription>
Create a new Nostr identity with a fresh keypair
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center space-x-2">
<input
id="use-password"
type="checkbox"
v-model="usePassword"
class="rounded"
/>
<Label for="use-password">Encrypt with password</Label>
</div>
<div v-if="usePassword" class="space-y-3">
<div>
<Label for="password">Password</Label>
<Input
id="password"
type="password"
v-model="password"
placeholder="Enter password (min 6 characters)"
/>
</div>
<div>
<Label for="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
v-model="confirmPassword"
placeholder="Confirm password"
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
/>
<p v-if="!passwordsMatch && confirmPassword.length > 0" class="text-xs text-destructive mt-1">
Passwords do not match
</p>
</div>
</div>
<Button
@click="handleGenerate"
:disabled="!canGenerate || identity.isLoading.value"
class="w-full"
>
Generate Identity
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="import" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Import Existing Identity</CardTitle>
<CardDescription>
Import your existing Nostr identity using your private key
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label for="import-key">Private Key</Label>
<Textarea
id="import-key"
v-model="importKey"
placeholder="Enter your private key (hex format or nsec...)"
class="font-mono text-xs"
/>
</div>
<div class="flex items-center space-x-2">
<input
id="use-password-import"
type="checkbox"
v-model="usePassword"
class="rounded"
/>
<Label for="use-password-import">Encrypt with password</Label>
</div>
<div v-if="usePassword" class="space-y-3">
<div>
<Label for="password-import">Password</Label>
<Input
id="password-import"
type="password"
v-model="password"
placeholder="Enter password (min 6 characters)"
/>
</div>
<div>
<Label for="confirm-password-import">Confirm Password</Label>
<Input
id="confirm-password-import"
type="password"
v-model="confirmPassword"
placeholder="Confirm password"
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
/>
</div>
</div>
<Button
@click="handleImport"
:disabled="!canImport || identity.isLoading.value"
class="w-full"
>
Import Identity
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</TabsContent>
<TabsContent value="profile" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your public Nostr profile information
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="name">Name</Label>
<Input
id="name"
v-model="profileForm.name"
placeholder="Your name"
/>
</div>
<div>
<Label for="display-name">Display Name</Label>
<Input
id="display-name"
v-model="profileForm.display_name"
placeholder="Display name"
/>
</div>
</div>
<div>
<Label for="about">About</Label>
<Textarea
id="about"
v-model="profileForm.about"
placeholder="Tell people about yourself..."
rows="3"
/>
</div>
<div>
<Label for="picture">Picture URL</Label>
<Input
id="picture"
v-model="profileForm.picture"
placeholder="https://example.com/your-avatar.jpg"
type="url"
/>
</div>
<div>
<Label for="website">Website</Label>
<Input
id="website"
v-model="profileForm.website"
placeholder="https://your-website.com"
type="url"
/>
</div>
<div>
<Label for="lightning">Lightning Address</Label>
<Input
id="lightning"
v-model="profileForm.lud16"
placeholder="you@getalby.com"
/>
<p class="text-xs text-muted-foreground mt-1">
Lightning address for receiving zaps
</p>
</div>
<Button
@click="handleUpdateProfile"
:disabled="identity.isLoading.value"
class="w-full"
>
Update Profile
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div v-if="identity.error.value" class="text-sm text-destructive">
{{ identity.error.value }}
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -1,87 +1,35 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone } from 'lucide-vue-next'
import { config } from '@/lib/config'
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { config, configUtils } from '@/lib/config'
import { useRelayHub } from '@/composables/useRelayHub'
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general'
}>()
const feedPreloader = useNostrFeedPreloader({
relays: props.relays,
feedType: props.feedType,
limit: 50,
includeReplies: false
})
const relayHub = useRelayHub()
const {
notes,
isLoading,
error,
isPreloading,
isPreloaded,
preloadError,
shouldShowLoading,
loadNotes,
connectToFeed,
subscribeToFeedUpdates,
cleanup
} = feedPreloader
// Check if we need to load feed data
const needsToLoadFeed = computed(() => {
return !isPreloaded.value &&
!isPreloading.value &&
notes.value.length === 0
})
const loadFeed = async () => {
try {
console.log('Connecting to feed...')
await connectToFeed()
console.log('Connected to feed')
console.log('Loading feed...')
await loadNotes()
console.log('Feed loaded')
// Subscribe to real-time updates
subscribeToFeedUpdates()
} catch (error) {
console.error('Failed to load feed:', error)
}
}
const retryLoadFeed = async () => {
try {
console.log('Refreshing feed...')
await connectToFeed()
await loadNotes(true) // Pass true to indicate this is a refresh
console.log('Feed refreshed')
// Subscribe to real-time updates
subscribeToFeedUpdates()
} catch (error) {
console.error('Failed to refresh feed:', error)
}
}
// Reactive state
const notes = ref<any[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
const isConnected = ref(false)
// Get admin/moderator pubkeys from centralized config
const adminPubkeys = config.nostr.adminPubkeys
function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
// Check if we have admin pubkeys configured
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
function getFeedTitle(): string {
// Get feed title and description based on type
const feedTitle = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Community Announcements'
@ -92,9 +40,9 @@ function getFeedTitle(): string {
default:
return 'Community Feed'
}
}
})
function getFeedDescription(): string {
const feedDescription = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
@ -105,108 +53,284 @@ function getFeedDescription(): string {
default:
return 'Latest posts from the community'
}
})
// Check if a post is from an admin
function isAdminPost(pubkey: string): boolean {
return configUtils.isAdminPubkey(pubkey)
}
onMounted(() => {
// Only load feed if it hasn't been preloaded
if (needsToLoadFeed.value) {
console.log('Feed not preloaded, loading now...')
loadFeed()
} else if (isPreloaded.value) {
console.log('Feed was preloaded, subscribing to updates...')
// Subscribe to real-time updates if feed was preloaded
subscribeToFeedUpdates()
} else {
console.log('Feed data is ready, no additional loading needed')
// Load notes from relays
async function loadNotes() {
if (!hasAdminPubkeys.value && props.feedType === 'announcements') {
notes.value = []
return
}
try {
isLoading.value = true
error.value = null
// Connect to relay hub if not already connected
if (!relayHub.isConnected.value) {
await relayHub.connect()
}
isConnected.value = relayHub.isConnected.value
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
}
// Configure filters based on feed type
const filters: any[] = [{
kinds: [1], // TEXT_NOTE
limit: 50
}]
// Filter by authors for announcements
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
filters[0].authors = adminPubkeys
}
// Query events from relays
const events = await relayHub.queryEvents(filters)
// Process and filter events
let processedNotes = events.map(event => ({
id: event.id,
pubkey: event.pubkey,
content: event.content,
created_at: event.created_at,
tags: event.tags || [],
// Extract mentions from tags
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
// Check if it's a reply
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
}))
// Sort by creation time (newest first)
processedNotes.sort((a, b) => b.created_at - a.created_at)
// For general feed, exclude admin posts
if (props.feedType === 'general' && hasAdminPubkeys.value) {
processedNotes = processedNotes.filter(note => !isAdminPost(note.pubkey))
}
notes.value = processedNotes
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to load notes')
error.value = errorObj
console.error('Failed to load notes:', errorObj)
} finally {
isLoading.value = false
}
}
// Refresh the feed
async function refreshFeed() {
await loadNotes()
}
// Subscribe to real-time updates
let unsubscribe: (() => void) | null = null
async function startRealtimeSubscription() {
if (!relayHub.isConnected.value) return
try {
const filters: any[] = [{
kinds: [1], // TEXT_NOTE
limit: 10
}]
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
filters[0].authors = adminPubkeys
}
unsubscribe = relayHub.subscribe({
id: `feed-${props.feedType || 'all'}`,
filters,
onEvent: (event) => {
// Add new note to the beginning of the list
const newNote = {
id: event.id,
pubkey: event.pubkey,
content: event.content,
created_at: event.created_at,
tags: event.tags || [],
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
}
// Check if note should be included
let shouldInclude = true
if (props.feedType === 'announcements' && !isAdminPost(event.pubkey)) {
shouldInclude = false
}
if (props.feedType === 'general' && isAdminPost(event.pubkey)) {
shouldInclude = false
}
if (shouldInclude) {
notes.value.unshift(newNote)
// Limit array size
if (notes.value.length > 100) {
notes.value = notes.value.slice(0, 100)
}
}
}
})
} catch (error) {
console.error('Failed to start real-time subscription:', error)
}
}
// Cleanup subscription
function cleanup() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
onMounted(async () => {
await loadNotes()
await startRealtimeSubscription()
})
onUnmounted(() => {
cleanup()
})
function formatDate(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
</script>
<template>
<div class="w-full max-w-3xl mx-auto">
<Card class="w-full">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Megaphone v-if="feedType === 'announcements'" class="h-5 w-5" />
{{ getFeedTitle() }}
</CardTitle>
<CardDescription>{{ getFeedDescription() }}</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="shouldShowLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<Card class="w-full">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Megaphone class="h-5 w-5 text-primary" />
<div>
<CardTitle>{{ feedTitle }}</CardTitle>
<CardDescription>{{ feedDescription }}</CardDescription>
</div>
<div v-else-if="error || preloadError" class="text-center py-8 text-destructive">
{{ (error || preloadError)?.message }}
</div>
<div v-else-if="notes.length === 0 && !isLoading && !isPreloading" class="text-center py-8 text-muted-foreground">
<div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-2">
<p>No admin pubkeys configured</p>
<p class="text-xs">Set VITE_ADMIN_PUBKEYS environment variable</p>
</div>
<p v-else>No {{ feedType || 'notes' }} found</p>
</div>
<div v-else-if="notes.length > 0" class="space-y-4">
<Card
v-for="note in notes"
:key="note.id"
:class="[
'p-4 transition-all',
isAdminPost(note.pubkey) && feedType !== 'announcements'
? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
: ''
]"
>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span>
<Badge
v-if="isAdminPost(note.pubkey)"
variant="secondary"
class="text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
>
<Megaphone class="h-3 w-3 mr-1" />
Admin
</Badge>
</div>
<span class="text-xs text-muted-foreground">{{ formatDate(note.created_at) }}</span>
</div>
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span>{{ note.replyCount }} replies</span>
<span>{{ note.reactionCount }} reactions</span>
</div>
</div>
</Card>
</div>
</ScrollArea>
</CardContent>
<CardFooter class="flex justify-between">
<button
class="text-sm text-primary hover:underline flex items-center gap-2"
:disabled="isLoading || isPreloading"
@click="retryLoadFeed"
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2"
>
<span v-if="isLoading || isPreloading" class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></span>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
Refresh
</button>
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
{{ adminPubkeys.length }} admin(s) configured
</span>
</CardFooter>
</Card>
</div>
</template>
</Button>
</div>
</CardHeader>
<CardContent>
<!-- Connection Status -->
<div v-if="!isConnected && !isLoading" class="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<AlertCircle class="h-4 w-4" />
<span class="text-sm">Not connected to relays</span>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin" />
<span class="text-muted-foreground">Loading announcements...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
<AlertCircle class="h-5 w-5" />
<span>Failed to load announcements</span>
</div>
<p class="text-sm text-muted-foreground mb-4">{{ error.message }}</p>
<Button @click="refreshFeed" variant="outline">Try Again</Button>
</div>
<!-- No Admin Pubkeys Warning -->
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No admin pubkeys configured</span>
</div>
<p class="text-sm text-muted-foreground">
Community announcements will appear here once admin pubkeys are configured.
</p>
</div>
<!-- No Notes -->
<div v-else-if="notes.length === 0" class="text-center py-8">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No announcements yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates and announcements.
</p>
</div>
<!-- Notes List -->
<ScrollArea v-else class="h-[400px] pr-4">
<div class="space-y-4">
<div
v-for="note in notes"
:key="note.id"
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
class="text-xs"
>
Admin
</Badge>
<Badge
v-if="note.isReply"
variant="secondary"
class="text-xs"
>
Reply
</Badge>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
</div>
<!-- Note Content -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ note.content }}
</div>
<!-- Note Footer -->
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
{{ mention.slice(0, 8) }}...
</span>
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
+{{ note.mentions.length - 3 }} more
</span>
</div>
</div>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</template>