web-app/src/modules/nostr-feed/components/NoteComposer.vue
padreug 2d0aadccb7 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.
2025-09-23 23:59:37 +02:00

337 lines
No EOL
9.6 KiB
Vue

<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>