import { useCallback, useRef } from 'react'
import { useApolloClient } from '@apollo/client'
import styles from './comment.module.css'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { commentsViewedAfterComment } from '../lib/new-comments'
import classNames from 'classnames'
import useVisibility from './use-visibility'
import {
itemUpdateQuery,
commentUpdateFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
readCommentsFragment
} from '../lib/comments'
// filters out new comments, by id, that already exist in the item's comments
// preventing duplicate comments from being injected
function dedupeNewComments (newComments, comments = []) {
const existingIds = new Set(comments.map(c => c.id))
return newComments.filter(id => !existingIds.has(id))
}
// of an array of new comments, count each new comment + all their existing comments
function countNComments (newComments) {
let totalNComments = newComments.length
for (const comment of newComments) {
totalNComments += comment.ncomments || 0
}
return totalNComments
}
// prepares and creates a new comments fragment for injection into the cache
// returns a function that can be used to update an item's comments field
function prepareComments (data, client, newComments) {
const totalNComments = countNComments(newComments)
const itemHierarchy = data.path.split('.')
const ancestors = itemHierarchy.slice(0, -1)
const rootId = itemHierarchy[0]
// update all ancestors, but not the item itself
updateAncestorsCommentCount(client.cache, ancestors, totalNComments)
// update commentsViewedAt with the most recent fresh new comment
// quirk: this is not the most recent comment, it's the most recent comment in the newComments array
// as such, the next visit will not outline other new comments that are older than this one.
const latestCommentCreatedAt = getLatestCommentCreatedAt(newComments, data.createdAt)
commentsViewedAfterComment(rootId, latestCommentCreatedAt, totalNComments)
// an item can either have a comments.comments field, or not
const payload = data.comments
? {
...data,
ncomments: data.ncomments + totalNComments,
newComments: [],
comments: {
...data.comments,
comments: newComments.concat(data.comments.comments)
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...data,
ncomments: data.ncomments + totalNComments,
newComments: []
}
return payload
}
// traverses all new comments and their children
// if it's a thread, or we're in a new comment subtree, we also consider their existing children
function traverseNewComments (client, item, onLevel, currentDepth, inSubtree) {
// if we're at the depth limit, stop traversing, we've reached the bottom of the visible thread
if (currentDepth >= COMMENT_DEPTH_LIMIT) return
// if the current item shows less comments than its nDirectComments, it's paginated
// we don't want to count/inject new comments in paginated items, as they shouldn't be visible
if (item.comments?.comments?.length < item.nDirectComments) return
if (item.newComments && item.newComments.length > 0) {
const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments)
// being newComments an array of comment ids, we can get their latest version from the cache
// ensuring that we don't miss any new comments
const freshNewComments = dedupedNewComments.map(id => {
// mark all new comments as injected, so we can outline them
return { ...readCommentsFragment(client, id), injected: true }
}).filter(Boolean)
// at each level, we can execute a callback passing the current item's new comments, depth and ID
onLevel(freshNewComments, currentDepth, item.id)
// traverse the new comment's new comments and their children
for (const newComment of freshNewComments) {
traverseNewComments(client, newComment, onLevel, currentDepth + 1, true)
}
}
// check for new comments in existing children
// only if we're in a new comment subtree, or it's a thread
if (inSubtree && item.comments?.comments) {
for (const child of item.comments.comments) {
traverseNewComments(client, child, onLevel, currentDepth + 1, true)
}
}
}
// recursively processes and displays all new comments
// handles comment injection at each level, respecting depth limits
function injectNewComments (client, item, sort, currentDepth, thread) {
traverseNewComments(client, item, (newComments, depth, itemId) => {
if (newComments.length > 0) {
// traverseNewComments also passes the depth of the current item
// used to determine if in an array of new comments, we are injecting topLevels (depth 0) or not
if (depth === 0) {
itemUpdateQuery(client, itemId, sort, (data) => prepareComments(data, client, newComments))
} else {
commentUpdateFragment(client, itemId, (data) => prepareComments(data, client, newComments))
}
}
}, currentDepth, thread)
}
// counts all new comments of an item
function countAllNewComments (client, item, currentDepth, thread) {
let newCommentsCount = 0
let threadChildren = false
// count by traversing the comment structure
traverseNewComments(client, item, (newComments, depth) => {
newCommentsCount += countNComments(newComments)
// if we reached a depth greater than 1, the thread's children have new comments
if (depth > 1 && newComments.length > 0) {
threadChildren = true
}
}, currentDepth, thread)
return { newCommentsCount, threadChildren }
}
function FloatingComments ({ buttonRef, showNewComments, text }) {
// show the floating comments button only when we're past the main top level button
const isButtonVisible = useVisibility(buttonRef, { pastElement: true })
if (isButtonVisible) return null
return (
{
// show new comments as we scroll up
showNewComments()
buttonRef.current?.scrollIntoView({ behavior: 'smooth' })
}}
>
{text}
)
}
// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field
export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) {
const client = useApolloClient()
const ref = useRef(null)
// a thread comment is a comment at depth 1 (parent)
const thread = depth === 1
// recurse through all new comments and their children
// if the item is a thread, we also consider all of their existing children
const { newCommentsCount, threadChildren } = countAllNewComments(client, item, depth, thread)
// only if the item is a thread and its children have new comments, we show "show all new comments"
const threadComment = thread && threadChildren
const showNewComments = useCallback(() => {
// a top level comment doesn't pass depth, we pass its default value of 0 to signify this
// child comments are injected from the depth they're at
injectNewComments(client, item, sort, depth, threadComment)
}, [client, sort, item, depth])
const text = !threadComment
? `${newCommentsCount} new comment${newCommentsCount > 1 ? 's' : ''}`
: 'show all new comments'
return (
<>
0 ? 'visible' : 'hidden' }}
>
{text}
{topLevel && newCommentsCount > 0 && (
)}
>
)
}