Squash merge nostrfeed-ui into main

This commit is contained in:
padreug 2025-10-21 21:31:25 +02:00
parent 5063a3e121
commit cc5e0dbef6
10 changed files with 379 additions and 258 deletions

View file

@ -1,5 +1,5 @@
<template>
<div class="space-y-4">
<div class="space-y-4 w-full max-w-3xl mx-auto">
<!-- Quick Presets -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Quick Filters</h3>
@ -153,10 +153,6 @@ const availableFilters = computed(() => {
const presets = computed(() => [
{ id: 'all', label: 'All Content' },
{ id: 'announcements', label: 'Announcements' },
{ id: 'community', label: 'Community' },
{ id: 'social', label: 'Social' },
{ id: 'events', label: 'Events' },
{ id: 'content', label: 'Articles' },
{ id: 'rideshare', label: 'Rideshare' }
])

View file

@ -1,6 +1,14 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '../composables/useProfiles'
@ -8,6 +16,11 @@ import { useReactions } from '../composables/useReactions'
import ThreadedPost from './ThreadedPost.vue'
import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
import { finalizeEvent } from 'nostr-tools'
import { useToast } from '@/core/composables/useToast'
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
@ -15,7 +28,7 @@ interface Emits {
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
feedType?: 'all' | 'announcements' | 'rideshare' | 'custom'
contentFilters?: ContentFilter[]
adminPubkeys?: string[]
compactMode?: boolean
@ -23,9 +36,17 @@ const props = defineProps<{
const emit = defineEmits<Emits>()
// Inject services
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
const toast = useToast()
// Get admin/moderator pubkeys from props or app config
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey || null)
// Use centralized feed service - this handles all subscription management and deduplication
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
feedType: props.feedType || 'all',
@ -40,6 +61,10 @@ const collapsedPosts = ref(new Set<string>())
// Track which posts should show limited replies (not collapsed, just limited)
const limitedReplyPosts = ref(new Set<string>())
// Delete confirmation dialog state
const showDeleteDialog = ref(false)
const postToDelete = ref<FeedPost | null>(null)
// Initialize posts that should show limited replies (>2 children)
watch(threadedPosts, (newPosts) => {
if (newPosts.length > 0) {
@ -91,13 +116,12 @@ const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
const feedTitle = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Community Announcements'
case 'events':
return 'Events & Calendar'
case 'general':
return 'General Discussion'
return 'Announcements'
case 'rideshare':
return 'Rideshare'
case 'all':
default:
return 'Community Feed'
return 'All Content'
}
})
@ -105,12 +129,11 @@ const feedDescription = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
case 'events':
return 'Upcoming events and calendar updates'
case 'general':
return 'Community discussions and general posts'
case 'rideshare':
return 'Rideshare requests and offers'
case 'all':
default:
return 'Latest posts from the community'
return 'All community posts and updates'
}
})
@ -187,35 +210,125 @@ function onToggleLimited(postId: string) {
limitedReplyPosts.value = newLimited
}
// 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
}
// Handle delete post button click - show confirmation dialog
function onDeletePost(note: FeedPost) {
if (!authService?.isAuthenticated.value || !authService?.user.value) {
toast.error("Please sign in to delete posts")
return
}
// Verify user owns the post
if (note.pubkey !== currentUserPubkey.value) {
toast.error("You can only delete your own posts")
return
}
// Show confirmation dialog
postToDelete.value = note
showDeleteDialog.value = true
}
// Confirm and execute post deletion (NIP-09: Event Deletion Request)
async function confirmDeletePost() {
const note = postToDelete.value
if (!note) return
if (!relayHub?.isConnected.value) {
toast.error("Not connected to Nostr relays")
showDeleteDialog.value = false
postToDelete.value = null
return
}
const userPrivkey = authService?.user.value?.prvkey
if (!userPrivkey) {
toast.error("User private key not available")
showDeleteDialog.value = false
postToDelete.value = null
return
}
try {
// Create deletion event (NIP-09)
const deletionEvent = {
kind: 5, // Event Deletion Request
content: 'Deleted by author',
tags: [
['e', note.id], // Reference to event being deleted
['k', String(note.kind)] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the deletion event
const privkeyBytes = hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
toast.success(`Deletion request sent to ${result.success}/${result.total} relays`)
// The post will be removed automatically via websocket when relays broadcast the deletion event
} else {
toast.error("Failed to send deletion request to any relay")
}
} catch (error) {
console.error('Failed to delete post:', error)
toast.error(error instanceof Error ? error.message : "Failed to delete post")
} finally {
// Close dialog and clear state
showDeleteDialog.value = false
postToDelete.value = null
}
}
// Cancel delete action
function cancelDelete() {
showDeleteDialog.value = false
postToDelete.value = null
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col">
<!-- 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 v-if="!compactMode" class="flex items-center justify-between p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
<div class="w-full max-w-3xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<Megaphone class="h-5 w-5 md:h-6 md:w-6 text-primary" />
<div>
<h2 class="text-lg md:text-xl font-bold">{{ feedTitle }}</h2>
<p class="text-xs md:text-sm text-muted-foreground">{{ feedDescription }}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
<span class="hidden md:inline">Refresh</span>
</Button>
</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>
<!-- Feed Content Container -->
<div class="flex-1 overflow-hidden">
<div class="w-full max-w-3xl mx-auto px-0 md:px-4">
<!-- 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-12">
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin" />
<span class="text-muted-foreground">Loading feed...</span>
@ -254,18 +367,15 @@ function onToggleLimited(postId: string) {
</p>
</div>
<!-- Posts List - Full height scroll with threaded view -->
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<!-- Debug info for troubleshooting -->
<div v-if="threadedPosts.length === 0" class="p-4 text-sm text-muted-foreground">
Debug: threadedPosts.length = {{ threadedPosts.length }}, posts.length = {{ notes.length }}
</div>
<div>
<!-- Posts List - Natural flow without internal scrolling -->
<div v-else>
<div class="md:space-y-4 md:py-4">
<ThreadedPost
v-for="post in threadedPosts"
:key="post.id"
:post="post"
:admin-pubkeys="adminPubkeys"
:current-user-pubkey="currentUserPubkey"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="0"
@ -276,9 +386,31 @@ function onToggleLimited(postId: string) {
@toggle-like="onToggleLike"
@toggle-collapse="onToggleCollapse"
@toggle-limited="onToggleLimited"
@delete-post="onDeletePost"
/>
</div>
<!-- End of feed message -->
<div class="text-center py-6 text-md text-muted-foreground">
<p>🐢</p>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<Dialog :open="showDeleteDialog" @update:open="(val: boolean) => showDeleteDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Post?</DialogTitle>
<DialogDescription>
Are you sure you want to delete this post? This action cannot be undone. The deletion request will be sent to all relays.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="confirmDeletePost">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View file

@ -1,5 +1,5 @@
<template>
<Card class="w-full">
<Card class="w-full max-w-3xl mx-auto">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">

View file

@ -341,11 +341,9 @@ const generateRideshareContent = (values: any): string => {
}
if (values.details?.trim()) {
content += `\n📝 Details: ${values.details.trim()}\n`
content += `\n📝 Details: ${values.details.trim()}`
}
content += `\n#rideshare #carpool #transport`
return content
}
@ -359,22 +357,23 @@ const generateRideshareTags = (values: any): string[][] => {
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])
// Note: All tag values must be strings per Nostr protocol
tags.push(['rideshare_type', String(values.type)]) // 'offering' or 'seeking'
tags.push(['rideshare_from', String(values.fromLocation)])
tags.push(['rideshare_to', String(values.toLocation)])
tags.push(['rideshare_date', String(values.date)])
tags.push(['rideshare_time', String(values.time)])
if (values.type === 'offering' && values.seats) {
tags.push(['rideshare_seats', values.seats])
tags.push(['rideshare_seats', String(values.seats)])
}
if (values.price) {
tags.push(['rideshare_price', values.price])
tags.push(['rideshare_price', String(values.price)])
}
if (values.contactMethod) {
tags.push(['rideshare_contact', values.contactMethod])
tags.push(['rideshare_contact', String(values.contactMethod)])
}
return tags

View file

@ -3,12 +3,13 @@ import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Reply, Heart, Share, ChevronUp, ChevronDown } from 'lucide-vue-next'
import { Reply, Heart, Share, ChevronUp, ChevronDown, Trash2 } from 'lucide-vue-next'
import type { FeedPost } from '../services/FeedService'
interface Props {
post: FeedPost
adminPubkeys?: string[]
currentUserPubkey?: string | null
getDisplayName: (pubkey: string) => string
getEventReactions: (eventId: string) => { likes: number; userHasLiked: boolean }
depth?: number
@ -22,6 +23,7 @@ interface Emits {
(e: 'toggle-like', note: FeedPost): void
(e: 'toggle-collapse', postId: string): void
(e: 'toggle-limited', postId: string): void
(e: 'delete-post', note: FeedPost): void
}
const props = withDefaults(defineProps<Props>(), {
@ -47,6 +49,9 @@ const isVisible = computed(() => !props.parentCollapsed)
// Check if this is an admin post
const isAdminPost = computed(() => props.adminPubkeys.includes(props.post.pubkey))
// Check if this post belongs to the current user
const isUserPost = computed(() => props.currentUserPubkey && props.post.pubkey === props.currentUserPubkey)
// Check if post has replies
const hasReplies = computed(() => props.post.replies && props.post.replies.length > 0)
@ -117,25 +122,22 @@ function getRideshareType(post: FeedPost): string {
<template>
<div v-if="isVisible" class="relative">
<!-- Vertical line connecting to parent (for nested replies) -->
<div
v-if="depth > 0"
class="absolute left-0 top-0 bottom-0 w-px bg-muted-foreground/60"
:style="{ marginLeft: `${depth * 6 + 3}px` }"
/>
<!-- Post container with indentation -->
<!-- Post container with Lemmy-style border-left threading -->
<div
:class="{
'pl-2': depth > 0,
'hover:bg-accent/50': true,
'transition-colors': true,
'border-b': depth === 0,
'border-border/40': depth === 0
'border-l-2 border-muted-foreground/40': depth > 0,
'ml-0.5': depth > 0,
'pl-1.5': depth > 0,
'hover:bg-accent/30': true,
'transition-all duration-200': true,
'border-b border-border/40': depth === 0,
'md:border md:border-border/60 md:rounded-lg': depth === 0,
'md:shadow-sm md:hover:shadow-md': depth === 0,
'md:bg-card': depth === 0,
'md:my-1': depth === 0
}"
:style="{ marginLeft: `${depth * 6}px` }"
>
<div class="p-3 relative">
<div class="p-3 md:p-5 relative">
<!-- Post Header -->
<div class="flex items-center justify-between mb-2">
@ -145,87 +147,87 @@ function getRideshareType(post: FeedPost): string {
v-if="hasReplies"
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
class="h-7 w-7 md:h-8 md:w-8 p-0 hover:bg-accent transition-colors"
@click="toggleCollapse"
>
<ChevronDown v-if="!isCollapsed" class="h-4 w-4" />
<ChevronUp v-else class="h-4 w-4" />
<ChevronDown v-if="!isCollapsed" class="h-4 w-4 md:h-5 md:w-5" />
<ChevronUp v-else class="h-4 w-4 md:h-5 md:w-5" />
</Button>
<div v-else class="w-6" />
<div v-else class="w-7 md:w-8" />
<!-- Badges -->
<Badge
v-if="isAdminPost"
variant="default"
class="text-xs px-1.5 py-0.5"
class="text-xs md:text-sm px-2 py-0.5"
>
Admin
</Badge>
<Badge
v-if="post.isReply && depth === 0"
variant="secondary"
class="text-xs px-1.5 py-0.5"
class="text-xs md:text-sm px-2 py-0.5"
>
Reply
</Badge>
<Badge
v-if="isRidesharePost(post)"
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"
class="text-xs md:text-sm px-2 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
🚗 {{ getRideshareType(post) }}
</Badge>
<!-- Author name -->
<span class="text-sm font-medium">{{ getDisplayName(post.pubkey) }}</span>
<span class="text-sm md:text-base font-semibold">{{ getDisplayName(post.pubkey) }}</span>
<!-- Reply count badge if collapsed -->
<Badge
v-if="isCollapsed && hasReplies"
variant="outline"
class="text-xs px-1.5 py-0.5"
class="text-xs md:text-sm px-2 py-0.5"
>
{{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }}
</Badge>
</div>
<!-- Timestamp -->
<span class="text-xs text-muted-foreground">
<span class="text-xs md:text-sm text-muted-foreground font-medium">
{{ formatDistanceToNow(post.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<!-- Post Content (always visible for non-collapsed posts) -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
<div class="text-sm md:text-base leading-relaxed whitespace-pre-wrap mb-3">
{{ post.content }}
</div>
<!-- Post Actions (always visible) -->
<div class="mt-2">
<div class="flex items-center gap-1">
<div class="mt-3">
<div class="flex items-center gap-1 md:gap-2">
<!-- Reply Button -->
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-muted-foreground hover:text-foreground hover:bg-accent/50"
class="h-7 w-7 md:h-8 md:w-8 p-0 text-muted-foreground hover:text-primary hover:bg-accent transition-colors"
@click="onReplyToNote"
>
<Reply class="h-3 w-3" />
<Reply class="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
<!-- Like Button -->
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50"
class="h-7 md:h-8 px-2 md:px-2.5 text-xs md:text-sm text-muted-foreground hover:text-red-500 hover:bg-accent transition-colors"
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(post.id).userHasLiked }"
@click="onToggleLike"
>
<Heart
class="h-3 w-3"
class="h-3.5 w-3.5 md:h-4 md:w-4"
:class="{ 'fill-current': getEventReactions(post.id).userHasLiked }"
/>
<span v-if="getEventReactions(post.id).likes > 0" class="ml-1">
<span v-if="getEventReactions(post.id).likes > 0" class="ml-1.5">
{{ getEventReactions(post.id).likes }}
</span>
</Button>
@ -234,9 +236,20 @@ function getRideshareType(post: FeedPost): string {
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-muted-foreground hover:text-foreground hover:bg-accent/50"
class="h-7 w-7 md:h-8 md:w-8 p-0 text-muted-foreground hover:text-primary hover:bg-accent transition-colors"
>
<Share class="h-3 w-3" />
<Share class="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
<!-- Delete Button (only for user's own posts) -->
<Button
v-if="isUserPost"
variant="ghost"
size="sm"
class="h-7 w-7 md:h-8 md:w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
@click="emit('delete-post', post)"
>
<Trash2 class="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
</div>
</div>
@ -250,6 +263,7 @@ function getRideshareType(post: FeedPost): string {
:key="reply.id"
:post="reply"
:admin-pubkeys="adminPubkeys"
:current-user-pubkey="currentUserPubkey"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="depth + 1"
@ -260,18 +274,18 @@ function getRideshareType(post: FeedPost): string {
@toggle-like="$emit('toggle-like', $event)"
@toggle-collapse="$emit('toggle-collapse', $event)"
@toggle-limited="$emit('toggle-limited', $event)"
@delete-post="$emit('delete-post', $event)"
/>
<!-- Show "Load more replies" button when limited and there are more than 2 replies -->
<div
v-if="hasLimitedReplies && (post.replies?.length || 0) > 2"
class="mt-2"
:style="{ marginLeft: `${(depth + 1) * 6}px` }"
class="mt-2 mb-1 ml-0.5"
>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50"
class="h-7 md:h-8 px-3 md:px-4 text-xs md:text-sm text-primary hover:text-primary hover:bg-accent transition-colors font-medium"
@click="() => emit('toggle-limited', post.id)"
>
Show {{ (post.replies?.length || 0) - 2 }} more {{ (post.replies?.length || 0) - 2 === 1 ? 'reply' : 'replies' }}