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,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 w-full max-w-3xl mx-auto">
|
||||||
<!-- Quick Presets -->
|
<!-- Quick Presets -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-sm font-medium">Quick Filters</h3>
|
<h3 class="text-sm font-medium">Quick Filters</h3>
|
||||||
|
|
@ -153,10 +153,6 @@ const availableFilters = computed(() => {
|
||||||
const presets = computed(() => [
|
const presets = computed(() => [
|
||||||
{ id: 'all', label: 'All Content' },
|
{ id: 'all', label: 'All Content' },
|
||||||
{ id: 'announcements', label: 'Announcements' },
|
{ id: 'announcements', label: 'Announcements' },
|
||||||
{ id: 'community', label: 'Community' },
|
|
||||||
{ id: 'social', label: 'Social' },
|
|
||||||
{ id: 'events', label: 'Events' },
|
|
||||||
{ id: 'content', label: 'Articles' },
|
|
||||||
{ id: 'rideshare', label: 'Rideshare' }
|
{ id: 'rideshare', label: 'Rideshare' }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '../composables/useProfiles'
|
import { useProfiles } from '../composables/useProfiles'
|
||||||
|
|
@ -8,6 +16,11 @@ import { useReactions } from '../composables/useReactions'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
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 {
|
interface Emits {
|
||||||
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
||||||
|
|
@ -15,7 +28,7 @@ interface Emits {
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
|
feedType?: 'all' | 'announcements' | 'rideshare' | 'custom'
|
||||||
contentFilters?: ContentFilter[]
|
contentFilters?: ContentFilter[]
|
||||||
adminPubkeys?: string[]
|
adminPubkeys?: string[]
|
||||||
compactMode?: boolean
|
compactMode?: boolean
|
||||||
|
|
@ -23,9 +36,17 @@ const props = defineProps<{
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
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
|
// 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 || []
|
||||||
|
|
||||||
|
// Get current user's pubkey
|
||||||
|
const currentUserPubkey = computed(() => authService?.user.value?.pubkey || null)
|
||||||
|
|
||||||
// Use centralized feed service - this handles all subscription management and deduplication
|
// Use centralized feed service - this handles all subscription management and deduplication
|
||||||
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
|
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
|
||||||
feedType: props.feedType || 'all',
|
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)
|
// Track which posts should show limited replies (not collapsed, just limited)
|
||||||
const limitedReplyPosts = ref(new Set<string>())
|
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)
|
// Initialize posts that should show limited replies (>2 children)
|
||||||
watch(threadedPosts, (newPosts) => {
|
watch(threadedPosts, (newPosts) => {
|
||||||
if (newPosts.length > 0) {
|
if (newPosts.length > 0) {
|
||||||
|
|
@ -91,13 +116,12 @@ const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||||
const feedTitle = computed(() => {
|
const feedTitle = computed(() => {
|
||||||
switch (props.feedType) {
|
switch (props.feedType) {
|
||||||
case 'announcements':
|
case 'announcements':
|
||||||
return 'Community Announcements'
|
return 'Announcements'
|
||||||
case 'events':
|
case 'rideshare':
|
||||||
return 'Events & Calendar'
|
return 'Rideshare'
|
||||||
case 'general':
|
case 'all':
|
||||||
return 'General Discussion'
|
|
||||||
default:
|
default:
|
||||||
return 'Community Feed'
|
return 'All Content'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -105,12 +129,11 @@ const feedDescription = computed(() => {
|
||||||
switch (props.feedType) {
|
switch (props.feedType) {
|
||||||
case 'announcements':
|
case 'announcements':
|
||||||
return 'Important announcements from community administrators'
|
return 'Important announcements from community administrators'
|
||||||
case 'events':
|
case 'rideshare':
|
||||||
return 'Upcoming events and calendar updates'
|
return 'Rideshare requests and offers'
|
||||||
case 'general':
|
case 'all':
|
||||||
return 'Community discussions and general posts'
|
|
||||||
default:
|
default:
|
||||||
return 'Latest posts from the community'
|
return 'All community posts and updates'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -187,35 +210,125 @@ function onToggleLimited(postId: string) {
|
||||||
|
|
||||||
limitedReplyPosts.value = newLimited
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col">
|
||||||
<!-- Compact Header (only in non-compact mode) -->
|
<!-- Compact Header (only in non-compact mode) -->
|
||||||
<div v-if="!compactMode" class="flex items-center justify-between p-4 border-b">
|
<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="flex items-center gap-2">
|
<div class="w-full max-w-3xl mx-auto flex items-center justify-between">
|
||||||
<Megaphone class="h-5 w-5 text-primary" />
|
<div class="flex items-center gap-3">
|
||||||
<div>
|
<Megaphone class="h-5 w-5 md:h-6 md:w-6 text-primary" />
|
||||||
<h2 class="text-lg font-semibold">{{ feedTitle }}</h2>
|
<div>
|
||||||
<p class="text-sm text-muted-foreground">{{ feedDescription }}</p>
|
<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>
|
</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>
|
</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>
|
||||||
|
|
||||||
<!-- Feed Content Container -->
|
<!-- 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 -->
|
<!-- 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">
|
<div class="flex items-center gap-2">
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||||
<span class="text-muted-foreground">Loading feed...</span>
|
<span class="text-muted-foreground">Loading feed...</span>
|
||||||
|
|
@ -254,18 +367,15 @@ function onToggleLimited(postId: string) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Posts List - Full height scroll with threaded view -->
|
<!-- Posts List - Natural flow without internal scrolling -->
|
||||||
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
<div v-else>
|
||||||
<!-- Debug info for troubleshooting -->
|
<div class="md:space-y-4 md:py-4">
|
||||||
<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>
|
|
||||||
<ThreadedPost
|
<ThreadedPost
|
||||||
v-for="post in threadedPosts"
|
v-for="post in threadedPosts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
:post="post"
|
:post="post"
|
||||||
:admin-pubkeys="adminPubkeys"
|
:admin-pubkeys="adminPubkeys"
|
||||||
|
:current-user-pubkey="currentUserPubkey"
|
||||||
:get-display-name="getDisplayName"
|
:get-display-name="getDisplayName"
|
||||||
:get-event-reactions="getEventReactions"
|
:get-event-reactions="getEventReactions"
|
||||||
:depth="0"
|
:depth="0"
|
||||||
|
|
@ -276,9 +386,31 @@ function onToggleLimited(postId: string) {
|
||||||
@toggle-like="onToggleLike"
|
@toggle-like="onToggleLike"
|
||||||
@toggle-collapse="onToggleCollapse"
|
@toggle-collapse="onToggleCollapse"
|
||||||
@toggle-limited="onToggleLimited"
|
@toggle-limited="onToggleLimited"
|
||||||
|
@delete-post="onDeletePost"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- End of feed message -->
|
||||||
|
<div class="text-center py-6 text-md text-muted-foreground">
|
||||||
|
<p>🐢</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Card class="w-full">
|
<Card class="w-full max-w-3xl mx-auto">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -341,11 +341,9 @@ const generateRideshareContent = (values: any): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.details?.trim()) {
|
if (values.details?.trim()) {
|
||||||
content += `\n📝 Details: ${values.details.trim()}\n`
|
content += `\n📝 Details: ${values.details.trim()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
content += `\n#rideshare #carpool #transport`
|
|
||||||
|
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,22 +357,23 @@ const generateRideshareTags = (values: any): string[][] => {
|
||||||
tags.push(['t', 'transport'])
|
tags.push(['t', 'transport'])
|
||||||
|
|
||||||
// Rideshare-specific tags (custom)
|
// Rideshare-specific tags (custom)
|
||||||
tags.push(['rideshare_type', values.type]) // 'offering' or 'seeking'
|
// Note: All tag values must be strings per Nostr protocol
|
||||||
tags.push(['rideshare_from', values.fromLocation])
|
tags.push(['rideshare_type', String(values.type)]) // 'offering' or 'seeking'
|
||||||
tags.push(['rideshare_to', values.toLocation])
|
tags.push(['rideshare_from', String(values.fromLocation)])
|
||||||
tags.push(['rideshare_date', values.date])
|
tags.push(['rideshare_to', String(values.toLocation)])
|
||||||
tags.push(['rideshare_time', values.time])
|
tags.push(['rideshare_date', String(values.date)])
|
||||||
|
tags.push(['rideshare_time', String(values.time)])
|
||||||
|
|
||||||
if (values.type === 'offering' && values.seats) {
|
if (values.type === 'offering' && values.seats) {
|
||||||
tags.push(['rideshare_seats', values.seats])
|
tags.push(['rideshare_seats', String(values.seats)])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.price) {
|
if (values.price) {
|
||||||
tags.push(['rideshare_price', values.price])
|
tags.push(['rideshare_price', String(values.price)])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.contactMethod) {
|
if (values.contactMethod) {
|
||||||
tags.push(['rideshare_contact', values.contactMethod])
|
tags.push(['rideshare_contact', String(values.contactMethod)])
|
||||||
}
|
}
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { computed } from 'vue'
|
||||||
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 { 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'
|
import type { FeedPost } from '../services/FeedService'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
post: FeedPost
|
post: FeedPost
|
||||||
adminPubkeys?: string[]
|
adminPubkeys?: string[]
|
||||||
|
currentUserPubkey?: string | null
|
||||||
getDisplayName: (pubkey: string) => string
|
getDisplayName: (pubkey: string) => string
|
||||||
getEventReactions: (eventId: string) => { likes: number; userHasLiked: boolean }
|
getEventReactions: (eventId: string) => { likes: number; userHasLiked: boolean }
|
||||||
depth?: number
|
depth?: number
|
||||||
|
|
@ -22,6 +23,7 @@ interface Emits {
|
||||||
(e: 'toggle-like', note: FeedPost): void
|
(e: 'toggle-like', note: FeedPost): void
|
||||||
(e: 'toggle-collapse', postId: string): void
|
(e: 'toggle-collapse', postId: string): void
|
||||||
(e: 'toggle-limited', postId: string): void
|
(e: 'toggle-limited', postId: string): void
|
||||||
|
(e: 'delete-post', note: FeedPost): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
@ -47,6 +49,9 @@ const isVisible = computed(() => !props.parentCollapsed)
|
||||||
// Check if this is an admin post
|
// Check if this is an admin post
|
||||||
const isAdminPost = computed(() => props.adminPubkeys.includes(props.post.pubkey))
|
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
|
// Check if post has replies
|
||||||
const hasReplies = computed(() => props.post.replies && props.post.replies.length > 0)
|
const hasReplies = computed(() => props.post.replies && props.post.replies.length > 0)
|
||||||
|
|
||||||
|
|
@ -117,25 +122,22 @@ function getRideshareType(post: FeedPost): string {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isVisible" class="relative">
|
<div v-if="isVisible" class="relative">
|
||||||
<!-- Vertical line connecting to parent (for nested replies) -->
|
<!-- Post container with Lemmy-style border-left threading -->
|
||||||
<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 -->
|
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'pl-2': depth > 0,
|
'border-l-2 border-muted-foreground/40': depth > 0,
|
||||||
'hover:bg-accent/50': true,
|
'ml-0.5': depth > 0,
|
||||||
'transition-colors': true,
|
'pl-1.5': depth > 0,
|
||||||
'border-b': depth === 0,
|
'hover:bg-accent/30': true,
|
||||||
'border-border/40': depth === 0
|
'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 -->
|
<!-- Post Header -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
|
@ -145,87 +147,87 @@ function getRideshareType(post: FeedPost): string {
|
||||||
v-if="hasReplies"
|
v-if="hasReplies"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<ChevronDown v-if="!isCollapsed" 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" />
|
<ChevronUp v-else class="h-4 w-4 md:h-5 md:w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div v-else class="w-6" />
|
<div v-else class="w-7 md:w-8" />
|
||||||
|
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isAdminPost"
|
v-if="isAdminPost"
|
||||||
variant="default"
|
variant="default"
|
||||||
class="text-xs px-1.5 py-0.5"
|
class="text-xs md:text-sm px-2 py-0.5"
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="post.isReply && depth === 0"
|
v-if="post.isReply && depth === 0"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-xs px-1.5 py-0.5"
|
class="text-xs md:text-sm px-2 py-0.5"
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isRidesharePost(post)"
|
v-if="isRidesharePost(post)"
|
||||||
variant="secondary"
|
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) }}
|
🚗 {{ getRideshareType(post) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Author name -->
|
<!-- 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 -->
|
<!-- Reply count badge if collapsed -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isCollapsed && hasReplies"
|
v-if="isCollapsed && hasReplies"
|
||||||
variant="outline"
|
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' }}
|
{{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timestamp -->
|
<!-- 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 }) }}
|
{{ formatDistanceToNow(post.created_at * 1000, { addSuffix: true }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Content (always visible for non-collapsed posts) -->
|
<!-- 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 }}
|
{{ post.content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Actions (always visible) -->
|
<!-- Post Actions (always visible) -->
|
||||||
<div class="mt-2">
|
<div class="mt-3">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1 md:gap-2">
|
||||||
<!-- Reply Button -->
|
<!-- Reply Button -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
@click="onReplyToNote"
|
||||||
>
|
>
|
||||||
<Reply class="h-3 w-3" />
|
<Reply class="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Like Button -->
|
<!-- Like Button -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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 }"
|
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(post.id).userHasLiked }"
|
||||||
@click="onToggleLike"
|
@click="onToggleLike"
|
||||||
>
|
>
|
||||||
<Heart
|
<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 }"
|
: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 }}
|
{{ getEventReactions(post.id).likes }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -234,9 +236,20 @@ function getRideshareType(post: FeedPost): string {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,6 +263,7 @@ function getRideshareType(post: FeedPost): string {
|
||||||
:key="reply.id"
|
:key="reply.id"
|
||||||
:post="reply"
|
:post="reply"
|
||||||
:admin-pubkeys="adminPubkeys"
|
:admin-pubkeys="adminPubkeys"
|
||||||
|
:current-user-pubkey="currentUserPubkey"
|
||||||
:get-display-name="getDisplayName"
|
:get-display-name="getDisplayName"
|
||||||
:get-event-reactions="getEventReactions"
|
:get-event-reactions="getEventReactions"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
|
|
@ -260,18 +274,18 @@ function getRideshareType(post: FeedPost): string {
|
||||||
@toggle-like="$emit('toggle-like', $event)"
|
@toggle-like="$emit('toggle-like', $event)"
|
||||||
@toggle-collapse="$emit('toggle-collapse', $event)"
|
@toggle-collapse="$emit('toggle-collapse', $event)"
|
||||||
@toggle-limited="$emit('toggle-limited', $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 -->
|
<!-- Show "Load more replies" button when limited and there are more than 2 replies -->
|
||||||
<div
|
<div
|
||||||
v-if="hasLimitedReplies && (post.replies?.length || 0) > 2"
|
v-if="hasLimitedReplies && (post.replies?.length || 0) > 2"
|
||||||
class="mt-2"
|
class="mt-2 mb-1 ml-0.5"
|
||||||
:style="{ marginLeft: `${(depth + 1) * 6}px` }"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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)"
|
@click="() => emit('toggle-limited', post.id)"
|
||||||
>
|
>
|
||||||
Show {{ (post.replies?.length || 0) - 2 }} more {{ (post.replies?.length || 0) - 2 === 1 ? 'reply' : 'replies' }}
|
Show {{ (post.replies?.length || 0) - 2 }} more {{ (post.replies?.length || 0) - 2 === 1 ? 'reply' : 'replies' }}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { FeedService, FeedConfig, ContentFilter } from '../services/FeedService'
|
import type { FeedService, FeedConfig, ContentFilter } from '../services/FeedService'
|
||||||
|
|
||||||
export interface UseFeedConfig {
|
export interface UseFeedConfig {
|
||||||
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
|
feedType: 'all' | 'announcements' | 'rideshare' | 'custom'
|
||||||
maxPosts?: number
|
maxPosts?: number
|
||||||
refreshInterval?: number
|
refreshInterval?: number
|
||||||
adminPubkeys?: string[]
|
adminPubkeys?: string[]
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,10 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
|
||||||
rideshare: {
|
rideshare: {
|
||||||
id: 'rideshare',
|
id: 'rideshare',
|
||||||
label: 'Rideshare',
|
label: 'Rideshare',
|
||||||
kinds: [1, 31001], // Text notes + custom rideshare events
|
kinds: [1], // Standard text notes with rideshare tags (NIP-01)
|
||||||
description: 'Rideshare requests, offers, and coordination',
|
description: 'Rideshare requests, offers, and coordination',
|
||||||
tags: ['rideshare', 'uber', 'lyft', 'carpool', 'taxi', 'ride'], // NIP-12 tags
|
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
||||||
keywords: ['rideshare', 'ride share', 'carpool', 'uber', 'lyft', 'taxi', 'pickup', 'dropoff']
|
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,43 +95,19 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
|
||||||
* Predefined filter combinations for common use cases
|
* Predefined filter combinations for common use cases
|
||||||
*/
|
*/
|
||||||
export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
||||||
// Basic presets
|
// All content
|
||||||
all: [
|
all: [
|
||||||
CONTENT_FILTERS.textNotes,
|
CONTENT_FILTERS.textNotes,
|
||||||
CONTENT_FILTERS.calendarEvents,
|
CONTENT_FILTERS.rideshare
|
||||||
CONTENT_FILTERS.longFormContent
|
|
||||||
// Note: reactions (kind 7) are handled separately by ReactionService
|
// Note: reactions (kind 7) are handled separately by ReactionService
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Admin announcements only
|
||||||
announcements: [
|
announcements: [
|
||||||
CONTENT_FILTERS.adminAnnouncements,
|
CONTENT_FILTERS.adminAnnouncements
|
||||||
CONTENT_FILTERS.textNotes // Include all text posts as fallback
|
|
||||||
],
|
|
||||||
|
|
||||||
community: [
|
|
||||||
CONTENT_FILTERS.communityPosts,
|
|
||||||
CONTENT_FILTERS.reposts
|
|
||||||
// Note: reactions are handled separately for counts
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
social: [
|
|
||||||
CONTENT_FILTERS.textNotes,
|
|
||||||
CONTENT_FILTERS.reposts,
|
|
||||||
CONTENT_FILTERS.chatMessages
|
|
||||||
// Note: reactions are for interaction counts, not displayed as posts
|
|
||||||
],
|
|
||||||
|
|
||||||
events: [
|
|
||||||
CONTENT_FILTERS.calendarEvents,
|
|
||||||
CONTENT_FILTERS.liveEvents
|
|
||||||
],
|
|
||||||
|
|
||||||
content: [
|
|
||||||
CONTENT_FILTERS.longFormContent,
|
|
||||||
CONTENT_FILTERS.textNotes
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Rideshare only
|
||||||
rideshare: [
|
rideshare: [
|
||||||
CONTENT_FILTERS.rideshare
|
CONTENT_FILTERS.rideshare
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export interface ContentFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedConfig {
|
export interface FeedConfig {
|
||||||
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
|
feedType: 'all' | 'announcements' | 'rideshare' | 'custom'
|
||||||
maxPosts?: number
|
maxPosts?: number
|
||||||
adminPubkeys?: string[]
|
adminPubkeys?: string[]
|
||||||
contentFilters?: ContentFilter[]
|
contentFilters?: ContentFilter[]
|
||||||
|
|
@ -176,8 +176,8 @@ export class FeedService extends BaseService {
|
||||||
filter.authors = config.adminPubkeys
|
filter.authors = config.adminPubkeys
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'general':
|
case 'rideshare':
|
||||||
// General posts - no specific author filtering
|
// Rideshare posts handled via content filters
|
||||||
break
|
break
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
|
|
@ -188,9 +188,20 @@ export class FeedService extends BaseService {
|
||||||
filters.push(filter)
|
filters.push(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add reactions (kind 7) to the filters
|
||||||
|
filters.push({
|
||||||
|
kinds: [7], // Reactions
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add ALL deletion events (kind 5) - we'll route them based on the 'k' tag
|
||||||
|
filters.push({
|
||||||
|
kinds: [5] // All deletion events (for both posts and reactions)
|
||||||
|
})
|
||||||
|
|
||||||
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
||||||
|
|
||||||
// Subscribe to events with deduplication
|
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
||||||
const unsubscribe = this.relayHub.subscribe({
|
const unsubscribe = this.relayHub.subscribe({
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
|
|
@ -232,7 +243,21 @@ export class FeedService extends BaseService {
|
||||||
* Handle new event with robust deduplication
|
* Handle new event with robust deduplication
|
||||||
*/
|
*/
|
||||||
private handleNewEvent(event: NostrEvent, config: FeedConfig): void {
|
private handleNewEvent(event: NostrEvent, config: FeedConfig): void {
|
||||||
// Skip if event already seen
|
// Route deletion events (kind 5) based on what's being deleted
|
||||||
|
if (event.kind === 5) {
|
||||||
|
this.handleDeletionEvent(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route reaction events (kind 7) to ReactionService
|
||||||
|
if (event.kind === 7) {
|
||||||
|
if (this.reactionService) {
|
||||||
|
this.reactionService.handleReactionEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if event already seen (for posts only, kind 1)
|
||||||
if (this.seenEventIds.has(event.id)) {
|
if (this.seenEventIds.has(event.id)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -313,21 +338,62 @@ export class FeedService extends BaseService {
|
||||||
}, 'nostr-feed')
|
}, 'nostr-feed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle deletion events (NIP-09)
|
||||||
|
* Routes deletions to appropriate service based on the 'k' tag
|
||||||
|
*/
|
||||||
|
private handleDeletionEvent(event: NostrEvent): void {
|
||||||
|
// Check the 'k' tag to determine what kind of event is being deleted
|
||||||
|
const kTag = event.tags?.find((tag: string[]) => tag[0] === 'k')
|
||||||
|
const deletedKind = kTag ? kTag[1] : null
|
||||||
|
|
||||||
|
// Route to ReactionService for reaction deletions (kind 7)
|
||||||
|
if (deletedKind === '7') {
|
||||||
|
if (this.reactionService) {
|
||||||
|
this.reactionService.handleDeletionEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle post deletions (kind 1) in FeedService
|
||||||
|
if (deletedKind === '1' || !deletedKind) {
|
||||||
|
// Extract event IDs to delete from 'e' tags
|
||||||
|
const eventIdsToDelete = event.tags
|
||||||
|
?.filter((tag: string[]) => tag[0] === 'e')
|
||||||
|
.map((tag: string[]) => tag[1]) || []
|
||||||
|
|
||||||
|
if (eventIdsToDelete.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deleted posts from the feed
|
||||||
|
this._posts.value = this._posts.value.filter(post => {
|
||||||
|
// Only delete if the deletion request comes from the same author (NIP-09 validation)
|
||||||
|
if (eventIdsToDelete.includes(post.id) && post.pubkey === event.pubkey) {
|
||||||
|
// Also remove from seen events so it won't be re-added
|
||||||
|
this.seenEventIds.delete(post.id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if event should be included in feed
|
* Check if event should be included in feed
|
||||||
*/
|
*/
|
||||||
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
|
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
|
||||||
// Never include reactions (kind 7) or deletions (kind 5) in the main feed
|
// Never include reactions (kind 7) in the main feed
|
||||||
// These should only be processed by the ReactionService
|
// Reactions should only be processed by the ReactionService
|
||||||
if (event.kind === 7 || event.kind === 5) {
|
if (event.kind === 7) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false
|
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false
|
||||||
|
|
||||||
// For custom content filters, check if event matches any active filter
|
// For custom content filters or specific feed types with filters, check if event matches any active filter
|
||||||
if (config.feedType === 'custom' && config.contentFilters) {
|
if ((config.feedType === 'custom' || config.feedType === 'rideshare') && config.contentFilters) {
|
||||||
console.log('FeedService: Using custom filters, count:', config.contentFilters.length)
|
console.log('FeedService: Using custom filters, count:', config.contentFilters.length)
|
||||||
const result = config.contentFilters.some(filter => {
|
const result = config.contentFilters.some(filter => {
|
||||||
console.log('FeedService: Checking filter:', filter.id, 'kinds:', filter.kinds, 'filterByAuthor:', filter.filterByAuthor)
|
console.log('FeedService: Checking filter:', filter.id, 'kinds:', filter.kinds, 'filterByAuthor:', filter.filterByAuthor)
|
||||||
|
|
@ -347,26 +413,34 @@ export class FeedService extends BaseService {
|
||||||
if (isAdminPost) return false
|
if (isAdminPost) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply keyword filtering if specified
|
// Apply keyword and tag filtering (OR logic when both are specified)
|
||||||
if (filter.keywords && filter.keywords.length > 0) {
|
const hasKeywordFilter = filter.keywords && filter.keywords.length > 0
|
||||||
const content = event.content.toLowerCase()
|
const hasTagFilter = filter.tags && filter.tags.length > 0
|
||||||
const hasMatchingKeyword = filter.keywords.some(keyword =>
|
|
||||||
content.includes(keyword.toLowerCase())
|
|
||||||
)
|
|
||||||
if (!hasMatchingKeyword) {
|
|
||||||
console.log('FeedService: No matching keywords found')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply tag filtering if specified (check if event has any matching tags)
|
if (hasKeywordFilter || hasTagFilter) {
|
||||||
if (filter.tags && filter.tags.length > 0) {
|
let keywordMatch = false
|
||||||
const eventTags = event.tags?.filter(tag => tag[0] === 't').map(tag => tag[1]) || []
|
let tagMatch = false
|
||||||
const hasMatchingTag = filter.tags.some(filterTag =>
|
|
||||||
eventTags.includes(filterTag)
|
// Check keywords
|
||||||
)
|
if (hasKeywordFilter) {
|
||||||
if (!hasMatchingTag) {
|
const content = event.content.toLowerCase()
|
||||||
console.log('FeedService: No matching tags found')
|
keywordMatch = filter.keywords!.some(keyword =>
|
||||||
|
content.includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tags
|
||||||
|
if (hasTagFilter) {
|
||||||
|
const eventTags = event.tags?.filter(tag => tag[0] === 't').map(tag => tag[1]) || []
|
||||||
|
tagMatch = filter.tags!.some(filterTag =>
|
||||||
|
eventTags.includes(filterTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must match at least one: keywords OR tags
|
||||||
|
const hasMatch = (hasKeywordFilter && keywordMatch) || (hasTagFilter && tagMatch)
|
||||||
|
if (!hasMatch) {
|
||||||
|
console.log('FeedService: No matching keywords or tags found')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -378,18 +452,14 @@ export class FeedService extends BaseService {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy feed type handling
|
// Feed type handling
|
||||||
switch (config.feedType) {
|
switch (config.feedType) {
|
||||||
case 'announcements':
|
case 'announcements':
|
||||||
return isAdminPost
|
return isAdminPost
|
||||||
case 'general':
|
case 'rideshare':
|
||||||
return !isAdminPost
|
// Rideshare filtering handled via content filters above
|
||||||
case 'events':
|
// If we reach here, contentFilters weren't provided - show nothing
|
||||||
// Events feed could show all posts for now, or implement event-specific filtering
|
return false
|
||||||
return true
|
|
||||||
case 'mentions':
|
|
||||||
// TODO: Implement mention detection if needed
|
|
||||||
return true
|
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@ export class ReactionService extends BaseService {
|
||||||
private currentSubscription: string | null = null
|
private currentSubscription: string | null = null
|
||||||
private currentUnsubscribe: (() => void) | null = null
|
private currentUnsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
// Track deletion subscription separately
|
|
||||||
private deletionUnsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
// Track which events we're monitoring
|
// Track which events we're monitoring
|
||||||
private monitoredEvents = new Set<string>()
|
private monitoredEvents = new Set<string>()
|
||||||
|
|
||||||
|
|
@ -60,50 +57,8 @@ export class ReactionService extends BaseService {
|
||||||
throw new Error('RelayHub service not available')
|
throw new Error('RelayHub service not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start monitoring deletion events globally
|
// Deletion monitoring is now handled by FeedService's consolidated subscription
|
||||||
await this.startDeletionMonitoring()
|
console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)')
|
||||||
|
|
||||||
console.log('ReactionService: Initialization complete')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start monitoring deletion events globally
|
|
||||||
*/
|
|
||||||
private async startDeletionMonitoring(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!this.relayHub?.isConnected) {
|
|
||||||
await this.relayHub?.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionId = `reaction-deletions-${Date.now()}`
|
|
||||||
|
|
||||||
// Subscribe to ALL deletion events for reactions
|
|
||||||
const filter = {
|
|
||||||
kinds: [5], // Deletion requests
|
|
||||||
'#k': ['7'], // Only for reaction events
|
|
||||||
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
|
|
||||||
limit: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('ReactionService: Starting global deletion monitoring')
|
|
||||||
|
|
||||||
const unsubscribe = this.relayHub.subscribe({
|
|
||||||
id: subscriptionId,
|
|
||||||
filters: [filter],
|
|
||||||
onEvent: (event: NostrEvent) => {
|
|
||||||
this.handleDeletionEvent(event)
|
|
||||||
},
|
|
||||||
onEose: () => {
|
|
||||||
console.log('ReactionService: Initial deletion events loaded')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store subscription ID if needed for tracking
|
|
||||||
this.deletionUnsubscribe = unsubscribe
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start deletion monitoring:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -146,34 +101,24 @@ export class ReactionService extends BaseService {
|
||||||
|
|
||||||
const subscriptionId = `reactions-${Date.now()}`
|
const subscriptionId = `reactions-${Date.now()}`
|
||||||
|
|
||||||
// Subscribe to reactions (kind 7) and deletions (kind 5) for these events
|
// Subscribe to reactions (kind 7) for these events
|
||||||
|
// Deletions (kind 5) are now handled by FeedService's consolidated subscription
|
||||||
const filters = [
|
const filters = [
|
||||||
{
|
{
|
||||||
kinds: [7], // Reactions
|
kinds: [7], // Reactions
|
||||||
'#e': newEventIds, // Events being reacted to
|
'#e': newEventIds, // Events being reacted to
|
||||||
limit: 1000
|
limit: 1000
|
||||||
},
|
|
||||||
{
|
|
||||||
kinds: [5], // Deletion requests for ALL users
|
|
||||||
'#k': ['7'], // Only deletions of reaction events (kind 7)
|
|
||||||
limit: 500
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
console.log('ReactionService: Creating reaction subscription', filters)
|
|
||||||
|
|
||||||
const unsubscribe = this.relayHub.subscribe({
|
const unsubscribe = this.relayHub.subscribe({
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
if (event.kind === 7) {
|
this.handleReactionEvent(event)
|
||||||
this.handleReactionEvent(event)
|
|
||||||
} else if (event.kind === 5) {
|
|
||||||
this.handleDeletionEvent(event)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
console.log(`Reaction subscription ${subscriptionId} complete`)
|
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -190,8 +135,9 @@ export class ReactionService extends BaseService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming reaction event
|
* Handle incoming reaction event
|
||||||
|
* Made public so FeedService can route kind 7 events to this service
|
||||||
*/
|
*/
|
||||||
private handleReactionEvent(event: NostrEvent): void {
|
public handleReactionEvent(event: NostrEvent): void {
|
||||||
try {
|
try {
|
||||||
// Find the event being reacted to
|
// Find the event being reacted to
|
||||||
const eTag = event.tags.find(tag => tag[0] === 'e')
|
const eTag = event.tags.find(tag => tag[0] === 'e')
|
||||||
|
|
@ -235,7 +181,6 @@ export class ReactionService extends BaseService {
|
||||||
|
|
||||||
if (previousReactionIndex >= 0) {
|
if (previousReactionIndex >= 0) {
|
||||||
// Replace the old reaction with the new one
|
// Replace the old reaction with the new one
|
||||||
console.log(`ReactionService: Replacing previous reaction from ${reaction.pubkey.slice(0, 8)}...`)
|
|
||||||
eventReactions.reactions[previousReactionIndex] = reaction
|
eventReactions.reactions[previousReactionIndex] = reaction
|
||||||
} else {
|
} else {
|
||||||
// Add as new reaction
|
// Add as new reaction
|
||||||
|
|
@ -245,17 +190,16 @@ export class ReactionService extends BaseService {
|
||||||
// Recalculate counts and user state
|
// Recalculate counts and user state
|
||||||
this.recalculateEventReactions(eventId)
|
this.recalculateEventReactions(eventId)
|
||||||
|
|
||||||
console.log(`ReactionService: Added/updated reaction ${content} to event ${eventId.slice(0, 8)}...`)
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle reaction event:', error)
|
console.error('Failed to handle reaction event:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle deletion event
|
* Handle deletion event (called by FeedService when a kind 5 event with k=7 is received)
|
||||||
|
* Made public so FeedService can route deletion events to this service
|
||||||
*/
|
*/
|
||||||
private handleDeletionEvent(event: NostrEvent): void {
|
public handleDeletionEvent(event: NostrEvent): void {
|
||||||
try {
|
try {
|
||||||
// Process each deleted event
|
// Process each deleted event
|
||||||
const eTags = event.tags.filter(tag => tag[0] === 'e')
|
const eTags = event.tags.filter(tag => tag[0] === 'e')
|
||||||
|
|
@ -281,9 +225,6 @@ export class ReactionService extends BaseService {
|
||||||
eventReactions.reactions.splice(reactionIndex, 1)
|
eventReactions.reactions.splice(reactionIndex, 1)
|
||||||
// Recalculate counts for this event
|
// Recalculate counts for this event
|
||||||
this.recalculateEventReactions(eventId)
|
this.recalculateEventReactions(eventId)
|
||||||
console.log(`ReactionService: Removed deleted reaction ${deletedEventId.slice(0, 8)}... from ${deletionAuthor.slice(0, 8)}...`)
|
|
||||||
} else {
|
|
||||||
console.log(`ReactionService: Ignoring deletion request from ${deletionAuthor.slice(0, 8)}... for reaction by ${reaction.pubkey.slice(0, 8)}...`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -393,18 +334,12 @@ export class ReactionService extends BaseService {
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('ReactionService: Creating like reaction:', eventTemplate)
|
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
||||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||||
|
|
||||||
console.log('ReactionService: Publishing like reaction:', signedEvent)
|
|
||||||
|
|
||||||
// Publish the reaction
|
// Publish the reaction
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
await this.relayHub.publishEvent(signedEvent)
|
||||||
|
|
||||||
console.log(`ReactionService: Like published to ${result.success}/${result.total} relays`)
|
|
||||||
|
|
||||||
// Optimistically update local state
|
// Optimistically update local state
|
||||||
this.handleReactionEvent(signedEvent)
|
this.handleReactionEvent(signedEvent)
|
||||||
|
|
@ -438,6 +373,7 @@ export class ReactionService extends BaseService {
|
||||||
|
|
||||||
// Get the user's reaction ID to delete
|
// Get the user's reaction ID to delete
|
||||||
const eventReactions = this.getEventReactions(eventId)
|
const eventReactions = this.getEventReactions(eventId)
|
||||||
|
|
||||||
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
|
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
|
||||||
throw new Error('No reaction to remove')
|
throw new Error('No reaction to remove')
|
||||||
}
|
}
|
||||||
|
|
@ -456,14 +392,10 @@ export class ReactionService extends BaseService {
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('ReactionService: Creating deletion event for reaction:', eventReactions.userReactionId)
|
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
||||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||||
|
|
||||||
console.log('ReactionService: Publishing deletion event:', signedEvent)
|
|
||||||
|
|
||||||
// Publish the deletion
|
// Publish the deletion
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
const result = await this.relayHub.publishEvent(signedEvent)
|
||||||
|
|
||||||
|
|
@ -527,9 +459,7 @@ export class ReactionService extends BaseService {
|
||||||
if (this.currentUnsubscribe) {
|
if (this.currentUnsubscribe) {
|
||||||
this.currentUnsubscribe()
|
this.currentUnsubscribe()
|
||||||
}
|
}
|
||||||
if (this.deletionUnsubscribe) {
|
// deletionUnsubscribe is no longer used - deletions handled by FeedService
|
||||||
this.deletionUnsubscribe()
|
|
||||||
}
|
|
||||||
this._eventReactions.clear()
|
this._eventReactions.clear()
|
||||||
this.monitoredEvents.clear()
|
this.monitoredEvents.clear()
|
||||||
this.deletedReactions.clear()
|
this.deletedReactions.clear()
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Feed Area - Takes remaining height -->
|
<!-- Main Feed Area - Takes remaining height with scrolling -->
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<!-- Collapsible Composer -->
|
<!-- Collapsible Composer -->
|
||||||
<div v-if="showComposer || replyTo" class="border-b bg-background">
|
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
|
||||||
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<div class="px-4 py-3 sm:px-6">
|
<div class="px-4 py-3 sm:px-6">
|
||||||
<!-- Regular Note Composer -->
|
<!-- Regular Note Composer -->
|
||||||
|
|
@ -59,8 +59,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feed Content - Full height scroll -->
|
<!-- Feed Content - Natural flow with padding for sticky elements -->
|
||||||
<div class="h-full">
|
<div>
|
||||||
<NostrFeed
|
<NostrFeed
|
||||||
:feed-type="feedType"
|
:feed-type="feedType"
|
||||||
:content-filters="selectedFilters"
|
:content-filters="selectedFilters"
|
||||||
|
|
@ -166,9 +166,7 @@ const replyTo = ref<ReplyToNote | undefined>()
|
||||||
// Quick filter presets for mobile bottom bar
|
// Quick filter presets for mobile bottom bar
|
||||||
const quickFilterPresets = {
|
const quickFilterPresets = {
|
||||||
all: { label: 'All', filters: FILTER_PRESETS.all },
|
all: { label: 'All', filters: FILTER_PRESETS.all },
|
||||||
announcements: { label: 'News', filters: FILTER_PRESETS.announcements },
|
announcements: { label: 'Announcements', filters: FILTER_PRESETS.announcements },
|
||||||
social: { label: 'Social', filters: FILTER_PRESETS.social },
|
|
||||||
events: { label: 'Events', filters: FILTER_PRESETS.events },
|
|
||||||
rideshare: { label: 'Rideshare', filters: FILTER_PRESETS.rideshare }
|
rideshare: { label: 'Rideshare', filters: FILTER_PRESETS.rideshare }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +185,7 @@ const isPresetActive = (presetKey: string) => {
|
||||||
const feedType = computed(() => {
|
const feedType = computed(() => {
|
||||||
if (selectedFilters.value.length === 0) return 'all'
|
if (selectedFilters.value.length === 0) return 'all'
|
||||||
|
|
||||||
// Check if it matches the 'all' preset - if so, use 'all' feed type for simple filtering
|
// Check if it matches the 'all' preset
|
||||||
if (selectedFilters.value.length === FILTER_PRESETS.all.length &&
|
if (selectedFilters.value.length === FILTER_PRESETS.all.length &&
|
||||||
FILTER_PRESETS.all.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
FILTER_PRESETS.all.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
||||||
return 'all'
|
return 'all'
|
||||||
|
|
@ -199,6 +197,12 @@ const feedType = computed(() => {
|
||||||
return 'announcements'
|
return 'announcements'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it matches the rideshare preset
|
||||||
|
if (selectedFilters.value.length === FILTER_PRESETS.rideshare.length &&
|
||||||
|
FILTER_PRESETS.rideshare.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
||||||
|
return 'rideshare'
|
||||||
|
}
|
||||||
|
|
||||||
// For all other cases, use custom
|
// For all other cases, use custom
|
||||||
return 'custom'
|
return 'custom'
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue