Implement Note Composer and enhance NostrFeed interactions

- Added NoteComposer.vue for composing notes, including reply functionality and mention support.
- Integrated NoteComposer into Home.vue, allowing users to publish notes and reply to existing ones.
- Enhanced NostrFeed.vue to handle reply events, improving user engagement with notes.
- Updated Home.vue to manage note publishing and reply states effectively.

These changes enhance the user experience by providing a seamless way to compose and interact with notes within the feed.

Enhance NostrFeed and Home components for improved user experience

- Added compact mode support in NostrFeed.vue to optimize display for mobile users.
- Refactored layout in NostrFeed.vue for better responsiveness and usability, including adjustments to padding and spacing.
- Updated Home.vue to integrate a collapsible filter panel and a floating action button for composing notes, enhancing accessibility and interaction.
- Implemented quick filter presets for mobile, allowing users to easily switch between content types.

These changes improve the overall functionality and user engagement within the feed, providing a more streamlined experience across devices.

Enhance NoteComposer and Home components for improved note management

- Updated NoteComposer.vue to include a close button for better user control when composing notes.
- Modified Home.vue to handle the close event from NoteComposer, allowing users to dismiss the composer easily.

These changes enhance the user experience by providing a more intuitive interface for composing and managing notes.
This commit is contained in:
padreug 2025-09-16 23:08:10 +02:00
parent 77ed913e1b
commit 2d0aadccb7
3 changed files with 634 additions and 128 deletions

View file

@ -1,24 +1,29 @@
<script setup lang="ts">
import { computed } 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, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import appConfig from '@/app.config'
import type { ContentFilter } from '../services/FeedService'
import MarketProduct from './MarketProduct.vue'
import { parseMarketProduct, isMarketEvent, getMarketEventType } from '../utils/marketParser'
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
}
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
contentFilters?: ContentFilter[]
adminPubkeys?: string[]
compactMode?: boolean
}>()
const emit = defineEmits<Emits>()
// Get admin/moderator pubkeys from props or app config
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
@ -92,33 +97,42 @@ function onShareProduct(productId: string) {
console.log('Share product:', productId)
// TODO: Implement sharing functionality
}
// Handle reply to note
function onReplyToNote(note: any) {
emit('reply-to-note', {
id: note.id,
content: note.content,
pubkey: note.pubkey
})
}
</script>
<template>
<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 class="flex flex-col h-full">
<!-- Compact Header (only in non-compact mode) -->
<div v-if="!compactMode" class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-2">
<Megaphone class="h-5 w-5 text-primary" />
<div>
<h2 class="text-lg font-semibold">{{ feedTitle }}</h2>
<p class="text-sm text-muted-foreground">{{ feedDescription }}</p>
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
Refresh
</Button>
</div>
</CardHeader>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
Refresh
</Button>
</div>
<CardContent>
<!-- Feed Content Container -->
<div class="flex-1 overflow-hidden">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center gap-2">
@ -128,7 +142,7 @@ function onShareProduct(productId: string) {
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<div v-else-if="error" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
<AlertCircle class="h-5 w-5" />
<span>Failed to load feed</span>
@ -138,7 +152,7 @@ function onShareProduct(productId: string) {
</div>
<!-- No Admin Pubkeys Warning -->
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8">
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8 px-4">
<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>
@ -148,8 +162,8 @@ function onShareProduct(productId: string) {
</p>
</div>
<!-- No Notes -->
<div v-else-if="notes.length === 0" class="text-center py-8">
<!-- No Posts -->
<div v-else-if="notes.length === 0" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
@ -159,55 +173,14 @@ function onShareProduct(productId: string) {
</p>
</div>
<!-- Posts List -->
<ScrollArea v-else class="h-[400px] pr-4">
<div class="space-y-4">
<!-- Posts List - Full height scroll -->
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div class="divide-y divide-border">
<div v-for="note in notes" :key="note.id">
<!-- Market Product Component (kind 30018) -->
<template v-if="note.kind === 30018">
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
class="text-xs"
>
Admin
</Badge>
<span>{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}</span>
</div>
<MarketProduct
v-if="getMarketProductData(note)"
:product="getMarketProductData(note)!"
@view-product="onViewProduct"
@share-product="onShareProduct"
/>
<!-- Fallback for invalid market data -->
<div
v-else
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors border-destructive/20"
>
<div class="flex items-center gap-2 mb-2">
<Badge variant="destructive" class="text-xs">
Invalid Market Product
</Badge>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<div class="text-sm text-muted-foreground">
Unable to parse market product data
</div>
</div>
</template>
<!-- Regular Text Posts and Other Event Types -->
<div
v-else
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">
<div class="p-3">
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
@ -215,24 +188,65 @@ function onShareProduct(productId: string) {
>
Admin
</Badge>
<Badge
v-if="note.isReply"
variant="secondary"
class="text-xs"
>
Reply
</Badge>
<Badge
v-if="isMarketEvent({ kind: note.kind })"
variant="outline"
class="text-xs"
>
{{ getMarketEventType({ kind: note.kind }) }}
</Badge>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
<span>{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}</span>
</div>
<MarketProduct
v-if="getMarketProductData(note)"
:product="getMarketProductData(note)!"
@view-product="onViewProduct"
@share-product="onShareProduct"
/>
<!-- Fallback for invalid market data -->
<div
v-else
class="p-3 border rounded-lg border-destructive/20"
>
<div class="flex items-center gap-2 mb-2">
<Badge variant="destructive" class="text-xs">
Invalid Market Product
</Badge>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<div class="text-sm text-muted-foreground">
Unable to parse market product data
</div>
</div>
</div>
</template>
<!-- Regular Text Posts and Other Event Types -->
<div
v-else
class="p-3 hover:bg-accent/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-center flex-wrap gap-1.5 mb-2">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
class="text-xs px-1.5 py-0.5"
>
Admin
</Badge>
<Badge
v-if="note.isReply"
variant="secondary"
class="text-xs px-1.5 py-0.5"
>
Reply
</Badge>
<Badge
v-if="isMarketEvent({ kind: note.kind })"
variant="outline"
class="text-xs px-1.5 py-0.5"
>
{{ getMarketEventType({ kind: note.kind }) }}
</Badge>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<!-- Note Content -->
@ -240,22 +254,54 @@ function onShareProduct(productId: string) {
{{ 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>
<!-- Note Actions -->
<div class="mt-2 pt-2 border-t">
<div class="flex items-center justify-between">
<!-- Mentions -->
<div v-if="note.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in note.mentions.slice(0, 2)" :key="mention" class="font-mono">
{{ mention.slice(0, 6) }}...
</span>
<span v-if="note.mentions.length > 2" class="text-muted-foreground">
+{{ note.mentions.length - 2 }} more
</span>
</div>
<!-- Action Buttons - Compact on mobile -->
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
@click="onReplyToNote(note)"
>
<Reply class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Reply</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
>
<Heart class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Like</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
>
<Share class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Share</span>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</template>