Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions components/post/post-action-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client'

import type { ReactNode } from 'react'
import { motion } from 'framer-motion'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Tooltip from '@radix-ui/react-tooltip'
import {
ArrowPathIcon,
ArrowUpTrayIcon,
BookmarkIcon,
ChatBubbleOvalLeftIcon,
CurrencyDollarIcon,
HeartIcon,
PencilSquareIcon,
} from '@heroicons/react/24/outline'
import { HeartIcon as HeartIconSolid, BookmarkIcon as BookmarkIconSolid } from '@heroicons/react/24/solid'
import { cn, formatNumber } from '@/lib/utils'
import { stopPropagation } from '@/lib/utils/events'
import { logger } from '@/lib/logger'

function ActionTooltip({ label, children }: { label: string; children: ReactNode }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="bg-gray-800 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded" sideOffset={5}>
{label}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}

const MENU_ITEM = 'flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer outline-none'

interface PostActionBarProps {
postId: string
isOwnPost: boolean
reply: { count: number; enabled: boolean; reason?: string | null; onClick: () => void }
/** `allowed` false hides the Repost item; the control still shows the count and Quote. */
repost: { count: number; active: boolean; loading: boolean; allowed: boolean; onClick: () => void }
like: { count: number; active: boolean; loading: boolean; onClick: () => void }
/** Absent where the topology has no bookmark doctype for this kind. */
bookmark?: { active: boolean; loading: boolean; onClick: () => void }
onQuote: () => void
onTip: () => void
onShare: () => void
}

/** Stop the card click, then run the action; async failures are logged. */
export function stopAndRun(e: React.MouseEvent, action: () => void | Promise<void>) {
e.stopPropagation()
Promise.resolve(action()).catch((error) => logger.error(error))
}

/** The reply / repost / like / tip / bookmark / share row under a post. */
export function PostActionBar({ postId, isOwnPost, reply, repost, like, bookmark, onQuote, onTip, onShare }: PostActionBarProps) {
return (
<div className="flex items-center justify-between mt-1 -ml-2 max-w-[485px]">
<Tooltip.Provider>
<ActionTooltip label={reply.reason || 'Reply'}>
<button
data-testid={`reply-btn-${postId}`}
onClick={(e) => stopAndRun(e, reply.onClick)}
disabled={!reply.enabled}
className={cn('group flex items-center gap-1 p-2 rounded-full transition-colors', reply.enabled ? 'hover:bg-yappr-50 dark:hover:bg-yappr-950' : 'opacity-50 cursor-not-allowed')}
>
<ChatBubbleOvalLeftIcon className={cn('h-5 w-5 transition-colors', reply.enabled ? 'text-gray-500 group-hover:text-yappr-500' : 'text-gray-400')} />
<span className={cn('text-sm transition-colors', reply.enabled ? 'text-gray-500 group-hover:text-yappr-500' : 'text-gray-400')}>
{reply.count > 0 && formatNumber(reply.count)}
</span>
</button>
</ActionTooltip>

<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
data-testid={`repost-menu-btn-${postId}`}
onClick={stopPropagation}
disabled={repost.loading}
className={cn(
'group flex items-center gap-1 p-2 rounded-full transition-colors hover:bg-green-50 dark:hover:bg-green-950',
repost.loading && 'opacity-50 cursor-wait',
repost.active && 'text-green-500'
)}
>
<ArrowPathIcon className={cn('h-5 w-5 transition-colors', repost.loading && 'animate-spin', repost.active ? 'text-green-500' : 'text-gray-500 group-hover:text-green-500')} />
<span className={cn('text-sm transition-colors', repost.active ? 'text-green-500' : 'text-gray-500 group-hover:text-green-500')}>
{repost.count > 0 && formatNumber(repost.count)}
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[160px] bg-white dark:bg-neutral-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 py-2 z-50"
sideOffset={5}
onClick={stopPropagation}
>
{/* Reposting a reply has no doctype on v3, so the item is absent rather than failing. */}
{repost.allowed && (
<DropdownMenu.Item onClick={(e) => stopAndRun(e, repost.onClick)} className={MENU_ITEM}>
<ArrowPathIcon className={cn('h-5 w-5', repost.active && 'text-green-500')} />
{repost.active ? 'Undo Repost' : 'Repost'}
</DropdownMenu.Item>
)}
<DropdownMenu.Item onClick={(e) => stopAndRun(e, onQuote)} className={MENU_ITEM}>
<PencilSquareIcon className="h-5 w-5" />
Quote
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

<ActionTooltip label="Like">
<button
data-testid={`like-btn-${postId}`}
aria-pressed={like.active}
onClick={(e) => stopAndRun(e, like.onClick)}
disabled={like.loading}
className={cn(
'group flex items-center gap-1 p-2 rounded-full transition-colors hover:bg-red-50 dark:hover:bg-red-950',
like.loading && 'opacity-50 cursor-wait',
like.active && 'text-red-500'
)}
>
<motion.div whileTap={{ scale: 0.8 }} transition={{ type: 'spring', stiffness: 400, damping: 17 }}>
{like.active ? <HeartIconSolid className="h-5 w-5 text-red-500" /> : <HeartIcon className="h-5 w-5 text-gray-500 group-hover:text-red-500 transition-colors" />}
</motion.div>
<span className={cn('text-sm transition-colors', like.active ? 'text-red-500' : 'text-gray-500 group-hover:text-red-500')}>
{like.count > 0 && formatNumber(like.count)}
</span>
</button>
</ActionTooltip>

<ActionTooltip label={isOwnPost ? "Can't tip yourself" : 'Tip'}>
<button
onClick={(e) => stopAndRun(e, onTip)}
disabled={isOwnPost}
className={cn('group flex items-center gap-1 p-2 rounded-full transition-colors', isOwnPost ? 'opacity-40 cursor-not-allowed' : 'hover:bg-amber-50 dark:hover:bg-amber-950')}
>
<CurrencyDollarIcon className={cn('h-5 w-5 transition-colors', isOwnPost ? 'text-gray-400' : 'text-gray-500 group-hover:text-amber-500')} />
</button>
</ActionTooltip>

<div className="flex items-center gap-1">
{bookmark && (
<ActionTooltip label="Bookmark">
<button
data-testid={`bookmark-btn-${postId}`}
onClick={(e) => stopAndRun(e, bookmark.onClick)}
disabled={bookmark.loading}
className={cn('p-2 rounded-full hover:bg-yappr-50 dark:hover:bg-yappr-950 transition-colors', bookmark.loading && 'opacity-50 cursor-wait')}
>
{bookmark.active ? <BookmarkIconSolid className="h-5 w-5 text-yappr-500" /> : <BookmarkIcon className="h-5 w-5 text-gray-500 hover:text-yappr-500 transition-colors" />}
</button>
</ActionTooltip>
)}
<ActionTooltip label="Share">
<button onClick={(e) => stopAndRun(e, onShare)} className="p-2 rounded-full hover:bg-yappr-50 dark:hover:bg-yappr-950 transition-colors">
<ArrowUpTrayIcon className="h-5 w-5 text-gray-500 hover:text-yappr-500 transition-colors" />
</button>
</ActionTooltip>
</div>
</Tooltip.Provider>
</div>
)
}
93 changes: 93 additions & 0 deletions components/post/post-author-line.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client'

