Add Rideshare functionality to NostrFeed module

- Introduced a new RideshareComposer component for creating rideshare posts, allowing users to specify ride details such as type, locations, date, time, and contact methods.
- Enhanced NostrFeed.vue to display rideshare badges for relevant posts, improving visibility and categorization of rideshare content.
- Updated Home.vue to integrate the RideshareComposer, providing users with an option to compose rideshare requests or offers alongside regular notes.

These changes enhance the user experience by facilitating rideshare interactions within the NostrFeed module, promoting community engagement.
This commit is contained in:
padreug 2025-09-17 02:21:42 +02:00
parent 667a7c2d89
commit dfd3ddd112
3 changed files with 564 additions and 10 deletions

View file

@ -92,6 +92,44 @@ function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
// Check if a post is a rideshare post
function isRidesharePost(note: any): boolean {
// Check for rideshare tags
const hasTags = note.tags?.some((tag: string[]) =>
tag[0] === 't' && ['rideshare', 'carpool'].includes(tag[1])
) || false
// Check for rideshare-specific custom tags
const hasRideshareTypeTags = note.tags?.some((tag: string[]) =>
tag[0] === 'rideshare_type' && ['offering', 'seeking'].includes(tag[1])
) || false
// Check content for rideshare keywords (fallback)
const hasRideshareContent = note.content && (
note.content.includes('🚗 OFFERING RIDE') ||
note.content.includes('🚶 SEEKING RIDE') ||
note.content.includes('#rideshare') ||
note.content.includes('#carpool')
)
return hasTags || hasRideshareTypeTags || hasRideshareContent
}
// Get rideshare type from post
function getRideshareType(note: any): string | null {
// Check custom tags first
const typeTag = note.tags?.find((tag: string[]) => tag[0] === 'rideshare_type')
if (typeTag) {
return typeTag[1] === 'offering' ? 'Offering Ride' : 'Seeking Ride'
}
// Fallback to content analysis
if (note.content?.includes('🚗 OFFERING RIDE')) return 'Offering Ride'
if (note.content?.includes('🚶 SEEKING RIDE')) return 'Seeking Ride'
return 'Rideshare'
}
// Get market product data for market events
function getMarketProductData(note: any) {
if (note.kind === 30018) {
@ -281,6 +319,13 @@ async function onToggleLike(note: any) {
>
{{ getMarketEventType({ kind: note.kind }) }}
</Badge>
<Badge
v-if="isRidesharePost(note)"
variant="secondary"
class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
🚗 {{ getRideshareType(note) }}
</Badge>
<span class="text-sm font-medium">{{ getDisplayName(note.pubkey) }}</span>
</div>
<span class="text-xs text-muted-foreground">

View file

@ -0,0 +1,447 @@
<template>
<Card class="w-full">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<Car class="h-5 w-5" />
Create Rideshare Post
</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 or find rides with the community
</CardDescription>
</CardHeader>
<CardContent>
<form @submit="onSubmit" class="space-y-4">
<!-- Rideshare Type -->
<FormField v-slot="{ componentField }" name="type">
<FormItem>
<FormLabel>Type *</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select rideshare type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="offering">Offering a ride</SelectItem>
<SelectItem value="seeking">Looking for a ride</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Are you offering a ride or looking for one?
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- From Location -->
<FormField v-slot="{ componentField }" name="fromLocation">
<FormItem>
<FormLabel>From *</FormLabel>
<FormControl>
<Input
placeholder="Pickup location"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Starting location or pickup point
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- To Location -->
<FormField v-slot="{ componentField }" name="toLocation">
<FormItem>
<FormLabel>To *</FormLabel>
<FormControl>
<Input
placeholder="Destination"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Destination or drop-off point
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Date and Time -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="date">
<FormItem>
<FormLabel>Date *</FormLabel>
<FormControl>
<Input
type="date"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="time">
<FormItem>
<FormLabel>Time *</FormLabel>
<FormControl>
<Input
type="time"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Available Seats (for offering) -->
<FormField v-slot="{ componentField }" name="seats">
<FormItem v-show="watchedType === 'offering'">
<FormLabel>Available Seats</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Number of seats" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 seat</SelectItem>
<SelectItem value="2">2 seats</SelectItem>
<SelectItem value="3">3 seats</SelectItem>
<SelectItem value="4">4 seats</SelectItem>
<SelectItem value="5">5+ seats</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
How many passengers can you take?
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Price/Cost -->
<FormField v-slot="{ componentField }" name="price">
<FormItem>
<FormLabel>{{ watchedType === 'offering' ? 'Price per person' : 'Willing to pay' }}</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">$</span>
<Input
type="number"
placeholder="0.00"
step="0.01"
min="0"
v-bind="componentField"
/>
</div>
</FormControl>
<FormDescription>
{{ watchedType === 'offering' ? 'Cost per passenger (optional)' : 'Maximum you\'re willing to pay (optional)' }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Additional Details -->
<FormField v-slot="{ componentField }" name="details">
<FormItem>
<FormLabel>Additional Details</FormLabel>
<FormControl>
<Textarea
placeholder="Any additional information, requirements, or preferences..."
class="min-h-[80px] resize-y"
v-bind="componentField"
:maxlength="200"
/>
</FormControl>
<div class="flex items-center justify-between">
<FormDescription>
Extra details, preferences, or contact info
</FormDescription>
<span class="text-xs text-muted-foreground">
{{ detailsCharacterCount }}/200
</span>
</div>
<FormMessage />
</FormItem>
</FormField>
<!-- Contact Method -->
<FormField v-slot="{ componentField }" name="contactMethod">
<FormItem>
<FormLabel>Contact Method</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="How should people contact you?" />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr-dm">Nostr DM</SelectItem>
<SelectItem value="replies">Replies to this post</SelectItem>
<SelectItem value="other">Other (specify in details)</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Preferred way for people to reach you
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Actions -->
<div class="flex justify-end pt-4 border-t">
<div class="flex gap-2">
<Button
type="button"
variant="outline"
@click="emit('close')"
>
Cancel
</Button>
<Button
type="submit"
:disabled="isPublishing || !isFormValid"
class="gap-2"
>
<Send class="h-4 w-4" />
{{ isPublishing ? 'Publishing...' : 'Publish Rideshare' }}
</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 { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Car, Send, 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'
// Emits
interface Emits {
(e: 'close'): void
(e: 'rideshare-published', noteId: string): void
}
const emit = defineEmits<Emits>()
// Services
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
const toast = useToast()
// Form schema
const rideshareSchema = toTypedSchema(z.object({
type: z.enum(['offering', 'seeking'], {
required_error: "Please select rideshare type"
}),
fromLocation: z.string().min(1, "From location is required").max(100, "Location too long"),
toLocation: z.string().min(1, "To location is required").max(100, "Location too long"),
date: z.string().min(1, "Date is required").refine(
(date) => new Date(date) >= new Date(new Date().toDateString()),
"Date cannot be in the past"
),
time: z.string().min(1, "Time is required"),
seats: z.string().optional(),
price: z.string().optional(),
details: z.string().max(200, "Details too long").optional(),
contactMethod: z.enum(['nostr-dm', 'replies', 'other']).optional(),
}))
// Form setup
const form = useForm({
validationSchema: rideshareSchema,
initialValues: {
type: '',
fromLocation: '',
toLocation: '',
date: '',
time: '',
seats: '',
price: '',
details: '',
contactMethod: 'nostr-dm'
}
})
const { values, meta, setFieldValue, resetForm } = form
const isFormValid = computed(() => meta.value.valid)
// State
const isPublishing = ref(false)
// Computed properties
const watchedType = computed(() => values.type)
const detailsCharacterCount = computed(() => (values.details || '').length)
// 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
}
// Generate rideshare post content
const generateRideshareContent = (values: any): string => {
const type = values.type === 'offering' ? '🚗 OFFERING RIDE' : '🚶 SEEKING RIDE'
const dateTime = `${values.date} at ${values.time}`
let content = `${type}\n\n`
content += `📍 From: ${values.fromLocation}\n`
content += `🎯 To: ${values.toLocation}\n`
content += `📅 When: ${dateTime}\n`
if (values.type === 'offering' && values.seats) {
content += `👥 Seats available: ${values.seats}\n`
}
if (values.price) {
const priceLabel = values.type === 'offering' ? 'Price per person' : 'Willing to pay'
content += `💰 ${priceLabel}: $${values.price}\n`
}
if (values.contactMethod) {
const contactLabels = {
'nostr-dm': 'DM me on Nostr',
'replies': 'Reply to this post',
'other': 'See details below'
}
content += `📞 Contact: ${contactLabels[values.contactMethod as keyof typeof contactLabels]}\n`
}
if (values.details?.trim()) {
content += `\n📝 Details: ${values.details.trim()}\n`
}
content += `\n#rideshare #carpool #transport`
return content
}
// Generate rideshare tags following Nostr best practices
const generateRideshareTags = (values: any): string[][] => {
const tags: string[][] = []
// NIP-12: Generic tags for rideshare content
tags.push(['t', 'rideshare'])
tags.push(['t', 'carpool'])
tags.push(['t', 'transport'])
// Rideshare-specific tags (custom)
tags.push(['rideshare_type', values.type]) // 'offering' or 'seeking'
tags.push(['rideshare_from', values.fromLocation])
tags.push(['rideshare_to', values.toLocation])
tags.push(['rideshare_date', values.date])
tags.push(['rideshare_time', values.time])
if (values.type === 'offering' && values.seats) {
tags.push(['rideshare_seats', values.seats])
}
if (values.price) {
tags.push(['rideshare_price', values.price])
}
if (values.contactMethod) {
tags.push(['rideshare_contact', values.contactMethod])
}
return tags
}
// Submit handler
const onSubmit = form.handleSubmit(async (values) => {
if (!authService?.isAuthenticated.value || !authService?.user.value) {
toast.error("Please sign in to publish rideshare posts")
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 {
// Generate content and tags
const content = generateRideshareContent(values)
const tags = generateRideshareTags(values)
// Create rideshare event template
// Using kind 1 (text note) with rideshare tags for maximum compatibility
// Could also use custom kind like 31001 for structured rideshare events
const eventTemplate: EventTemplate = {
kind: 1, // Standard text note with rideshare tags
content,
tags,
created_at: Math.floor(Date.now() / 1000)
}
console.log('Creating rideshare event:', eventTemplate)
// Sign the event
const privkeyBytes = hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('Publishing signed rideshare post:', signedEvent)
// Publish the rideshare post
const result = await relayHub.publishEvent(signedEvent)
toast.success(`Rideshare post published to ${result.success}/${result.total} relays!`)
// Reset form
resetForm()
// Emit success event
emit('rideshare-published', signedEvent.id)
console.log('Rideshare post published with ID:', signedEvent.id)
} catch (error) {
console.error('Failed to publish rideshare post:', error)
toast.error(error instanceof Error ? error.message : "Failed to publish rideshare post")
} finally {
isPublishing.value = false
}
})
</script>

View file

@ -36,15 +36,24 @@
<!-- Main Feed Area - Takes remaining height -->
<div class="flex-1 overflow-hidden">
<!-- Collapsible Note Composer -->
<!-- Collapsible Composer -->
<div v-if="showComposer || replyTo" class="border-b bg-background">
<div class="px-4 py-3 sm:px-6">
<!-- Regular Note Composer -->
<NoteComposer
v-if="composerType === 'note' || replyTo"
:reply-to="replyTo"
@note-published="onNotePublished"
@clear-reply="onClearReply"
@close="onCloseComposer"
/>
<!-- Rideshare Composer -->
<RideshareComposer
v-else-if="composerType === 'rideshare'"
@rideshare-published="onRidesharePublished"
@close="onCloseComposer"
/>
</div>
</div>
@ -61,15 +70,44 @@
</div>
</div>
<!-- Floating Action Button for Compose -->
<!-- Floating Action Buttons 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-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
>
<Plus class="h-6 w-6 stroke-[2.5]" />
</Button>
<!-- Main compose button -->
<div class="flex flex-col items-end gap-3">
<!-- Secondary buttons (when expanded) -->
<div v-if="showComposerOptions" class="flex flex-col gap-2">
<Button
@click="openComposer('note')"
size="lg"
variant="secondary"
class="h-12 px-4 rounded-full shadow-md hover:shadow-lg transition-all gap-2"
>
<MessageSquare class="h-4 w-4" />
<span class="text-sm">Note</span>
</Button>
<Button
@click="openComposer('rideshare')"
size="lg"
variant="secondary"
class="h-12 px-4 rounded-full shadow-md hover:shadow-lg transition-all gap-2"
>
<Car class="h-4 w-4" />
<span class="text-sm">Rideshare</span>
</Button>
</div>
<!-- Main FAB -->
<Button
@click="toggleComposerOptions"
size="lg"
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
>
<Plus
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
:class="{ 'rotate-45': showComposerOptions }"
/>
</Button>
</div>
</div>
<!-- Quick Filters Bar (Mobile) -->
@ -97,10 +135,11 @@
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
import { ref, computed, watch } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Filter, Plus } from 'lucide-vue-next'
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
import appConfig from '@/app.config'
@ -113,6 +152,8 @@ const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// UI state
const showFilters = ref(false)
const showComposer = ref(false)
const showComposerOptions = ref(false)
const composerType = ref<'note' | 'rideshare'>('note')
// Feed configuration
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
@ -191,6 +232,27 @@ const onReplyToNote = (note: ReplyToNote) => {
const onCloseComposer = () => {
showComposer.value = false
showComposerOptions.value = false
replyTo.value = undefined
}
// New composer methods
const toggleComposerOptions = () => {
showComposerOptions.value = !showComposerOptions.value
}
const openComposer = (type: 'note' | 'rideshare') => {
composerType.value = type
showComposer.value = true
showComposerOptions.value = false
}
const onRidesharePublished = (noteId: string) => {
console.log('Rideshare post published:', noteId)
// Refresh the feed to show the new rideshare post
feedKey.value++
// Hide composer
showComposer.value = false
showComposerOptions.value = false
}
</script>