diff --git a/components/post/post-action-bar.tsx b/components/post/post-action-bar.tsx new file mode 100644 index 00000000..93d7afd3 --- /dev/null +++ b/components/post/post-action-bar.tsx @@ -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 ( + + {children} + + + {label} + + + + ) +} + +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) { + 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 ( +
+ + + + + + + + + + + + {/* Reposting a reply has no doctype on v3, so the item is absent rather than failing. */} + {repost.allowed && ( + stopAndRun(e, repost.onClick)} className={MENU_ITEM}> + + {repost.active ? 'Undo Repost' : 'Repost'} + + )} + stopAndRun(e, onQuote)} className={MENU_ITEM}> + + Quote + + + + + + + + + + + + + +
+ {bookmark && ( + + + + )} + + + +
+
+
+ ) +} diff --git a/components/post/post-author-line.tsx b/components/post/post-author-line.tsx new file mode 100644 index 00000000..ef941156 --- /dev/null +++ b/components/post/post-author-line.tsx @@ -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 ( + + + @{usernameState} + + + ) + } + if (usernameState === undefined) return + // A profile name is enough on its own; only a nameless author shows the id. + if (hasProfile) return null + return ( + + + + + + ) + } + + return ( + <> + {usernameState === undefined || (!hasProfile && !profileLoaded) ? ( + + ) : ( + + + {hasProfile ? displayName : 'Unknown User'} + + + )} + {author.verified && ( + + + + )} + {handle()} + · + + ) +} diff --git a/components/post/post-card.tsx b/components/post/post-card.tsx index 5dd91137..ac9ec855 100644 --- a/components/post/post-card.tsx +++ b/components/post/post-card.tsx @@ -1,140 +1,51 @@ 'use client' -import { logger } from '@/lib/logger'; -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useMemo, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { motion } from 'framer-motion' -import { - ChatBubbleOvalLeftIcon, - ArrowPathIcon, - HeartIcon, - ArrowUpTrayIcon, - BookmarkIcon, - EllipsisHorizontalIcon, - CurrencyDollarIcon, - PencilSquareIcon, - LockClosedIcon, - TrashIcon, -} from '@heroicons/react/24/outline' -import { HeartIcon as HeartIconSolid, BookmarkIcon as BookmarkIconSolid } from '@heroicons/react/24/solid' -import { Post } from '@/lib/types' -import { formatNumber } from '@/lib/utils' -import { useRelativeTime } from '@/hooks/use-relative-time' -import { IconButton } from '@/components/ui/icon-button' -import { cn } from '@/lib/utils' -import { useAppStore, useSettingsStore } from '@/lib/store' +import { ArrowPathIcon, ChatBubbleOvalLeftIcon, CurrencyDollarIcon, EllipsisHorizontalIcon, LockClosedIcon, TrashIcon } from '@heroicons/react/24/outline' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import * as Tooltip from '@radix-ui/react-tooltip' import toast from 'react-hot-toast' +import type { Post } from '@/lib/types' +import { cn } from '@/lib/utils' +import { useAppStore, useSettingsStore } from '@/lib/store' import { useAuth } from '@/contexts/auth-context' import { useRequireAuth } from '@/hooks/use-require-auth' -import { UserAvatar } from '@/components/ui/avatar-image' -import { LikesModal } from './likes-modal' -import { PostContent } from './post-content' -import { PrivatePostContent, isPrivatePost } from './private-post-content' -import { SensitiveContentGate } from './sensitive-content-gate' -import { shouldGateSensitive } from '@/lib/sensitive-content' -import { EmbeddedPostCard, EmbeddedPostSkeleton, EmbeddedPostUnavailable } from './embedded-post-card' -import { EmbeddedBlogPostCard, isEmbeddedBlogPostLike } from '@/components/blog/embedded-blog-post-card' -import { PollCard } from '@/components/poll/poll-card' -import { findPollrPollLink, getEmbeddedPollId, stripPollrPollLink } from '@/lib/poll-embed' -import { ProfileHoverCard } from '@/components/profile/profile-hover-card' +import { useRelativeTime } from '@/hooks/use-relative-time' +import { useCopy } from '@/hooks/use-copy' import { useTipModal } from '@/hooks/use-tip-modal' -import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal' -import { categorizeError, isFrozenBalanceError } from '@/lib/error-utils' import { useBlock } from '@/hooks/use-block' import { useFollow } from '@/hooks/use-follow' import { useMediaGate } from '@/hooks/use-media-gate' import { useQuotedPost } from '@/hooks/use-quoted-post' -import { GatedPostMedia } from './gated-media' import { usePostFieldValidation } from '@/hooks/use-post-field-validation' import { useRecoveryModal } from '@/hooks/use-recovery-modal' import { useDeleteConfirmationModal } from '@/hooks/use-delete-confirmation-modal' -import { tipService } from '@/lib/services/tip-service' import { useCanReplyToPrivate } from '@/hooks/use-can-reply-to-private' -import { canBookmark, canRepost, deletesAreTombstones, targetKindOf } from '@/lib/contract-topology' -import { isUnconfirmed, settleUnconfirmed } from '@/lib/unconfirmed-writes' - -// Username loading state: undefined = loading, null = no DPNS, string = username -type UsernameState = string | null | undefined - -/** - * Resolves username display state from progressive enrichment and post data. - * Priority: progressive enrichment > post.author.hasDpns flag - */ -function resolveUsernameState( - progressiveUsername: UsernameState, - postAuthor: Post['author'] -): UsernameState { - // Progressive enrichment takes priority when defined - if (progressiveUsername !== undefined) { - return progressiveUsername - } - - // Fall back to hasDpns flag on author - if (postAuthor.hasDpns === undefined) { - return undefined // Still loading - } - - if (postAuthor.hasDpns) { - return postAuthor.username // Has DPNS - } - - return null // No DPNS -} - -/** - * Checks if a display name represents a real profile (not a placeholder). - */ -function hasRealProfile(displayName: string | undefined, identityId: string): boolean { - if (!displayName) return false - if (displayName === 'Unknown User') return false - if (displayName === `User ${identityId.slice(-6)}` || displayName === `User ${identityId.slice(-8)}`) return false - return true -} - -/** - * How to address the author of a reply's parent in the "Replying to" label: - * their DPNS handle when they have one, their profile name when they don't, and - * a truncated identity when they have neither. - */ -function parentHandleOf(parent: Post): string { - const { username, displayName, id } = parent.author - if (username && !username.startsWith('user_')) return `@${username}` - return hasRealProfile(displayName, id) ? displayName : `${id.slice(0, 8)}...` -} - -/** - * Reusable tooltip wrapper for action buttons. - * Reduces boilerplate for the repetitive Tooltip.Root/Trigger/Portal/Content pattern. - */ -interface ActionTooltipProps { - label: string - children: React.ReactNode -} - -function ActionTooltip({ label, children }: ActionTooltipProps): React.ReactElement { - return ( - - - {children} - - - - {label} - - - - ) -} +import { usePostEngagement } from '@/hooks/use-post-engagement' +import { tipService } from '@/lib/services/tip-service' +import { shouldGateSensitive } from '@/lib/sensitive-content' +import { findPollrPollLink, getEmbeddedPollId, stripPollrPollLink } from '@/lib/poll-embed' +import { deletesAreTombstones, targetKindOf } from '@/lib/contract-topology' +import { stopPropagation } from '@/lib/utils/events' +import { IconButton } from '@/components/ui/icon-button' +import { UserAvatar } from '@/components/ui/avatar-image' +import { TooltipBadge } from '@/components/ui/tooltip-button' +import { ProfileHoverCard } from '@/components/profile/profile-hover-card' +import { EmbeddedBlogPostCard, isEmbeddedBlogPostLike } from '@/components/blog/embedded-blog-post-card' +import { PollCard } from '@/components/poll/poll-card' +import { LikesModal } from './likes-modal' +import { PostContent } from './post-content' +import { PrivatePostContent, isPrivatePost } from './private-post-content' +import { SensitiveContentGate } from './sensitive-content-gate' +import { EmbeddedPostCard, EmbeddedPostSkeleton, EmbeddedPostUnavailable } from './embedded-post-card' +import { GatedPostMedia } from './gated-media' +import { PostActionBar, stopAndRun } from './post-action-bar' +import { PostAuthorLine, hasRealProfile, resolveUsernameState, type UsernameState } from './post-author-line' -// Enrichment data from progressive loading +/** What progressive loading has resolved so far for a card. */ export interface ProgressiveEnrichment { - username: string | null | undefined // undefined = loading, null = no DPNS, string = username + username: UsernameState displayName: string | undefined /** True once the profile lookup completed, even when no profile exists. Omitted = already resolved. */ profileLoaded?: boolean @@ -148,417 +59,177 @@ export interface ProgressiveEnrichment { interface PostCardProps { post: Post + /** Hide the avatar and author line, and make the like button show who liked instead. */ hideAvatar?: boolean isOwnPost?: boolean - /** Progressive enrichment data - use this when available for faster rendering */ + /** Progressive enrichment data; preferred over `post` fields when present. */ enrichment?: ProgressiveEnrichment - /** For replies to private posts, the root post owner ID to check access against */ + /** For replies, the owner of the thread's root post; private-reply access is checked against them. */ rootPostOwnerId?: string - /** - * When this card is a reply shown outside its thread (the profile Replies - * tab), the document it answers — embedded under the reply so the card reads - * on its own. - */ + /** The post a reply answers, for cards rendered outside their thread. */ parentPost?: Post /** True while `parentPost` is still being fetched, so the embed slot is held with a skeleton. */ parentPostLoading?: boolean - /** Callback when post is successfully deleted - parent component should remove post from list */ + /** Called after a successful delete so a list can drop the card. */ onDelete?: (postId: string) => void } -export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, enrichment: progressiveEnrichment, rootPostOwnerId, parentPost, parentPostLoading = false, onDelete }: PostCardProps) { +/** + * How to address the author of a reply's parent: their DPNS handle when they + * have one, their profile name otherwise, and a truncated identity for neither. + */ +function parentHandleOf(parent: Post): string { + const { username, displayName, id } = parent.author + if (username && !username.startsWith('user_')) return `@${username}` + return hasRealProfile(displayName, id) ? displayName : `${id.slice(0, 8)}...` +} + +const CARD_MENU_ITEM = 'px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none' + +export function PostCard({ + post, + hideAvatar = false, + isOwnPost: isOwnPostProp, + enrichment: progressiveEnrichment, + rootPostOwnerId, + parentPost, + parentPostLoading = false, + onDelete, +}: PostCardProps) { const router = useRouter() const { user } = useAuth() const { requireAuth } = useRequireAuth() + const copy = useCopy() + const viewerId = user?.identityId + const isOwnPost = isOwnPostProp ?? viewerId === post.author.id - // Compute isOwnPost from auth context if not explicitly provided - const isOwnPost = isOwnPostProp ?? (user?.identityId === post.author.id) - - // Which document type this card is actually showing. Everything that reads or - // writes an engagement has to dispatch on it, because the v3 topology gives - // posts and replies different interaction doctypes (and forbids reposting or - // bookmarking a reply at all). + // Which document type this card shows. Every engagement dispatches on it: the + // v3 topology gives posts and replies different interaction doctypes, and + // forbids reposting or bookmarking a reply at all. const targetKind = targetKindOf(post) const isReply = targetKind === 'reply' - const repostable = canRepost(targetKind) - const bookmarkable = canBookmark(targetKind) - // On v3 posts and replies are permanent, so "delete" blanks the document and - // flags it instead of removing it. + // On v3 posts are permanent: "delete" blanks the document and flags it. const tombstones = deletesAreTombstones() - // Set after this card's own document was tombstoned this session, so the - // deleted state renders in place even when no parent list removes the card. const [locallyTombstoned, setLocallyTombstoned] = useState(false) - // Single source of truth for "this card's document is a tombstone" — the - // deleted paragraph must beat EVERY content branch (tip text, poll, quote, - // media), not just the plain-content one, or a freshly tombstoned card keeps - // exposing its former attachments until fresh Platform data arrives. + // Beats EVERY content branch (tip, poll, quote, media), or a freshly + // tombstoned card keeps exposing its former attachments until Platform data arrives. const isTombstoned = Boolean(post.deleted) || locallyTombstoned - // Author-flagged sensitive content gets an opaque gate over the whole - // content region. Like the tombstone flag above, it must beat EVERY content - // branch (tip text, poll, quote, media, link previews), so the gate wraps - // the full region rather than any single branch. 'hide' filtering is the - // list's job — on detail/thread surfaces 'hide' behaves like 'blur'. + // Author-flagged sensitive content gets an opaque gate over the whole content + // region for the same reason. 'hide' filtering is a list's job; here it blurs. const sensitiveContentMode = useSettingsStore((s) => s.sensitiveContentMode) const gateSensitive = !isTombstoned && shouldGateSensitive(post, sensitiveContentMode) - // Use progressive enrichment data when available, fall back to post._enrichment (old path) const legacyEnrichment = post._enrichment - - // Resolve display values: progressive enrichment > post data > placeholder const displayName = progressiveEnrichment?.displayName ?? post.author.displayName const avatarUrl = progressiveEnrichment?.avatarUrl ?? legacyEnrichment?.authorAvatarUrl ?? post.author.avatar - - // Resolve username state using helper (replaces nested ternary) - const usernameState = resolveUsernameState( - progressiveEnrichment?.username, - post.author - ) - - // Check if user has a real profile (not a placeholder) - const hasProfile = hasRealProfile(displayName, post.author.id) - - // Whether the profile lookup has finished. Callers that don't pass - // progressive enrichment resolve authors before render, so treat as loaded. + const usernameState = resolveUsernameState(progressiveEnrichment?.username, post.author) + // Callers that pass no progressive enrichment resolved the author before render. const profileLoaded = progressiveEnrichment?.profileLoaded ?? true - // Stats: use progressive enrichment > post data - const statsLikes = progressiveEnrichment?.stats?.likes ?? post.likes - const statsReposts = progressiveEnrichment?.stats?.reposts ?? post.reposts - const statsReplies = progressiveEnrichment?.stats?.replies ?? post.replies - const statsQuotes = progressiveEnrichment?.stats?.quotes ?? post.quotes - - // Interactions: use progressive enrichment > post data - const initialLiked = progressiveEnrichment?.interactions?.liked ?? post.liked ?? false - const initialReposted = progressiveEnrichment?.interactions?.reposted ?? post.reposted ?? false - const initialBookmarked = progressiveEnrichment?.interactions?.bookmarked ?? post.bookmarked ?? false - - - // Memoize enriched post for use in compose/tip modals and caching - // Includes all resolved values so cached posts display correctly - const enrichedPost = useMemo(() => ({ - ...post, - author: { - ...post.author, - username: usernameState || post.author.username, - displayName: displayName || post.author.displayName, - avatar: avatarUrl || post.author.avatar, - // Set hasDpns based on resolved username state to prevent loading skeletons - // undefined = still loading, true = has DPNS, false = no DPNS - hasDpns: usernameState !== undefined ? (usernameState !== null) : post.author.hasDpns - } - }), [post, usernameState, displayName, avatarUrl]) - - // Render username/identity display based on state - const renderUsernameOrIdentity = useCallback(() => { - // Has DPNS username - if (usernameState) { - return ( - - e.stopPropagation()} - className="text-gray-500 hover:underline truncate" - > - @{usernameState} - - - ) - } - - // Still loading - if (usernameState === undefined) { - return - } - - // No DPNS and no profile - show identity ID with copy tooltip - if (!hasProfile) { - return ( - - - - - - - - - - Click to copy full identity ID - - - - - - - ) - } - - // Has profile but no DPNS - display name is sufficient - return null - }, [usernameState, hasProfile, post.author.id, displayName, avatarUrl]) + const stats = { + likes: progressiveEnrichment?.stats?.likes ?? post.likes, + reposts: progressiveEnrichment?.stats?.reposts ?? post.reposts, + replies: progressiveEnrichment?.stats?.replies ?? post.replies, + quotes: progressiveEnrichment?.stats?.quotes ?? post.quotes, + views: progressiveEnrichment?.stats?.views ?? post.views, + } + const engagement = usePostEngagement( + post, + viewerId, + { + liked: progressiveEnrichment?.interactions?.liked ?? post.liked ?? false, + likes: stats.likes, + reposted: progressiveEnrichment?.interactions?.reposted ?? post.reposted ?? false, + reposts: stats.reposts, + bookmarked: progressiveEnrichment?.interactions?.bookmarked ?? post.bookmarked ?? false, + }, + targetKind + ) + const { repostable, bookmarkable } = engagement + // The repost control shows reposts plus quote-posts; where the topology + // forbids reposting this kind there is no repost doctype to have counted. + const totalReposts = (repostable ? engagement.reposts : 0) + stats.quotes + + // The resolved author travels with the post into compose, tip and navigation + // so cached copies render without loading skeletons. + const enrichedPost = useMemo( + () => ({ + ...post, + author: { + ...post.author, + username: usernameState || post.author.username, + displayName: displayName || post.author.displayName, + avatar: avatarUrl || post.author.avatar, + hasDpns: usernameState !== undefined ? usernameState !== null : post.author.hasDpns, + }, + }), + [post, usernameState, displayName, avatarUrl] + ) - const [liked, setLiked] = useState(initialLiked) - const [likes, setLikes] = useState(statsLikes) - const [reposted, setReposted] = useState(initialReposted) - const [reposts, setReposts] = useState(statsReposts) - const [bookmarked, setBookmarked] = useState(initialBookmarked) - // The repost control shows reposts + quote-posts combined — or, where the - // topology forbids reposting this kind, just the quotes (there is no repost - // doctype to have counted). - const totalReposts = (repostable ? reposts : 0) + statsQuotes const [showLikesModal, setShowLikesModal] = useState(false) - const [likeLoading, setLikeLoading] = useState(false) - const [repostLoading, setRepostLoading] = useState(false) - const [bookmarkLoading, setBookmarkLoading] = useState(false) const { setReplyingTo, setComposeOpen, setQuotingPost } = useAppStore() const { open: openTipModal } = useTipModal() const { open: openRecoveryModal } = useRecoveryModal() const { open: openDeleteModal } = useDeleteConfirmationModal() - // Whether each hashtag/mention index document actually landed on Platform. const { validations: hashtagValidations } = usePostFieldValidation('hashtag', post) const { validations: mentionValidations } = usePostFieldValidation('mention', post) - - // Use pre-fetched enrichment data to avoid N+1 queries const { isBlocked, isLoading: blockLoading, toggleBlock } = useBlock(post.author.id, { - initialValue: progressiveEnrichment?.isBlocked ?? legacyEnrichment?.authorIsBlocked + initialValue: progressiveEnrichment?.isBlocked ?? legacyEnrichment?.authorIsBlocked, }) const { isFollowing, isLoading: followLoading, toggleFollow } = useFollow(post.author.id, { - initialValue: progressiveEnrichment?.isFollowing ?? legacyEnrichment?.authorIsFollowing + initialValue: progressiveEnrichment?.isFollowing ?? legacyEnrichment?.authorIsFollowing, }) - - // Live follow state, preferred over the enrichment snapshot as soon as - // useFollow settles: the snapshot was taken when the feed was built and goes - // stale the moment the viewer unfollows, and a stale `true` would leave that - // author's media ungated. While the hook is still resolving its state - // defaults to false, so the snapshot covers that window instead (undefined - // when there is none, which gates until the answer arrives). - const authorIsFollowing = followLoading - ? progressiveEnrichment?.isFollowing ?? legacyEnrichment?.authorIsFollowing - : isFollowing - - // Follow-gates this card's media/previews. + // Live follow state beats the enrichment snapshot as soon as useFollow + // settles: the snapshot goes stale the moment the viewer unfollows, and a + // stale true would leave this author's media ungated. While the hook is + // resolving, the snapshot covers that window (undefined gates until then). + const authorIsFollowing = followLoading ? progressiveEnrichment?.isFollowing ?? legacyEnrichment?.authorIsFollowing : isFollowing const mediaGate = useMediaGate(post.author.id, authorIsFollowing) - - // Quote embed: batch-resolved when the loader attached one, otherwise - // fetched here so a missed batch pass can't strand the card on a skeleton. + // Batch-resolved when the loader attached one, otherwise fetched here. const { quotedPost, loading: quotedPostLoading, unavailable: quotedPostUnavailable } = useQuotedPost(post) - - // Check if user can reply to private posts (PRD §5.5) - // For replies, check access against root post owner, not the reply author + // For replies, access is checked against the root post owner, not the reply author. const { canReply: canReplyToPrivate, reason: cantReplyReason } = useCanReplyToPrivate(post, rootPostOwnerId) - // Sync local state with prop changes (reuses computed initial values) - useEffect(() => { - setLiked(initialLiked) - setLikes(statsLikes) - setReposted(initialReposted) - setReposts(statsReposts) - setBookmarked(initialBookmarked) - }, [initialLiked, statsLikes, initialReposted, statsReposts, initialBookmarked]) - - // Check if this post is a tip and parse tip info const tipInfo = useMemo(() => tipService.parseTipContent(post.content), [post.content]) - const isTipPost = !!tipInfo const createdAtLabel = useRelativeTime(post.createdAt, { compact: true }) - // Guarded like every other timestamp consumer — toISOString() throws on an invalid Date. + // toISOString() throws on an invalid Date. const createdAtDate = new Date(post.createdAt) const createdAtValid = Number.isFinite(createdAtDate.getTime()) - // Native poll embed, or a legacy post that only links to the Pollr web app. + // A native poll embed, or a legacy post that only links to the Pollr web app. + // On a native poll post a Pollr URL in the body points at some other poll. const nativePollId = getEmbeddedPollId(post) - // Only look for a legacy link when there is no native embed: on a native poll - // post a Pollr URL in the body points at some *other* poll, and stripping it - // would drop a link nothing else renders. - const pollLink = useMemo( - () => (nativePollId ? null : findPollrPollLink(post.content)), - [nativePollId, post.content] - ) + const pollLink = useMemo(() => (nativePollId ? null : findPollrPollLink(post.content)), [nativePollId, post.content]) const embeddedPollId = nativePollId ?? pollLink?.pollId ?? null - // Legacy poll links are rendered as the poll itself, so drop the raw URL. - const displayContent = useMemo( - () => (pollLink ? stripPollrPollLink(post.content, pollLink.url) : post.content), - [post.content, pollLink] - ) + // A legacy poll link is rendered as the poll itself, so drop the raw URL. + const displayContent = useMemo(() => (pollLink ? stripPollrPollLink(post.content, pollLink.url) : post.content), [post.content, pollLink]) - const handleLike = async () => { + const handleLike = () => { + // On "Your Posts" the like button shows who liked instead. if (hideAvatar) { - // On "Your Posts" tab, show who liked instead of liking setShowLikesModal(true) return } - - const authedUser = requireAuth() - if (!authedUser) return - - if (likeLoading) return - - const wasLiked = liked - const prevLikes = likes - - // Optimistic update - setLiked(!wasLiked) - setLikes(wasLiked ? prevLikes - 1 : prevLikes + 1) - setLikeLoading(true) - - try { - // A like references its target, and on v3 that reference is checked by - // consensus — so liking a card this session just created but never saw - // confirmed would be rejected with the YAPP spent. Only reachable on the - // DAPI-timeout path; a no-op otherwise. - if (isUnconfirmed(post.id) && !(await settleUnconfirmed(post.id))) { - throw new Error('This post has not confirmed yet. Try again in a moment.') - } - - const { likeService } = await import('@/lib/services/like-service') - // On v4 the like repeats the target's author and (for posts) hashtag - // under a consensus-checked agreement; forwarding them off the card's own - // post object saves the service a fetch. Ignored on v2/v3. - const targetInfo = { author: post.author.id, hashtag: post.hashtag } - const success = wasLiked - ? await likeService.unlikePost(post.id, authedUser.identityId, targetKind, targetInfo) - : await likeService.likePost(post.id, authedUser.identityId, post.author.id, targetKind, targetInfo) - - if (!success) throw new Error('Like operation failed') - } catch (error) { - // Rollback on error - setLiked(wasLiked) - setLikes(prevLikes) - logger.error('Like error:', error) - // Frozen accounts can't spend YAPP at all, so explain the suspension - // instead of prompting a purchase that wouldn't help. - if (isFrozenBalanceError(error)) { - toast.error(categorizeError(error)) - } else if (!handleInsufficientYapp(error, 'You need YAPP to like posts. Buy some to continue.')) { - toast.error('Failed to update like. Please try again.') - } - } finally { - setLikeLoading(false) - } + if (!requireAuth()) return + return engagement.toggleLike() } - - const handleRepost = async () => { - const authedUser = requireAuth() - if (!authedUser) return - - // The topology may forbid reposting this kind entirely (v3 replies), in which - // case there is no doctype to write. The control is still rendered today, so - // this guard — not the action row — is what enforces the rule. - if (!repostable) return - - if (repostLoading) return - - const wasReposted = reposted - const prevReposts = reposts - - // Optimistic update - setReposted(!wasReposted) - setReposts(wasReposted ? prevReposts - 1 : prevReposts + 1) - setRepostLoading(true) - - try { - // Same consensus-reference rule as likes: on v3 a repost names its target - // through a checked permanentDocument reference, so creating one against a - // not-yet-confirmed post would be rejected with the YAPP already spent. - // Removal needs no gate — the repost document itself already exists. - if (!wasReposted && isUnconfirmed(post.id) && !(await settleUnconfirmed(post.id))) { - throw new Error('This post has not confirmed yet. Try again in a moment.') - } - - const { repostService } = await import('@/lib/services/repost-service') - const success = wasReposted - ? await repostService.removeRepost(post.id, authedUser.identityId) - : await repostService.repostPost(post.id, authedUser.identityId, post.author.id) - - if (!success) throw new Error('Repost operation failed') - toast.success(wasReposted ? 'Removed repost' : 'Reposted!') - } catch (error) { - // Rollback on error - setReposted(wasReposted) - setReposts(prevReposts) - logger.error('Repost error:', error) - // Frozen accounts can't spend YAPP at all, so explain the suspension - // instead of prompting a purchase that wouldn't help. - if (isFrozenBalanceError(error)) { - toast.error(categorizeError(error)) - } else if (!handleInsufficientYapp(error, 'You need YAPP to repost. Buy some to continue.')) { - toast.error('Failed to update repost. Please try again.') - } - } finally { - setRepostLoading(false) - } + const handleRepost = () => { + if (!requireAuth()) return + return engagement.toggleRepost() + } + const handleBookmark = () => { + if (!requireAuth()) return + return engagement.toggleBookmark() } - const handleQuote = () => { if (!requireAuth()) return setQuotingPost(enrichedPost) setComposeOpen(true) } - - const handleBookmark = async () => { - const authedUser = requireAuth() - if (!authedUser) return - - // Same guard as handleRepost: on v3 replies have no bookmark doctype. - if (!bookmarkable) return - - if (bookmarkLoading) return - - const wasBookmarked = bookmarked - - // Optimistic update - setBookmarked(!wasBookmarked) - setBookmarkLoading(true) - - try { - // Same unconfirmed-target gate as likes/reposts (bookmark.postId is a - // checked reference on v3); removal is ungated. - if (!wasBookmarked && isUnconfirmed(post.id) && !(await settleUnconfirmed(post.id))) { - throw new Error('This post has not confirmed yet. Try again in a moment.') - } - - const { bookmarkService } = await import('@/lib/services/bookmark-service') - const success = wasBookmarked - ? await bookmarkService.removeBookmark(post.id, authedUser.identityId) - : await bookmarkService.bookmarkPost(post.id, authedUser.identityId) - - if (!success) throw new Error('Bookmark operation failed') - toast.success(wasBookmarked ? 'Removed from bookmarks' : 'Added to bookmarks') - } catch (error) { - // Rollback on error - setBookmarked(wasBookmarked) - logger.error('Bookmark error:', error) - toast.error('Failed to update bookmark. Please try again.') - } finally { - setBookmarkLoading(false) - } - } - const handleReply = () => { if (!requireAuth()) return - // Check if user can reply to private posts (PRD §5.5) if (!canReplyToPrivate) { toast.error(cantReplyReason || "Can't reply to this post") return @@ -566,132 +237,69 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e setReplyingTo(enrichedPost) setComposeOpen(true) } - - const handleShare = () => { - const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' - navigator.clipboard.writeText(`${baseUrl}/post?id=${post.id}`).catch((error) => logger.error(error)) - toast.success('Link copied to clipboard') - } - + const handleShare = () => copy(`${window.location.origin}/post?id=${post.id}`, 'Link copied to clipboard') const handleTip = () => { if (!requireAuth()) return openTipModal(enrichedPost) } - const handleFailedHashtagClick = (hashtag: string) => openRecoveryModal('hashtag', post, hashtag) - const handleFailedMentionClick = (username: string) => openRecoveryModal('mention', post, username) - const handleDelete = () => { const authedUser = requireAuth() if (!authedUser) return - openDeleteModal(post, async () => { - let success: boolean - + let ok: boolean if (isReply) { - // Use replyService for replies (document type 'reply') const { replyService } = await import('@/lib/services/reply-service') - success = tombstones - ? await replyService.tombstoneReply(post.id, authedUser.identityId) - : await replyService.deleteReply(post.id, authedUser.identityId) + ok = tombstones ? await replyService.tombstoneReply(post.id, authedUser.identityId) : await replyService.deleteReply(post.id, authedUser.identityId) } else { - // Use postService for posts (document type 'post') const { postService } = await import('@/lib/services/post-service') - success = tombstones - ? await postService.tombstonePost(post.id, authedUser.identityId) - : await postService.deletePost(post.id, authedUser.identityId) + ok = tombstones ? await postService.tombstonePost(post.id, authedUser.identityId) : await postService.deletePost(post.id, authedUser.identityId) } - - if (!success) throw new Error('Delete operation failed') - + if (!ok) throw new Error('Delete operation failed') toast.success(isReply ? 'Reply deleted' : 'Post deleted') - // On v3 the document still exists as a tombstone. Flip the card into its - // tombstone rendering immediately — detail and thread callers pass no - // onDelete, so without this the pre-delete content would stay on screen - // (and the service cache could re-serve it) until a full reload. - if (tombstones) { - setLocallyTombstoned(true) - } - // Notify parent to remove post from list if callback provided - if (onDelete) { - onDelete(post.id) - } + // Detail and thread callers pass no onDelete, so the card must flip its + // own rendering, or the pre-delete content would stay until a reload. + if (tombstones) setLocallyTombstoned(true) + onDelete?.(post.id) }) } const handleCardClick = (e: React.MouseEvent) => { const url = `/post?id=${post.id}` - - // Set pending navigation data for instant display on post detail page - // This is consumed immediately when the detail page mounts - no TTL needed - const { setPendingPostNavigation } = useAppStore.getState() - const resolvedEnrichment: ProgressiveEnrichment = { - // Use resolved values (what's currently displayed) instead of raw progressive state + // Hand the detail page what this card already shows, so it renders at once. + useAppStore.getState().setPendingPostNavigation(enrichedPost, { username: usernameState, - displayName: displayName, - profileLoaded: profileLoaded, - avatarUrl: avatarUrl, - // Preserve stats and interactions from progressive enrichment - stats: progressiveEnrichment?.stats ?? { - likes: statsLikes, - reposts: statsReposts, - replies: statsReplies, - quotes: statsQuotes, - views: post.views - }, - interactions: progressiveEnrichment?.interactions ?? { - liked: liked, - reposted: reposted, - bookmarked: bookmarked - }, + displayName, + profileLoaded, + avatarUrl, + stats: progressiveEnrichment?.stats ?? stats, + interactions: progressiveEnrichment?.interactions ?? { liked: engagement.liked, reposted: engagement.reposted, bookmarked: engagement.bookmarked }, isBlocked: progressiveEnrichment?.isBlocked ?? isBlocked, isFollowing: authorIsFollowing, - replyTo: progressiveEnrichment?.replyTo - } - setPendingPostNavigation(enrichedPost, resolvedEnrichment) - - // Handle Ctrl/Cmd+click to open in new tab (standard browser behavior) - if (e.ctrlKey || e.metaKey) { - window.open(url, '_blank') - } else { - router.push(url) - } + replyTo: progressiveEnrichment?.replyTo, + }) + if (e.ctrlKey || e.metaKey) window.open(url, '_blank') + else router.push(url) } + const authorLabel = usernameState ? `@${usernameState}` : displayName + return (
- {/* Reposted by header */} {post.repostedBy && ( - e.stopPropagation()} - className="flex items-center gap-2 text-sm text-gray-500 mb-2 ml-9 hover:underline" - > + - - {post.repostedBy.username - ? `@${post.repostedBy.username}` - : post.repostedBy.displayName || 'Someone'} reposted - + {post.repostedBy.username ? `@${post.repostedBy.username}` : post.repostedBy.displayName || 'Someone'} reposted )}
{!hideAvatar && ( - - e.stopPropagation()} - className="h-12 w-12 rounded-full overflow-hidden bg-white dark:bg-neutral-900 block flex-shrink-0" - > + + @@ -701,40 +309,15 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e
{!hideAvatar && ( - <> - {usernameState === undefined || (!hasProfile && !profileLoaded) ? ( - // Still loading - show skeleton for display name - - ) : ( - - e.stopPropagation()} - className="font-semibold hover:underline truncate" - > - {hasProfile ? displayName : 'Unknown User'} - - - )} - {post.author.verified && ( - - - - )} - {renderUsernameOrIdentity()} - · - + )} -
@@ -746,89 +329,58 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e )} - - e.stopPropagation()}> - - - - - - - { e.stopPropagation(); toggleFollow().catch((error) => logger.error(error)); }} - disabled={followLoading} - className="px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none disabled:opacity-50" - > - {isFollowing ? 'Unfollow' : 'Follow'} {usernameState ? `@${usernameState}` : displayName} - - { - e.stopPropagation(); - // The kind travels in the URL: the engagements page has to - // know which doctypes to read, and an id alone no longer says. - router.push(`/post/engagements?id=${post.id}&kind=${targetKind}`); - }} - className="px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none" - > - View post engagements - - {isOwnPost && ( + + + + + + + + stopAndRun(e, toggleFollow)} disabled={followLoading} className={cn(CARD_MENU_ITEM, 'disabled:opacity-50')}> + {isFollowing ? 'Unfollow' : 'Follow'} {authorLabel} + { e.stopPropagation(); handleDelete(); }} - className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none text-red-500" + onClick={(e) => { + e.stopPropagation() + // The engagements page needs the kind to know which doctypes to read. + router.push(`/post/engagements?id=${post.id}&kind=${targetKind}`) + }} + className={CARD_MENU_ITEM} > - - Delete {isReply ? 'reply' : 'post'} + View post engagements - )} - { e.stopPropagation(); toggleBlock().catch((error) => logger.error(error)); }} - disabled={blockLoading} - className="px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-900 cursor-pointer outline-none text-red-500 disabled:opacity-50" - > - {isBlocked ? 'Unblock' : 'Block'} {usernameState ? `@${usernameState}` : displayName} - - - - + {isOwnPost && ( + { + e.stopPropagation() + handleDelete() + }} + className={cn(CARD_MENU_ITEM, 'flex items-center gap-2 text-red-500')} + > + + Delete {isReply ? 'reply' : 'post'} + + )} + stopAndRun(e, toggleBlock)} disabled={blockLoading} className={cn(CARD_MENU_ITEM, 'text-red-500 disabled:opacity-50')}> + {isBlocked ? 'Unblock' : 'Block'} {authorLabel} + + + +
- {/* Tip post - show tip badge with recipient and message */} - {/* TODO: Remove tooltip once SDK exposes transition IDs for on-chain verification */} {isTombstoned ? ( -

- {isReply ? 'This reply was deleted.' : 'This post was deleted.'} -

- ) : isTipPost ? ( +

{isReply ? 'This reply was deleted.' : 'This post was deleted.'}

+ ) : tipInfo ? (
- - - -
- - - Sent a tip of {tipService.formatDash(tipService.creditsToDash(tipInfo.amount))} - -
-
- - - Unverified - awaiting SDK support - - -
-
- {tipInfo.message && ( - - )} + {/* TODO: drop the tooltip once the SDK exposes transition ids for on-chain verification. */} + + + Sent a tip of {tipService.formatDash(tipService.creditsToDash(tipInfo.amount))} + + {tipInfo.message && }
) : isPrivatePost(post) ? ( openRecoveryModal('hashtag', post, hashtag)} mentionValidations={mentionValidations} - onFailedMentionClick={handleFailedMentionClick} + onFailedMentionClick={(username) => openRecoveryModal('mention', post, username)} mediaGate={mediaGate} /> ) : displayContent ? ( @@ -846,259 +398,57 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e content={displayContent} className="mt-1" hashtagValidations={hashtagValidations} - onFailedHashtagClick={handleFailedHashtagClick} + onFailedHashtagClick={(hashtag) => openRecoveryModal('hashtag', post, hashtag)} mentionValidations={mentionValidations} - onFailedMentionClick={handleFailedMentionClick} + onFailedMentionClick={(username) => openRecoveryModal('mention', post, username)} mediaGate={mediaGate} /> ) : null} - {/* Native poll (Pollr contract) — suppressed on tombstones */} - {!isTombstoned && embeddedPollId && !isPrivatePost(post) && ( - - )} - - {/* Quoted post - skeleton while resolving, an explicit - "unavailable" state when resolution comes back empty. */} - {!isTombstoned && quotedPostLoading && ( - - )} + {!isTombstoned && embeddedPollId && !isPrivatePost(post) && } - {!isTombstoned && quotedPostUnavailable && ( - - )} - - {!isTombstoned && quotedPost && ( - isEmbeddedBlogPostLike(quotedPost) - ? - : - )} + {!isTombstoned && quotedPostLoading && } + {!isTombstoned && quotedPostUnavailable && } + {!isTombstoned && quotedPost && (isEmbeddedBlogPostLike(quotedPost) ? : )} {!isTombstoned && post.media && post.media.length > 0 && ( -
= 4 && 'grid-cols-2' - )}> +
{post.media.map((media, index) => ( -
+
))}
)} - {/* Reply context: what this reply answers, for cards rendered outside - their thread. Last, so the reply's own content and media stay - together, and labelled so the embed doesn't read as a quote. */} + {/* Last, so the reply's own content and media stay together, and + labelled so the embed does not read as a quote. */} {!isTombstoned && (parentPost || parentPostLoading) && (
- - Replying to{parentPost ? ` ${parentHandleOf(parentPost)}` : ''} - + Replying to{parentPost ? ` ${parentHandleOf(parentPost)}` : ''} - {parentPost - ? - : } + {parentPost ? : }
)} -
- - - - - - - - - - - e.stopPropagation()} - > - {/* Reposting a reply has no doctype to write on v3 — - consensus rejects a reply id on `repost.postId` — so the - item is absent rather than failing when clicked. */} - {repostable && ( - { e.stopPropagation(); handleRepost().catch((error) => logger.error(error)); }} - className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer outline-none" - > - - {reposted ? 'Undo Repost' : 'Repost'} - - )} - { e.stopPropagation(); handleQuote(); }} - className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer outline-none" - > - - Quote - - - - - - - - - - {/* Tip button - disabled for own posts */} - - - - -
- {/* Same rule as Repost: `bookmark.postId` only accepts posts on - v3, so replies have no bookmark control at all. */} - {bookmarkable && ( - - - - )} - - - - -
-
-
+
- - setShowLikesModal(false)} - postId={post.id} - /> + + setShowLikesModal(false)} postId={post.id} />
) } diff --git a/hooks/use-post-engagement.ts b/hooks/use-post-engagement.ts new file mode 100644 index 00000000..a711a4bb --- /dev/null +++ b/hooks/use-post-engagement.ts @@ -0,0 +1,148 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { logger } from '@/lib/logger' +import type { Post } from '@/lib/types' +import { canBookmark, canRepost, type TargetKind } from '@/lib/contract-topology' +import { categorizeError, isFrozenBalanceError } from '@/lib/error-utils' +import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal' +import { isUnconfirmed, settleUnconfirmed } from '@/lib/unconfirmed-writes' + +export interface EngagementSnapshot { + liked: boolean + likes: number + reposted: boolean + reposts: number + bookmarked: boolean +} + +/** Frozen accounts cannot spend at all, so say that instead of offering YAPP. */ +function reportSpendError(error: unknown, buyReason: string, fallback: string) { + if (isFrozenBalanceError(error)) toast.error(categorizeError(error)) + else if (!handleInsufficientYapp(error, buyReason)) toast.error(fallback) +} + +/** + * A card's like, repost and bookmark state with optimistic flips that roll + * back on failure. Each write names the post, and on v3 that reference is + * consensus-checked, so a post this session created but never saw confirmed + * is settled first; the check is a no-op off the DAPI-timeout path. + */ +export function usePostEngagement(post: Post, viewerId: string | undefined, initial: EngagementSnapshot, targetKind: TargetKind) { + // The v3 topology forbids reposting or bookmarking a reply at all. + const repostable = canRepost(targetKind) + const bookmarkable = canBookmark(targetKind) + const [liked, setLiked] = useState(initial.liked) + const [likes, setLikes] = useState(initial.likes) + const [reposted, setReposted] = useState(initial.reposted) + const [reposts, setReposts] = useState(initial.reposts) + const [bookmarked, setBookmarked] = useState(initial.bookmarked) + const [likeLoading, setLikeLoading] = useState(false) + const [repostLoading, setRepostLoading] = useState(false) + const [bookmarkLoading, setBookmarkLoading] = useState(false) + + // Follow the enrichment snapshot as it fills in. + useEffect(() => { + setLiked(initial.liked) + setLikes(initial.likes) + setReposted(initial.reposted) + setReposts(initial.reposts) + setBookmarked(initial.bookmarked) + }, [initial.liked, initial.likes, initial.reposted, initial.reposts, initial.bookmarked]) + + const settle = useCallback(async () => { + if (isUnconfirmed(post.id) && !(await settleUnconfirmed(post.id))) { + throw new Error('This post has not confirmed yet. Try again in a moment.') + } + }, [post.id]) + + const toggleLike = useCallback(async () => { + if (!viewerId || likeLoading) return + const wasLiked = liked + const prevLikes = likes + setLiked(!wasLiked) + setLikes(wasLiked ? prevLikes - 1 : prevLikes + 1) + setLikeLoading(true) + try { + await settle() + const { likeService } = await import('@/lib/services/like-service') + // On v4 the like repeats the target's author and hashtag under a + // consensus-checked agreement; passing them saves the service a fetch. + const targetInfo = { author: post.author.id, hashtag: post.hashtag } + const ok = wasLiked + ? await likeService.unlikePost(post.id, viewerId, targetKind, targetInfo) + : await likeService.likePost(post.id, viewerId, post.author.id, targetKind, targetInfo) + if (!ok) throw new Error('Like operation failed') + } catch (error) { + setLiked(wasLiked) + setLikes(prevLikes) + logger.error('Like error:', error) + reportSpendError(error, 'You need YAPP to like posts. Buy some to continue.', 'Failed to update like. Please try again.') + } finally { + setLikeLoading(false) + } + }, [viewerId, likeLoading, liked, likes, settle, post.id, post.author.id, post.hashtag, targetKind]) + + const toggleRepost = useCallback(async () => { + // The topology may forbid reposting this kind (v3 replies); this guard, not + // the action row, enforces it. + if (!viewerId || !repostable || repostLoading) return + const wasReposted = reposted + const prevReposts = reposts + setReposted(!wasReposted) + setReposts(wasReposted ? prevReposts - 1 : prevReposts + 1) + setRepostLoading(true) + try { + // Removal needs no gate: the repost document already exists. + if (!wasReposted) await settle() + const { repostService } = await import('@/lib/services/repost-service') + const ok = wasReposted ? await repostService.removeRepost(post.id, viewerId) : await repostService.repostPost(post.id, viewerId, post.author.id) + if (!ok) throw new Error('Repost operation failed') + toast.success(wasReposted ? 'Removed repost' : 'Reposted!') + } catch (error) { + setReposted(wasReposted) + setReposts(prevReposts) + logger.error('Repost error:', error) + reportSpendError(error, 'You need YAPP to repost. Buy some to continue.', 'Failed to update repost. Please try again.') + } finally { + setRepostLoading(false) + } + }, [viewerId, repostable, repostLoading, reposted, reposts, settle, post.id, post.author.id]) + + const toggleBookmark = useCallback(async () => { + if (!viewerId || !bookmarkable || bookmarkLoading) return + const wasBookmarked = bookmarked + setBookmarked(!wasBookmarked) + setBookmarkLoading(true) + try { + if (!wasBookmarked) await settle() + const { bookmarkService } = await import('@/lib/services/bookmark-service') + const ok = wasBookmarked ? await bookmarkService.removeBookmark(post.id, viewerId) : await bookmarkService.bookmarkPost(post.id, viewerId) + if (!ok) throw new Error('Bookmark operation failed') + toast.success(wasBookmarked ? 'Removed from bookmarks' : 'Added to bookmarks') + } catch (error) { + setBookmarked(wasBookmarked) + logger.error('Bookmark error:', error) + toast.error('Failed to update bookmark. Please try again.') + } finally { + setBookmarkLoading(false) + } + }, [viewerId, bookmarkable, bookmarkLoading, bookmarked, settle, post.id]) + + return { + repostable, + bookmarkable, + liked, + likes, + reposted, + reposts, + bookmarked, + likeLoading, + repostLoading, + bookmarkLoading, + toggleLike, + toggleRepost, + toggleBookmark, + } +} diff --git a/lib/utils/events.ts b/lib/utils/events.ts new file mode 100644 index 00000000..de49f020 --- /dev/null +++ b/lib/utils/events.ts @@ -0,0 +1,6 @@ +import type { SyntheticEvent } from 'react' + +/** For controls inside a clickable card: keep the click from reaching the card. */ +export function stopPropagation(e: SyntheticEvent): void { + e.stopPropagation() +}