import Link from 'next/link'
import type { Post } from '@/lib/types'
import { useCopy } from '@/hooks/use-copy'
import { ProfileHoverCard } from '@/components/profile/profile-hover-card'
import { TooltipBadge } from '@/components/ui/tooltip-button'
import { stopPropagation } from '@/lib/utils/events'

/** `undefined` while the lookup is running, `null` for an author with no DPNS name. */
export type UsernameState = string | null | undefined

/** Progressive enrichment wins; otherwise the author's own `hasDpns` flag decides. */
export function resolveUsernameState(progressiveUsername: UsernameState, author: Post['author']): UsernameState {
if (progressiveUsername !== undefined) return progressiveUsername
if (author.hasDpns === undefined) return undefined
return author.hasDpns ? author.username : null
}

/** Whether a display name is a real profile name rather than one of our placeholders. */
export function hasRealProfile(displayName: string | undefined, identityId: string): boolean {
if (!displayName || displayName === 'Unknown User') return false
return displayName !== `User ${identityId.slice(-6)}` && displayName !== `User ${identityId.slice(-8)}`
}

const VERIFIED_PATH =
'M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z'

interface PostAuthorLineProps {
author: Post['author']
usernameState: UsernameState
displayName: string
avatarUrl: string | undefined
profileLoaded: boolean
}

/** Display name, verified mark and handle (or identity id) for the author of a card. */
export function PostAuthorLine({ author, usernameState, displayName, avatarUrl, profileLoaded }: PostAuthorLineProps) {
const copy = useCopy()
const hasProfile = hasRealProfile(displayName, author.id)
const hover = { userId: author.id, displayName, avatarUrl }

const handle = () => {
if (usernameState) {
return (
<ProfileHoverCard {...hover} username={usernameState}>
<Link href={`/user?id=${author.id}`} onClick={stopPropagation} className="text-gray-500 hover:underline truncate">
@{usernameState}
</Link>
</ProfileHoverCard>
)
}
if (usernameState === undefined) return <span className="inline-block w-20 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
// A profile name is enough on its own; only a nameless author shows the id.
if (hasProfile) return null
return (
<ProfileHoverCard {...hover} username={null}>
<TooltipBadge label="Click to copy full identity ID" className="inline-flex">
<button
onClick={(e) => {
e.stopPropagation()
copy(author.id, 'Identity ID copied')
}}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 truncate font-mono text-xs"
>
{author.id.slice(0, 8)}...{author.id.slice(-6)}
</button>
</TooltipBadge>
</ProfileHoverCard>
)
}

return (
<>
{usernameState === undefined || (!hasProfile && !profileLoaded) ? (
<span className="inline-block w-24 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
) : (
<ProfileHoverCard {...hover} username={usernameState}>
<Link href={`/user?id=${author.id}`} onClick={stopPropagation} className="font-semibold hover:underline truncate">
{hasProfile ? displayName : 'Unknown User'}
</Link>
</ProfileHoverCard>
)}
{author.verified && (
<svg className="h-4 w-4 text-yappr-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d={VERIFIED_PATH} />
</svg>
)}
{handle()}
<span className="text-gray-500 flex-shrink-0">·</span>
</>
)
}
Loading
Loading