import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import axios from 'axios'
import CommentForm from '../../components/comments/CommentForm'
import ReactionBar from '../../components/comments/ReactionBar'
import LevelBadge from '../../components/xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
function formatAbsoluteDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_FORMATTER.format(date)
}
function formatAbsoluteDateTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
}
function formatCommentTime(primaryLabel, createdAt) {
return primaryLabel || formatAbsoluteDate(createdAt)
}
function ReplyIcon() {
return (
)
}
function TrashIcon() {
return (
)
}
function ChatBubbleIcon() {
return (
)
}
function ChevronDownIcon({ className }) {
return (
)
}
function Avatar({ user, size = 36 }) {
if (user?.avatar_url) {
return (
{
event.currentTarget.onerror = null
event.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
}
const initials = (user?.display || user?.username || '?').slice(0, 1).toUpperCase()
return (
{initials}
)
}
function DeleteButton({ onDelete, pending = false }) {
return (
)
}
function ReplyItem({ reply, articleId, isLoggedIn, onReplyPosted, onDelete, deletePending = false, depth = 1 }) {
const user = reply.user
const html = reply.rendered_content ?? null
const plain = reply.raw_content ?? ''
const profileLabel = user?.display || user?.username || 'Member'
const replies = reply.replies || []
const reactionEndpoint = `/api/news/comments/${reply.id}/reactions`
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
useEffect(() => {
if (reply.reactions || !reply.id) return
axios
.get(reactionEndpoint)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [reply.id, reply.reactions, reactionEndpoint])
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(reply.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [onReplyPosted, reply.id])
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
const avatarSize = depth >= 3 ? 22 : 28
return (
)
}
function CommentItem({ comment, isLoggedIn, articleId, onReplyPosted, onDelete, deletePending = false }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.raw_content ?? ''
const profileLabel = user?.display || user?.username || 'Member'
const replies = comment.replies || []
const flood = isFlood(plain)
const reactionEndpoint = `/api/news/comments/${comment.id}/reactions`
const [expanded, setExpanded] = useState(!flood)
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
.get(reactionEndpoint)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [comment.id, comment.reactions, reactionEndpoint])
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(comment.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [comment.id, onReplyPosted])
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
return (
)
}
function Skeleton() {
return (
{Array.from({ length: 3 }).map((_, index) => (
))}
)
}
function NewsComments({ articleId, isLoggedIn = false, loginUrl = '/login' }) {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [lastPage, setLastPage] = useState(1)
const [total, setTotal] = useState(0)
const [deletingId, setDeletingId] = useState(null)
const initialized = useRef(false)
const loadComments = useCallback(async (nextPage = 1) => {
if (!articleId) return
setLoading(true)
try {
const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`)
if (nextPage === 1) {
setComments(data.data ?? [])
} else {
setComments((current) => [...current, ...(data.data ?? [])])
}
setPage(data.meta?.current_page ?? nextPage)
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// Keep the server-rendered fallback in place if the API is unavailable.
} finally {
setLoading(false)
}
}, [articleId])
useEffect(() => {
if (initialized.current) return
initialized.current = true
loadComments(1)
}, [loadComments])
const handlePosted = useCallback((newComment) => {
const comment = { ...newComment, replies: newComment.replies || [] }
setComments((current) => [comment, ...current])
setTotal((current) => current + 1)
}, [])
const handleReplyPosted = useCallback((parentId, newReply) => {
const insertReply = (nodes) => nodes.map((comment) => {
if (comment.id === parentId) {
return { ...comment, replies: [...(comment.replies || []), { ...newReply, replies: [] }] }
}
if (comment.replies?.length) {
return { ...comment, replies: insertReply(comment.replies) }
}
return comment
})
setComments((current) => insertReply(current))
setTotal((current) => current + 1)
}, [])
const handleDelete = useCallback(async (commentId) => {
if (!commentId || deletingId) return
setDeletingId(commentId)
try {
await axios.delete(`/api/news/articles/${articleId}/comments/${commentId}`)
await loadComments(1)
} catch {
// Preserve the current state if deletion fails.
} finally {
setDeletingId(null)
}
}, [articleId, deletingId, loadComments])
return (
Comments
{total > 0 ? (
{total}
) : null}
{loading && comments.length === 0 ? (
) : comments.length === 0 ? (
No comments yet
Be the first to share your thoughts.
) : (
<>
{comments.map((comment) => (
))}
{page < lastPage ? (
) : null}
>
)}
{articleId ? (
) : null}
)
}
if (typeof document !== 'undefined') {
const mountEl = document.getElementById('news-comments-root')
const propsEl = document.getElementById('news-comments-props')
if (mountEl && propsEl) {
let props = {}
try {
props = JSON.parse(propsEl.textContent || '{}')
} catch {
props = {}
}
createRoot(mountEl).render()
}
}
export default NewsComments