297 lines
10 KiB
Vue
297 lines
10 KiB
Vue
<script setup lang="ts">
|
|
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, 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
|
|
parentCollapsed?: boolean
|
|
collapsedPosts?: Set<string>
|
|
limitedReplyPosts?: Set<string>
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
|
(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>(), {
|
|
adminPubkeys: () => [],
|
|
depth: 0,
|
|
parentCollapsed: false,
|
|
collapsedPosts: () => new Set<string>(),
|
|
limitedReplyPosts: () => new Set<string>()
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// Check if this post is collapsed based on centralized state
|
|
const isCollapsed = computed(() => props.collapsedPosts?.has(props.post.id) || false)
|
|
|
|
// Check if this post has limited replies showing (only first 2)
|
|
const hasLimitedReplies = computed(() => props.limitedReplyPosts?.has(props.post.id) || false)
|
|
|
|
// Check if this post should be visible (not hidden by parent collapse)
|
|
// Note: A post is only hidden if its PARENT is collapsed, not if IT is collapsed
|
|
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)
|
|
|
|
// Count total nested replies
|
|
const replyCount = computed(() => {
|
|
const countReplies = (post: FeedPost): number => {
|
|
let count = (post.replies?.length || 0)
|
|
post.replies?.forEach(reply => {
|
|
count += countReplies(reply)
|
|
})
|
|
return count
|
|
}
|
|
return countReplies(props.post)
|
|
})
|
|
|
|
// Toggle collapse state - emit to parent for centralized management
|
|
function toggleCollapse() {
|
|
emit('toggle-collapse', props.post.id)
|
|
}
|
|
|
|
// Handle reply action
|
|
function onReplyToNote() {
|
|
emit('reply-to-note', {
|
|
id: props.post.id,
|
|
content: props.post.content,
|
|
pubkey: props.post.pubkey
|
|
})
|
|
}
|
|
|
|
// Handle like action
|
|
function onToggleLike() {
|
|
emit('toggle-like', props.post)
|
|
}
|
|
|
|
// Check if a post is a rideshare post (for badge display)
|
|
function isRidesharePost(post: FeedPost): boolean {
|
|
const hasTags = post.tags?.some((tag: string[]) =>
|
|
tag[0] === 't' && ['rideshare', 'carpool'].includes(tag[1])
|
|
) || false
|
|
|
|
const hasRideshareTypeTags = post.tags?.some((tag: string[]) =>
|
|
tag[0] === 'rideshare_type' && ['offering', 'seeking'].includes(tag[1])
|
|
) || false
|
|
|
|
const hasRideshareContent = !!(post.content && (
|
|
post.content.includes('🚗 OFFERING RIDE') ||
|
|
post.content.includes('🚶 SEEKING RIDE') ||
|
|
post.content.includes('#rideshare') ||
|
|
post.content.includes('#carpool')
|
|
))
|
|
|
|
return hasTags || hasRideshareTypeTags || hasRideshareContent
|
|
}
|
|
|
|
// Get rideshare type from post
|
|
function getRideshareType(post: FeedPost): string {
|
|
const typeTag = post.tags?.find((tag: string[]) => tag[0] === 'rideshare_type')
|
|
if (typeTag) {
|
|
return typeTag[1] === 'offering' ? 'Offering Ride' : 'Seeking Ride'
|
|
}
|
|
|
|
if (post.content?.includes('🚗 OFFERING RIDE')) return 'Offering Ride'
|
|
if (post.content?.includes('🚶 SEEKING RIDE')) return 'Seeking Ride'
|
|
|
|
return 'Rideshare'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="isVisible" class="relative">
|
|
<!-- Post container with Lemmy-style border-left threading -->
|
|
<div
|
|
:class="{
|
|
'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
|
|
}"
|
|
>
|
|
<div class="p-3 md:p-5 relative">
|
|
|
|
<!-- Post Header -->
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Collapse button for posts with replies -->
|
|
<Button
|
|
v-if="hasReplies"
|
|
variant="ghost"
|
|
size="sm"
|
|
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 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-7 md:w-8" />
|
|
|
|
<!-- Badges -->
|
|
<Badge
|
|
v-if="isAdminPost"
|
|
variant="default"
|
|
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 md:text-sm px-2 py-0.5"
|
|
>
|
|
Reply
|
|
</Badge>
|
|
<Badge
|
|
v-if="isRidesharePost(post)"
|
|
variant="secondary"
|
|
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 md:text-base font-semibold">{{ getDisplayName(post.pubkey) }}</span>
|
|
|
|
<!-- Reply count badge if collapsed -->
|
|
<Badge
|
|
v-if="isCollapsed && hasReplies"
|
|
variant="outline"
|
|
class="text-xs md:text-sm px-2 py-0.5"
|
|
>
|
|
{{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<!-- Timestamp -->
|
|
<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 md:text-base leading-relaxed whitespace-pre-wrap mb-3">
|
|
{{ post.content }}
|
|
</div>
|
|
|
|
<!-- Post Actions (always visible) -->
|
|
<div class="mt-3">
|
|
<div class="flex items-center gap-1 md:gap-2">
|
|
<!-- Reply Button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
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.5 w-3.5 md:h-4 md:w-4" />
|
|
</Button>
|
|
|
|
<!-- Like Button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
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.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.5">
|
|
{{ getEventReactions(post.id).likes }}
|
|
</span>
|
|
</Button>
|
|
|
|
<!-- Share Button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
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.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>
|
|
</div>
|
|
|
|
<!-- Render replies recursively -->
|
|
<div v-if="hasReplies">
|
|
<!-- Show first 2 replies when limited, or all when not limited -->
|
|
<ThreadedPost
|
|
v-for="reply in hasLimitedReplies ? post.replies?.slice(0, 2) : post.replies"
|
|
:key="reply.id"
|
|
:post="reply"
|
|
:admin-pubkeys="adminPubkeys"
|
|
:current-user-pubkey="currentUserPubkey"
|
|
:get-display-name="getDisplayName"
|
|
:get-event-reactions="getEventReactions"
|
|
:depth="depth + 1"
|
|
:parent-collapsed="isCollapsed"
|
|
:collapsed-posts="collapsedPosts"
|
|
:limited-reply-posts="limitedReplyPosts"
|
|
@reply-to-note="$emit('reply-to-note', $event)"
|
|
@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 mb-1 ml-0.5"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
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' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|