Squash merge nostrfeed-ui into main
This commit is contained in:
parent
5063a3e121
commit
cc5e0dbef6
10 changed files with 379 additions and 258 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue