515 lines
17 KiB
Plaintext
515 lines
17 KiB
Plaintext
---
|
|
import { Icon } from 'astro-icon/components'
|
|
import { Markdown } from 'astro-remote'
|
|
import { Schema } from 'astro-seo-schema'
|
|
import { actions } from 'astro:actions'
|
|
|
|
import { commentStatusById } from '../constants/commentStatus'
|
|
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 UserBadge from './UserBadge.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 isAdminOrModerator = !!user && (user.admin || user.moderator)
|
|
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
|
|
|
|
// 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/moderator
|
|
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 bg-night-700',
|
|
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 scrollbar-w-none flex items-center gap-2 overflow-auto 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">
|
|
<UserBadge
|
|
user={comment.author}
|
|
size="md"
|
|
class={cn('text-day-300', isAuthor && 'font-medium text-green-500')}
|
|
/>
|
|
|
|
{
|
|
(comment.author.verified || comment.author.admin || comment.author.moderator) && (
|
|
<Tooltip
|
|
text={`${
|
|
comment.author.admin || comment.author.moderator
|
|
? `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.moderator && !comment.author.admin && (
|
|
<BadgeSmall
|
|
icon="ri:graduation-cap-fill"
|
|
color="teal"
|
|
text="Moderator"
|
|
variant="faded"
|
|
inlineIcon
|
|
/>
|
|
)
|
|
}
|
|
|
|
{
|
|
isNewUser && !comment.author.admin && !comment.author.moderator && (
|
|
<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.moderator && (
|
|
<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} variant="faded" inlineIcon>
|
|
{roleInfo.label} at
|
|
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
|
</BadgeSmall>
|
|
)
|
|
})
|
|
}
|
|
</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.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={commentStatusById.VERIFIED.icon}
|
|
color={commentStatusById.VERIFIED.color}
|
|
text={commentStatusById.VERIFIED.label}
|
|
inlineIcon
|
|
/>
|
|
)
|
|
}
|
|
|
|
{
|
|
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
|
|
(showPending || isHighlightParent || isAuthorOrPrivileged) && (
|
|
<BadgeSmall
|
|
icon={commentStatusById.PENDING.icon}
|
|
color={commentStatusById.PENDING.color}
|
|
text={commentStatusById.PENDING.label}
|
|
inlineIcon
|
|
/>
|
|
)
|
|
}
|
|
|
|
{
|
|
comment.status === 'REJECTED' && isAuthorOrPrivileged && (
|
|
<BadgeSmall
|
|
icon={commentStatusById.REJECTED.icon}
|
|
color={commentStatusById.REJECTED.color}
|
|
text={commentStatusById.REJECTED.label}
|
|
inlineIcon
|
|
endIcon="ri:lock-line"
|
|
/>
|
|
)
|
|
}
|
|
|
|
{
|
|
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
|
<BadgeSmall
|
|
icon="ri:alert-fill"
|
|
color="yellow"
|
|
text="Needs admin review"
|
|
inlineIcon
|
|
endIcon="ri:lock-line"
|
|
/>
|
|
)
|
|
}
|
|
|
|
{/* 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="prose prose-sm prose-invert prose-strong:text-zinc-300/90 text-xs text-zinc-300">
|
|
<Markdown content={`**Added context:** ${comment.communityNote}`} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
user && (user.admin || user.moderator) && 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.moderator) && 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>
|