Files
kycnotme/web/src/components/CommentItem.astro
2025-05-19 21:31:29 +00:00

511 lines
17 KiB
Plaintext

---
import Image from 'astro/components/Image.astro'
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema'
import { actions } from 'astro:actions'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
import { cn } from '../lib/cn'
import {
makeCommentUrl,
MAX_COMMENT_DEPTH,
type CommentWithRepliesPopulated,
} from '../lib/commentsWithReplies'
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
import { formatDateShort } from '../lib/timeAgo'
import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro'
import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
comment: CommentWithRepliesPopulated
depth?: number
showPending?: boolean
highlightedCommentId: number | null
serviceSlug: string
itemReviewedId: string
}
const {
comment,
depth = 0,
showPending = false,
highlightedCommentId = null,
serviceSlug,
itemReviewedId,
class: className,
...htmlProps
} = Astro.props
const user = Astro.locals.user
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
const authorUnlocks = computeKarmaUnlocks(comment.author.totalKarma)
function checkIsHighlightParent(c: CommentWithRepliesPopulated, highlight: number | null): boolean {
if (!highlight) return false
if (c.id === highlight) return true
if (!c.replies?.length) return false
return c.replies.some((r) => checkIsHighlightParent(r, highlight))
}
const isHighlightParent = checkIsHighlightParent(comment, highlightedCommentId)
const isHighlighted = comment.id === highlightedCommentId
// Get user's current vote if any
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
const isAuthor = user?.id === comment.author.id
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
// Check if user is new (less than 1 week old)
const isNewUser =
new Date().getTime() - new Date(comment.author.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
const isRatingActive =
comment.rating !== null &&
!comment.parentId &&
comment.ratingActive &&
!comment.suspicious &&
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
const shouldShow =
comment.status === 'APPROVED' ||
comment.status === 'VERIFIED' ||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'PENDING') ||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'HUMAN_PENDING') ||
((isHighlightParent || isHighlighted) && comment.status === 'REJECTED') ||
isAuthorOrPrivileged
if (!shouldShow) return null
const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin: Astro.url.origin })
---
<style>
.collapse-toggle:checked + .comment-header .collapse-symbol::before {
content: '[+]';
}
.collapse-symbol::before {
content: '[-]';
}
</style>
<div
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && 'bg-[#292815]',
comment.status === 'REJECTED' && 'bg-[#2f1f1f]',
isHighlighted && 'bg-[#192633]',
comment.suspicious &&
'opacity-25 transition-opacity not-has-[[data-collapse-toggle]:checked]:opacity-100! focus-within:opacity-100 hover:opacity-100 focus:opacity-100',
className,
])}
>
{
isRatingActive && comment.rating !== null && (
<Schema
item={{
'@context': 'https://schema.org',
'@type': 'Review',
'@id': commentUrl,
author: {
'@type': 'Person',
name: comment.author.displayName ?? comment.author.name,
image: comment.author.picture ?? undefined,
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
},
datePublished: comment.createdAt.toISOString(),
reviewBody: comment.content,
reviewAspect: 'User comment',
commentCount: comment.replies?.length ?? 0,
itemReviewed: { '@id': itemReviewedId },
reviewRating: {
'@type': 'Rating',
ratingValue: comment.rating,
bestRating: 5,
worstRating: 1,
},
}}
/>
)
}
<input
type="checkbox"
id={`collapse-${comment.id.toString()}`}
data-collapse-toggle
class="collapse-toggle peer/collapse hidden"
checked={comment.suspicious}
/>
<div class="comment-header flex items-center gap-2 text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
{
comment.author.picture && (
<Image
src={comment.author.picture}
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
class="size-6 rounded-full bg-zinc-700 object-cover"
loading="lazy"
height={24}
width={24}
/>
)
}
<a
href={`/u/${comment.author.name}`}
class={cn([
'font-title text-day-300 font-medium hover:underline focus-visible:underline',
isAuthor && 'font-medium text-green-500',
])}
>
{comment.author.displayName ?? comment.author.name}
</a>
{
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
<Tooltip
text={`${
comment.author.admin || comment.author.verifier
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
>
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
</Tooltip>
)
}
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
)
}
{
comment.author.verifier && !comment.author.admin && (
<BadgeSmall
icon="ri:graduation-cap-fill"
color="teal"
text="Moderator"
variant="faded"
inlineIcon
/>
)
}
{
isNewUser && !comment.author.admin && !comment.author.verifier && (
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
</Tooltip>
)
}
{
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
<BadgeSmall
icon={karmaUnlocksById.highKarmaBadge.icon}
color="lime"
text="High Karma"
variant="faded"
inlineIcon
/>
)
}
{
authorUnlocks.negativeKarmaBadge && !authorUnlocks.untrustedBadge && (
<BadgeSmall
icon={karmaUnlocksById.negativeKarmaBadge.icon}
color="orange"
text="Negative Karma"
variant="faded"
inlineIcon
/>
)
}
{
(authorUnlocks.untrustedBadge || comment.author.spammer) && (
<BadgeSmall
icon={karmaUnlocksById.untrustedBadge.icon}
color="red"
text="Untrusted User"
variant="faded"
inlineIcon
/>
)
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
)
})
}
</div>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-1 text-xs text-zinc-400">
<span class="flex items-center gap-1">
<Icon name="ri:arrow-up-line" class="size-3" />
{comment.upvotes}
</span>
<span class="text-zinc-700">•</span>
<a href={commentUrl} class="hover:text-zinc-300">
<TimeFormatted date={comment.createdAt} hourPrecision />
</a>
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
text="Not counting for the total"
position="right"
enabled={!isRatingActive}
class={cn('flex items-center gap-1', isRatingActive ? 'text-yellow-400' : 'text-yellow-400/60')}
>
<Icon name={isRatingActive ? 'ri:star-fill' : 'ri:star-line'} class="size-3" />
{comment.rating.toLocaleString()}/5
</Tooltip>
)
}
{
comment.status === 'VERIFIED' && (
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon />
)
}
{
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
(showPending || isHighlightParent || isAuthorOrPrivileged) && (
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon />
)
}
{
comment.status === 'REJECTED' && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon />
)
}
{/* Service usage verification indicators */}
{
comment.orderId && comment.orderIdStatus === 'APPROVED' && (
<BadgeSmall icon="ri:verified-badge-fill" color="green" text="Valid order ID" inlineIcon />
)
}
{
comment.orderId && comment.orderIdStatus === 'REJECTED' && (
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Invalid order ID" inlineIcon />
)
}
{
comment.kycRequested && (
<BadgeSmall icon="ri:user-forbid-fill" color="red" text="KYC issue" inlineIcon />
)
}
{
comment.fundsBlocked && (
<BadgeSmall icon="ri:wallet-3-fill" color="orange" text="Funds blocked" inlineIcon />
)
}
</div>
<div class={cn(['comment-body mt-2 peer-checked/collapse:hidden'])}>
{
isAuthor && comment.status === 'REJECTED' && (
<div class="mb-2 inline-block rounded-xs bg-red-500/30 px-2 py-1 text-xs text-red-300">
This comment has been rejected and is only visible to you
</div>
)
}
<div class="text-sm">
{
!!comment.content && (
<div class="prose prose-invert prose-sm max-w-none overflow-auto">
<Markdown content={comment.content} />
</div>
)
}
</div>
</div>
{
comment.communityNote && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
<span class="font-medium text-zinc-400">Added context:</span>
<span class="text-zinc-300">{comment.communityNote}</span>
</div>
</div>
)
}
{
user && (user.admin || user.verifier) && comment.internalNote && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-red-400">Internal note:</span>
<span class="text-red-300">{comment.internalNote}</span>
</div>
</div>
)
}
{
user && (user.admin || user.verifier) && comment.privateContext && (
<div class="mt-2 peer-checked/collapse:hidden">
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
<span class="font-medium text-blue-400">Private context:</span>
<span class="text-blue-300">{comment.privateContext}</span>
</div>
</div>
)
}
<div class="mt-2 flex items-center gap-3 text-xs peer-checked/collapse:hidden">
<div class="flex items-center gap-1">
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="downvote" value="false" />
<Tooltip
as="button"
type="submit"
disabled={!user?.totalKarma || user.totalKarma < 20}
class={cn([
'rounded-sm p-1 hover:bg-zinc-800',
userVote?.downvote === false ? 'text-blue-500' : 'text-zinc-500',
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
])}
text={user?.totalKarma && user.totalKarma >= 20 ? 'Upvote' : 'Need 20+ karma to vote'}
position="right"
aria-label="Upvote"
>
<Icon name="ri:arrow-up-line" class="h-3.5 w-3.5" />
</Tooltip>
</form>
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="downvote" value="true" />
<Tooltip
as="button"
text={user?.totalKarma && user.totalKarma >= 20 ? 'Downvote' : 'Need 20+ karma to vote'}
position="right"
disabled={!user?.totalKarma || user.totalKarma < 20}
class={cn([
'rounded-sm p-1 hover:bg-zinc-800',
userVote?.downvote === true ? 'text-red-500' : 'text-zinc-500',
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
])}
aria-label="Downvote"
>
<Icon name="ri:arrow-down-line" class="h-3.5 w-3.5" />
</Tooltip>
</form>
</div>
{
user && userCommentsDisabled ? (
<span class="text-xs text-red-400">You cannot reply due to low karma.</span>
) : (
<label
for={`reply-toggle-${comment.id.toString()}`}
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
>
<Icon name="ri:reply-line" class="h-3.5 w-3.5" />
Reply
</label>
)
}
{
user && (
<form
method="POST"
action={`${actions.notification.preferences.watchComment}&comment=${comment.id.toString()}#comment-${comment.id.toString()}`}
class="inline"
data-astro-reload
>
<input type="hidden" name="commentId" value={comment.id} />
<input type="hidden" name="watch" value={comment.isWatchingReplies ? 'false' : 'true'} />
<button
type="submit"
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
>
<Icon name={comment.isWatchingReplies ? 'ri:eye-off-line' : 'ri:eye-line'} class="size-3" />
{comment.isWatchingReplies ? 'Unwatch' : 'Watch'}
</button>
</form>
)
}
</div>
<CommentModeration class="mt-2 peer-checked/collapse:hidden" comment={comment} />
{
user && userCommentsDisabled ? null : (
<>
<input type="checkbox" id={`reply-toggle-${comment.id.toString()}`} class="peer/reply hidden" />
<CommentReply
serviceId={comment.serviceId}
parentId={comment.id}
commentId={comment.id}
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
/>
</>
)
}
{
comment.replies && comment.replies.length > 0 && depth < MAX_COMMENT_DEPTH && (
<div class="replies mt-3 peer-checked/collapse:hidden">
{comment.replies.map((reply) => (
<Astro.self
comment={reply}
depth={depth + 1}
showPending={showPending}
highlightedCommentId={isHighlightParent ? highlightedCommentId : null}
serviceSlug={serviceSlug}
itemReviewedId={itemReviewedId}
/>
))}
</div>
)
}
</div>