web-app/src/modules/nostr-feed/components/ThreadedPost.vue
padreug 791bdbe3eb Refactor indentation and styling in ThreadedPost component
- Adjusted margin and padding values for nested posts to improve visual hierarchy and readability.
- Updated styles for vertical lines and post containers to enhance the overall layout of threaded discussions.

These changes contribute to a more organized and user-friendly interface within the NostrFeed module.
2025-09-23 23:59:43 +02:00

269 lines
No EOL
8.6 KiB
Vue

<script setup lang="ts">
import { ref, 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 type { FeedPost } from '../services/FeedService'
interface Props {
post: FeedPost
adminPubkeys?: string[]
getDisplayName: (pubkey: string) => string
getEventReactions: (eventId: string) => { likes: number; userHasLiked: boolean }
depth?: number
}
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
(e: 'toggle-like', note: FeedPost): void
}
const props = withDefaults(defineProps<Props>(), {
adminPubkeys: () => [],
depth: 0
})
const emit = defineEmits<Emits>()
// Collapse state for this post's replies
const isCollapsed = ref(false)
// Check if this is an admin post
const isAdminPost = computed(() => props.adminPubkeys.includes(props.post.pubkey))
// 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
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
// 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 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-border/40"
:style="{ marginLeft: `${(depth - 1) * 12 + 6}px` }"
/>
<!-- Post container with indentation -->
<div
:class="{
'pl-3': depth > 0,
'hover:bg-accent/50': true,
'transition-colors': true,
'border-b': depth === 0,
'border-border/40': depth === 0
}"
:style="{ marginLeft: `${depth * 12}px` }"
>
<div class="p-3 relative">
<!-- Horizontal line from vertical line to content (for nested replies) -->
<div
v-if="depth > 0"
class="absolute left-0 top-7 w-2 h-px bg-border/40"
:style="{ marginLeft: `-${depth * 12}px` }"
/>
<!-- 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-6 w-6 p-0"
@click="toggleCollapse"
>
<ChevronDown v-if="!isCollapsed" class="h-4 w-4" />
<ChevronUp v-else class="h-4 w-4" />
</Button>
<div v-else class="w-6" />
<!-- Badges -->
<Badge
v-if="isAdminPost"
variant="default"
class="text-xs px-1.5 py-0.5"
>
Admin
</Badge>
<Badge
v-if="post.isReply && depth === 0"
variant="secondary"
class="text-xs px-1.5 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"
>
🚗 {{ getRideshareType(post) }}
</Badge>
<!-- Author name -->
<span class="text-sm font-medium">{{ 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"
>
{{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }}
</Badge>
</div>
<!-- Timestamp -->
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(post.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<!-- Post Content (hidden when collapsed if has replies) -->
<div
v-if="!isCollapsed || !hasReplies"
class="text-sm leading-relaxed whitespace-pre-wrap"
>
{{ post.content }}
</div>
<!-- Post Actions (hidden when collapsed) -->
<div v-if="!isCollapsed" class="mt-2 pt-2 border-t">
<div class="flex items-center justify-between">
<!-- Mentions -->
<div v-if="post.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in post.mentions.slice(0, 2)" :key="mention" class="font-mono">
{{ mention.slice(0, 6) }}...
</span>
<span v-if="post.mentions.length > 2" class="text-muted-foreground">
+{{ post.mentions.length - 2 }} more
</span>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
@click="onReplyToNote"
>
<Reply class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Reply</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(post.id).userHasLiked }"
@click="onToggleLike"
>
<Heart
class="h-3.5 w-3.5 sm:mr-1"
:class="{ 'fill-current': getEventReactions(post.id).userHasLiked }"
/>
<span class="hidden sm:inline">
{{ getEventReactions(post.id).userHasLiked ? 'Liked' : 'Like' }}
</span>
<span v-if="getEventReactions(post.id).likes > 0" class="ml-1 text-xs">
{{ getEventReactions(post.id).likes }}
</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
>
<Share class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Share</span>
</Button>
</div>
</div>
</div>
</div>
<!-- Render replies recursively (hidden when collapsed) -->
<div v-if="!isCollapsed && hasReplies">
<ThreadedPost
v-for="reply in post.replies"
:key="reply.id"
:post="reply"
:admin-pubkeys="adminPubkeys"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="depth + 1"
@reply-to-note="$emit('reply-to-note', $event)"
@toggle-like="$emit('toggle-like', $event)"
/>
</div>
</div>
</div>
</template>