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:
parent
77ed913e1b
commit
2d0aadccb7
3 changed files with 634 additions and 128 deletions
|
|
@ -1,24 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
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 { useFeed } from '../composables/useFeed'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter } from '../services/FeedService'
|
import type { ContentFilter } from '../services/FeedService'
|
||||||
import MarketProduct from './MarketProduct.vue'
|
import MarketProduct from './MarketProduct.vue'
|
||||||
import { parseMarketProduct, isMarketEvent, getMarketEventType } from '../utils/marketParser'
|
import { parseMarketProduct, isMarketEvent, getMarketEventType } from '../utils/marketParser'
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
|
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
|
||||||
contentFilters?: ContentFilter[]
|
contentFilters?: ContentFilter[]
|
||||||
adminPubkeys?: string[]
|
adminPubkeys?: string[]
|
||||||
|
compactMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// Get admin/moderator pubkeys from props or app config
|
// Get admin/moderator pubkeys from props or app config
|
||||||
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
||||||
|
|
||||||
|
|
@ -92,33 +97,42 @@ function onShareProduct(productId: string) {
|
||||||
console.log('Share product:', productId)
|
console.log('Share product:', productId)
|
||||||
// TODO: Implement sharing functionality
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="w-full">
|
<div class="flex flex-col h-full">
|
||||||
<CardHeader>
|
<!-- Compact Header (only in non-compact mode) -->
|
||||||
<div class="flex items-center justify-between">
|
<div v-if="!compactMode" class="flex items-center justify-between p-4 border-b">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Megaphone class="h-5 w-5 text-primary" />
|
<Megaphone class="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{{ feedTitle }}</CardTitle>
|
<h2 class="text-lg font-semibold">{{ feedTitle }}</h2>
|
||||||
<CardDescription>{{ feedDescription }}</CardDescription>
|
<p class="text-sm text-muted-foreground">{{ feedDescription }}</p>
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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 -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -128,7 +142,7 @@ function onShareProduct(productId: string) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- 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">
|
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
|
||||||
<AlertCircle class="h-5 w-5" />
|
<AlertCircle class="h-5 w-5" />
|
||||||
<span>Failed to load feed</span>
|
<span>Failed to load feed</span>
|
||||||
|
|
@ -138,7 +152,7 @@ function onShareProduct(productId: string) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Admin Pubkeys Warning -->
|
<!-- 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">
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
<Megaphone class="h-5 w-5" />
|
<Megaphone class="h-5 w-5" />
|
||||||
<span>No admin pubkeys configured</span>
|
<span>No admin pubkeys configured</span>
|
||||||
|
|
@ -148,8 +162,8 @@ function onShareProduct(productId: string) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Notes -->
|
<!-- No Posts -->
|
||||||
<div v-else-if="notes.length === 0" class="text-center py-8">
|
<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">
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
<Megaphone class="h-5 w-5" />
|
<Megaphone class="h-5 w-5" />
|
||||||
<span>No posts yet</span>
|
<span>No posts yet</span>
|
||||||
|
|
@ -159,55 +173,14 @@ function onShareProduct(productId: string) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Posts List -->
|
<!-- Posts List - Full height scroll -->
|
||||||
<ScrollArea v-else class="h-[400px] pr-4">
|
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<div class="space-y-4">
|
<div class="divide-y divide-border">
|
||||||
<div v-for="note in notes" :key="note.id">
|
<div v-for="note in notes" :key="note.id">
|
||||||
<!-- Market Product Component (kind 30018) -->
|
<!-- Market Product Component (kind 30018) -->
|
||||||
<template v-if="note.kind === 30018">
|
<template v-if="note.kind === 30018">
|
||||||
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
<div class="p-3">
|
||||||
<Badge
|
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
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">
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isAdminPost(note.pubkey)"
|
v-if="isAdminPost(note.pubkey)"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|
@ -215,24 +188,65 @@ function onShareProduct(productId: string) {
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<span>{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}</span>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Note Content -->
|
<!-- Note Content -->
|
||||||
|
|
@ -240,22 +254,54 @@ function onShareProduct(productId: string) {
|
||||||
{{ note.content }}
|
{{ note.content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note Footer -->
|
<!-- Note Actions -->
|
||||||
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
|
<div class="mt-2 pt-2 border-t">
|
||||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
<div class="flex items-center justify-between">
|
||||||
<span>Mentions:</span>
|
<!-- Mentions -->
|
||||||
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
|
<div v-if="note.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
{{ mention.slice(0, 8) }}...
|
<span>Mentions:</span>
|
||||||
</span>
|
<span v-for="mention in note.mentions.slice(0, 2)" :key="mention" class="font-mono">
|
||||||
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
|
{{ mention.slice(0, 6) }}...
|
||||||
+{{ note.mentions.length - 3 }} more
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
337
src/modules/nostr-feed/components/NoteComposer.vue
Normal file
337
src/modules/nostr-feed/components/NoteComposer.vue
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
<template>
|
||||||
|
<Card class="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<Edit3 class="h-5 w-5" />
|
||||||
|
Compose Note
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" @click="emit('close')" class="h-8 w-8 p-0">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Share your thoughts with the community
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form @submit="onSubmit" class="space-y-4">
|
||||||
|
<!-- Note Content -->
|
||||||
|
<FormField v-slot="{ componentField }" name="content">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Message *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
class="min-h-[120px] resize-y"
|
||||||
|
v-bind="componentField"
|
||||||
|
:maxlength="280"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<FormDescription>
|
||||||
|
Share your thoughts, updates, or announcements
|
||||||
|
</FormDescription>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ characterCount }}/280
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Reply To (if replying) -->
|
||||||
|
<div v-if="replyTo" class="p-3 border rounded-lg bg-muted/50">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Reply class="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm text-muted-foreground">Replying to</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="clearReply">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm truncate">
|
||||||
|
{{ replyTo.content.slice(0, 100) }}{{ replyTo.content.length > 100 ? '...' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mentions -->
|
||||||
|
<FormField name="mentions">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mentions (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Input
|
||||||
|
v-model="newMention"
|
||||||
|
placeholder="Add npub or hex pubkey"
|
||||||
|
@keydown.enter.prevent="addMention"
|
||||||
|
/>
|
||||||
|
<div v-if="mentions.length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="(mention, index) in mentions"
|
||||||
|
:key="index"
|
||||||
|
variant="secondary"
|
||||||
|
class="gap-1"
|
||||||
|
>
|
||||||
|
{{ formatPubkey(mention) }}
|
||||||
|
<button @click="removeMention(index)" class="ml-1 hover:text-destructive">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Press Enter to add mentions. Use npub format or hex pubkeys.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-4 border-t">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Globe class="h-4 w-4" />
|
||||||
|
<span>Public post</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="replyTo"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="clearReply"
|
||||||
|
>
|
||||||
|
Cancel Reply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isPublishing || !isFormValid"
|
||||||
|
class="gap-2"
|
||||||
|
>
|
||||||
|
<Send class="h-4 w-4" />
|
||||||
|
{{ isPublishing ? 'Publishing...' : 'Publish Note' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Edit3, Send, Globe, Reply, X } from 'lucide-vue-next'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
||||||
|
|
||||||
|
export interface ReplyToNote {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
replyTo?: ReplyToNote
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'note-published', noteId: string): void
|
||||||
|
(e: 'clear-reply'): void
|
||||||
|
(e: 'close'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const isPublishing = ref(false)
|
||||||
|
const mentions = ref<string[]>([])
|
||||||
|
const newMention = ref('')
|
||||||
|
|
||||||
|
// Form schema
|
||||||
|
const formSchema = toTypedSchema(z.object({
|
||||||
|
content: z.string()
|
||||||
|
.min(1, "Content is required")
|
||||||
|
.max(280, "Content must be 280 characters or less"),
|
||||||
|
mentions: z.array(z.string()).optional()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Form setup
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
initialValues: {
|
||||||
|
content: '',
|
||||||
|
mentions: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { values, meta, resetForm, setFieldValue } = form
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const isFormValid = computed(() => meta.value.valid && values.content.trim().length > 0)
|
||||||
|
const characterCount = computed(() => values.content?.length || 0)
|
||||||
|
|
||||||
|
// Watch mentions array and sync with form
|
||||||
|
watch(mentions, (newMentions) => {
|
||||||
|
setFieldValue('mentions', newMentions)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatPubkey = (pubkey: string): string => {
|
||||||
|
if (pubkey.startsWith('npub')) {
|
||||||
|
return pubkey.slice(0, 12) + '...'
|
||||||
|
}
|
||||||
|
return pubkey.slice(0, 8) + '...' + pubkey.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMention = () => {
|
||||||
|
const mention = newMention.value.trim()
|
||||||
|
if (mention && !mentions.value.includes(mention)) {
|
||||||
|
// Basic validation for pubkey format
|
||||||
|
if (mention.startsWith('npub') || (mention.length === 64 && /^[0-9a-f]+$/i.test(mention))) {
|
||||||
|
mentions.value.push(mention)
|
||||||
|
newMention.value = ''
|
||||||
|
} else {
|
||||||
|
toast.error("Invalid pubkey format - please use npub format or 64-character hex pubkey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMention = (index: number) => {
|
||||||
|
mentions.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearReply = () => {
|
||||||
|
emit('clear-reply')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert npub to hex if needed
|
||||||
|
const convertPubkeyToHex = (pubkey: string): string => {
|
||||||
|
if (pubkey.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
// This would need proper bech32 decoding - for now return as is
|
||||||
|
// In a real implementation, use nostr-tools nip19.decode
|
||||||
|
return pubkey // Placeholder - needs proper conversion
|
||||||
|
} catch {
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert hex string to Uint8Array
|
||||||
|
const 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
if (!authService?.isAuthenticated.value || !authService?.user.value) {
|
||||||
|
toast.error("Please sign in to publish notes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayHub?.isConnected.value) {
|
||||||
|
toast.error("Not connected to Nostr relays")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPubkey = authService.user.value.pubkey
|
||||||
|
const userPrivkey = authService.user.value.prvkey
|
||||||
|
|
||||||
|
if (!userPubkey || !userPrivkey) {
|
||||||
|
toast.error("User keys not available - please sign in again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPublishing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build tags array
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
// Add reply tags if replying
|
||||||
|
if (props.replyTo) {
|
||||||
|
tags.push(['e', props.replyTo.id, '', 'reply'])
|
||||||
|
tags.push(['p', props.replyTo.pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mention tags
|
||||||
|
mentions.value.forEach(mention => {
|
||||||
|
const hexPubkey = convertPubkeyToHex(mention)
|
||||||
|
tags.push(['p', hexPubkey])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create note event template
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: 1,
|
||||||
|
content: values.content.trim(),
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating note event:', eventTemplate)
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const privkeyBytes = hexToUint8Array(userPrivkey)
|
||||||
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||||
|
|
||||||
|
console.log('Publishing signed note:', signedEvent)
|
||||||
|
|
||||||
|
// Publish the note
|
||||||
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
|
|
||||||
|
toast.success(`Note published to ${result.success}/${result.total} relays!`)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
resetForm()
|
||||||
|
mentions.value = []
|
||||||
|
|
||||||
|
// Emit success event
|
||||||
|
emit('note-published', signedEvent.id)
|
||||||
|
|
||||||
|
console.log('Note published with ID:', signedEvent.id)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish note:', error)
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to publish note")
|
||||||
|
} finally {
|
||||||
|
isPublishing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resize-y {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,32 +1,92 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8 space-y-6">
|
<div class="flex flex-col h-screen bg-background">
|
||||||
<PWAInstallPrompt auto-show />
|
<PWAInstallPrompt auto-show />
|
||||||
<!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog -->
|
<!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog -->
|
||||||
<!-- <NotificationPermission auto-show /> -->
|
<!-- <NotificationPermission auto-show /> -->
|
||||||
|
|
||||||
<!-- Feed Filter Controls -->
|
<!-- Compact Header with Filters Toggle (Mobile) -->
|
||||||
<Card>
|
<div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||||
<CardHeader>
|
<div class="flex items-center justify-between px-4 py-2 sm:px-6">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<h1 class="text-lg font-semibold">Feed</h1>
|
||||||
<Filter class="h-5 w-5" />
|
<div class="flex items-center gap-2">
|
||||||
Content Filters
|
<!-- Active Filter Indicator -->
|
||||||
</CardTitle>
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<CardDescription>
|
<span v-if="activeFilterCount > 0">{{ activeFilterCount }} filters</span>
|
||||||
Choose what types of content you want to see in your feed
|
<span v-else>All content</span>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<!-- Filter Toggle Button -->
|
||||||
<CardContent>
|
<Button
|
||||||
<FeedFilters v-model="selectedFilters" :admin-pubkeys="adminPubkeys" />
|
variant="ghost"
|
||||||
</CardContent>
|
size="sm"
|
||||||
</Card>
|
@click="showFilters = !showFilters"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Filter class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Feed Content -->
|
<!-- Collapsible Filter Panel -->
|
||||||
<NostrFeed
|
<div v-if="showFilters" class="border-t bg-background/95 backdrop-blur">
|
||||||
:feed-type="feedType"
|
<div class="px-4 py-3 sm:px-6">
|
||||||
:content-filters="selectedFilters"
|
<FeedFilters v-model="selectedFilters" :admin-pubkeys="adminPubkeys" />
|
||||||
:admin-pubkeys="adminPubkeys"
|
</div>
|
||||||
:key="feedKey"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Feed Area - Takes remaining height -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- Collapsible Note Composer -->
|
||||||
|
<div v-if="showComposer || replyTo" class="border-b bg-background">
|
||||||
|
<div class="px-4 py-3 sm:px-6">
|
||||||
|
<NoteComposer
|
||||||
|
:reply-to="replyTo"
|
||||||
|
@note-published="onNotePublished"
|
||||||
|
@clear-reply="onClearReply"
|
||||||
|
@close="onCloseComposer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feed Content - Full height scroll -->
|
||||||
|
<div class="h-full">
|
||||||
|
<NostrFeed
|
||||||
|
:feed-type="feedType"
|
||||||
|
:content-filters="selectedFilters"
|
||||||
|
:admin-pubkeys="adminPubkeys"
|
||||||
|
:key="feedKey"
|
||||||
|
:compact-mode="true"
|
||||||
|
@reply-to-note="onReplyToNote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Action Button for Compose -->
|
||||||
|
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
|
||||||
|
<Button
|
||||||
|
@click="showComposer = true"
|
||||||
|
size="lg"
|
||||||
|
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
<Plus class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Filters Bar (Mobile) -->
|
||||||
|
<div class="md:hidden sticky bottom-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t">
|
||||||
|
<div class="flex overflow-x-auto px-4 py-2 gap-2 scrollbar-hide">
|
||||||
|
<Button
|
||||||
|
v-for="(preset, key) in quickFilterPresets"
|
||||||
|
:key="key"
|
||||||
|
:variant="isPresetActive(key) ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="setQuickFilter(key)"
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -37,20 +97,50 @@
|
||||||
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Filter } from 'lucide-vue-next'
|
import { Filter, Plus } from 'lucide-vue-next'
|
||||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
|
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
|
||||||
|
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
|
||||||
|
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
|
||||||
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
||||||
|
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
|
||||||
|
|
||||||
// Get admin pubkeys from app config
|
// Get admin pubkeys from app config
|
||||||
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const showFilters = ref(false)
|
||||||
|
const showComposer = ref(false)
|
||||||
|
|
||||||
// Feed configuration
|
// Feed configuration
|
||||||
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
||||||
const feedKey = ref(0) // Force feed component to re-render when filters change
|
const feedKey = ref(0) // Force feed component to re-render when filters change
|
||||||
|
|
||||||
|
// Note composer state
|
||||||
|
const replyTo = ref<ReplyToNote | undefined>()
|
||||||
|
|
||||||
|
// Quick filter presets for mobile bottom bar
|
||||||
|
const quickFilterPresets = {
|
||||||
|
all: { label: 'All', filters: FILTER_PRESETS.all },
|
||||||
|
announcements: { label: 'News', filters: FILTER_PRESETS.announcements },
|
||||||
|
marketplace: { label: 'Market', filters: FILTER_PRESETS.marketplace },
|
||||||
|
social: { label: 'Social', filters: FILTER_PRESETS.social },
|
||||||
|
events: { label: 'Events', filters: FILTER_PRESETS.events }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const activeFilterCount = computed(() => selectedFilters.value.length)
|
||||||
|
|
||||||
|
const isPresetActive = (presetKey: string) => {
|
||||||
|
const preset = quickFilterPresets[presetKey as keyof typeof quickFilterPresets]
|
||||||
|
if (!preset) return false
|
||||||
|
|
||||||
|
return preset.filters.length === selectedFilters.value.length &&
|
||||||
|
preset.filters.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))
|
||||||
|
}
|
||||||
|
|
||||||
// Determine feed type based on selected filters
|
// Determine feed type based on selected filters
|
||||||
const feedType = computed(() => {
|
const feedType = computed(() => {
|
||||||
if (selectedFilters.value.length === 0) return 'all'
|
if (selectedFilters.value.length === 0) return 'all'
|
||||||
|
|
@ -70,4 +160,37 @@ const feedType = computed(() => {
|
||||||
watch(selectedFilters, () => {
|
watch(selectedFilters, () => {
|
||||||
feedKey.value++
|
feedKey.value++
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Handle note composer events
|
||||||
|
// Methods
|
||||||
|
const setQuickFilter = (presetKey: string) => {
|
||||||
|
const preset = quickFilterPresets[presetKey as keyof typeof quickFilterPresets]
|
||||||
|
if (preset) {
|
||||||
|
selectedFilters.value = preset.filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNotePublished = (noteId: string) => {
|
||||||
|
console.log('Note published:', noteId)
|
||||||
|
// Refresh the feed to show the new note
|
||||||
|
feedKey.value++
|
||||||
|
// Clear reply state and hide composer
|
||||||
|
replyTo.value = undefined
|
||||||
|
showComposer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClearReply = () => {
|
||||||
|
replyTo.value = undefined
|
||||||
|
showComposer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReplyToNote = (note: ReplyToNote) => {
|
||||||
|
replyTo.value = note
|
||||||
|
showComposer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseComposer = () => {
|
||||||
|
showComposer.value = false
|
||||||
|
replyTo.value = undefined
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue