live comments: auto show new comments (#2355)

* enhance: FaviconProvider, keep track of new comment IDs to change favicon, remove new comment IDs per outline removal

* don't track oneself comments

* enhance: auto-show new comments, idempotency by ignoring already injected comments, preserveScroll utility

* fadeIn animation on comment injection; cleanup: remove unused counts and thread handling; non-critical fix: always give rootLastCommentAt a value

* reliably preserve scroll position by tracking a reference found at the center of the viewport; cleanup: add more comments, add cleanup function

* mitigate fractional scrolling subtle layout shifts by rounding the new reference element position

* enhanced outlining system, favicon context keeps track of new comments presence

- de-outlining now happens only for outlined comments
- enhanced outlining: add outline only if isNewComment
- de-outlining will remove the new comments favicon
- on unmount remove the new comments favicon

* remove the new comments favicon on new comments injection

* track only deduplicated new comments

* fix typo

* clearer unsetOutline conditions, fix typo in live comments hook

* backport: remove the injectedComment class from injected comments after animation ends

* set the new comments favicon on any new outlined comment

* enhance: directly inject new comments; cleanup: dismantle ShowNewComments, remove newComments field

* tweaks: slower injection animation, clear favicon on Comment section unmount

* change nDirectComments bug strategy to avoiding updates on comment edit

* cleanup: better naming, re-instate injected comments outline

* injection: major cache utilities refactor, don't preserve scroll if no comments have been injected

- don't preserve scroll if after deduplication we don't inject any comments

- use manual read/write cache updates to control the flow
-- allows to check if we are really injecting or not

- reduce polling to 5 seconds instead of 10

- light cleanup
-- removed update cache functions
-- added 'injected' to typeDefs (gql consistency)

* cleanup: detailed comments, refactor, remove clutter

Refactor:
+ clearer variables
+ depth calculation utility function
+ use destructured Apollo cache
+ extract item object from item query
+ skip ignored comment instead of ending the loop

CSS:
+ from-to fadeIn animation keyframes
- floatingComments unused class

Favicon:
+ provider exported by default

* fix wrong merge

* split: remove favicon context

* split: remove favicon pngs

* regression: revert to updateQuery for multiple comment fragments handling

* reverse multiple reads for deduplication on comment injection

* fix regression on apollo manipulations via fn; cleanup: remove wrong deps from outlining
This commit is contained in:
soxa 2025-08-08 17:04:54 +02:00 committed by GitHub
parent 1bda8a6de2
commit 0e842e9915
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 177 additions and 317 deletions

View File

@ -149,7 +149,7 @@ export default gql`
ncomments: Int! ncomments: Int!
nDirectComments: Int! nDirectComments: Int!
comments(sort: String, cursor: String): Comments! comments(sort: String, cursor: String): Comments!
newComments(rootId: ID, after: Date): Comments! injected: Boolean!
path: String path: String
position: Int position: Int
prior: Int prior: Int

View File

@ -28,7 +28,6 @@ import LinkToContext from './link-to-context'
import Boost from './boost-button' import Boost from './boost-button'
import { gql, useApolloClient } from '@apollo/client' import { gql, useApolloClient } from '@apollo/client'
import classNames from 'classnames' import classNames from 'classnames'
import { ShowNewComments } from './show-new-comments'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -115,6 +114,17 @@ export default function Comment ({
const { cache } = useApolloClient() const { cache } = useApolloClient()
const unsetOutline = () => {
if (!ref.current) return
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
// don't try to unset the outline if the comment is not outlined or we already unset the outline
if (hasOutline && !hasOutlineUnset) {
ref.current.classList.add('outline-new-comment-unset')
}
}
useEffect(() => { useEffect(() => {
const comment = cache.readFragment({ const comment = cache.readFragment({
id: `Item:${router.query.commentId}`, id: `Item:${router.query.commentId}`,
@ -142,17 +152,26 @@ export default function Comment ({
useEffect(() => { useEffect(() => {
if (me?.id === item.user?.id) return if (me?.id === item.user?.id) return
const itemCreatedAt = new Date(item.createdAt).getTime()
if (router.query.commentsViewedAt && const itemCreatedAt = new Date(item.createdAt).getTime()
!item.injected && // it's a new comment if it was created after the last comment was viewed
itemCreatedAt > router.query.commentsViewedAt) { // or, in the case of live comments, after the last comment was created
ref.current.classList.add('outline-new-comment') const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
// newly injected comments have to use a different class to outline every new comment (rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
} else if (rootLastCommentAt && if (!isNewComment) return
item.injected &&
itemCreatedAt > new Date(rootLastCommentAt).getTime()) { if (item.injected) {
// newly injected comments (item.injected) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-injected-comment') ref.current.classList.add('outline-new-injected-comment')
// wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.injectedComment)
}, { once: true })
// animate the live comment injection
ref.current.classList.add(styles.injectedComment)
} else {
ref.current.classList.add('outline-new-comment')
} }
}, [item.id, rootLastCommentAt]) }, [item.id, rootLastCommentAt])
@ -168,8 +187,8 @@ export default function Comment ({
return ( return (
<div <div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')} onMouseEnter={unsetOutline}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')} onTouchStart={unsetOutline}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode {item.outlawed && !me?.privates?.wildWestMode
@ -269,9 +288,6 @@ export default function Comment ({
: !noReply && : !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}> <Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />} {root.bounty && !bountyPaid && <PayBounty item={item} />}
<div className='ms-auto'>
<ShowNewComments item={item} depth={depth} />
</div>
</Reply>} </Reply>}
{children} {children}
<div className={styles.comments}> <div className={styles.comments}>
@ -300,7 +316,6 @@ export default function Comment ({
export function ViewMoreReplies ({ item, navigateRoot = false }) { export function ViewMoreReplies ({ item, navigateRoot = false }) {
const root = useRoot() const root = useRoot()
const { cache } = useApolloClient()
const id = navigateRoot ? commentSubTreeRootId(item, root) : item.id const id = navigateRoot ? commentSubTreeRootId(item, root) : item.id
const href = `/items/${id}` + (navigateRoot ? '' : `?commentId=${item.id}`) const href = `/items/${id}` + (navigateRoot ? '' : `?commentId=${item.id}`)
@ -314,23 +329,8 @@ export function ViewMoreReplies ({ item, navigateRoot = false }) {
href={href} href={href}
as={`/items/${id}`} as={`/items/${id}`}
className='fw-bold d-flex align-items-center gap-2 text-muted' className='fw-bold d-flex align-items-center gap-2 text-muted'
onClick={() => {
if (!item.newComments?.length) return
// clear new comments going to another page
cache.writeFragment({
id: `Item:${item.id}`,
fragment: gql`
fragment NewComments on Item {
newComments
}`,
data: {
newComments: []
}
})
}}
> >
{text} {text}
{item.newComments?.length > 0 && <div className={styles.newCommentDot} />}
</Link> </Link>
) )
} }

View File

@ -160,20 +160,11 @@
} }
} }
.floatingComments { .injectedComment {
position: fixed; animation: fadeIn 0.5s ease-out;
top: 72px;
left: 50%;
transform: translateX(-50%);
z-index: 1050;
animation: slideDown 0.3s ease-out;
} }
@keyframes slideDown { @keyframes fadeIn {
0% { from { opacity: 0; }
transform: translateX(-50%) translateY(-100px); to { opacity: 1; }
}
100% {
transform: translateX(-50%) translateY(0);
}
} }

View File

@ -9,7 +9,6 @@ import { useRouter } from 'next/router'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments' import useLiveComments from './use-live-comments'
import { ShowNewComments } from './show-new-comments'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter() const router = useRouter()
@ -66,17 +65,17 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
export default function Comments ({ export default function Comments ({
parentId, pinned, bio, parentCreatedAt, parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, item, ...props commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
}) { }) {
const router = useRouter() const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort) useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return ( return (
<> <>
<ShowNewComments topLevel item={item} sort={router.query.sort} />
{comments?.length > 0 {comments?.length > 0
? <CommentsHeader ? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt} commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -95,11 +94,11 @@ export default function Comments ({
: null} : null}
{pins.map(item => ( {pins.map(item => (
<Fragment key={item.id}> <Fragment key={item.id}>
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt} {...props} pin /> <Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
</Fragment> </Fragment>
))} ))}
{comments.filter(({ position }) => !position).map(item => ( {comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt} {...props} /> <Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
))} ))}
{ncomments > FULL_COMMENTS_THRESHOLD && {ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter <MoreFooter

View File

@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from 'react'
import Price from '../price' import Price from '../price'
import SubSelect from '../sub-select' import SubSelect from '../sub-select'
import { USER_ID } from '../../lib/constants' import { USER_ID } from '../../lib/constants'
import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg' import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me' import { useMe } from '../me'
import { abbrNum } from '../../lib/format' import { abbrNum } from '../../lib/format'
@ -121,9 +120,6 @@ export function NavNotifications ({ className }) {
return ( return (
<> <>
<Head>
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior> <Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}> <Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
<NoteIcon height={28} width={20} className='theme' /> <NoteIcon height={28} width={20} className='theme' />

View File

@ -0,0 +1,53 @@
export default function preserveScroll (callback) {
// preserve the actual scroll position
const scrollTop = window.scrollY
// if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) {
callback()
return
}
// get a reference element at the center of the viewport to track if content is added above it
const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
// observe the document for changes in height
const observer = new window.MutationObserver(() => {
// request animation frame to ensure the DOM is updated
window.requestAnimationFrame(() => {
// we can't proceed if we couldn't find a traceable reference element
if (!ref) {
cleanup()
return
}
// get the new position of the reference element along with the new scroll position
const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
// has the reference element moved?
const refMoved = newRefTop - refTop
// if the reference element moved, we need to scroll to the new position
if (refMoved > 0) {
window.scrollTo({
// some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer
top: scrollTop + Math.ceil(refMoved),
behavior: 'instant'
})
}
cleanup()
})
})
const timeout = setTimeout(() => cleanup(), 1000) // fallback
function cleanup () {
clearTimeout(timeout)
observer.disconnect()
}
observer.observe(document.body, { childList: true, subtree: true })
callback()
}

View File

@ -1,204 +0,0 @@
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 (
<span
className={classNames(styles.floatingComments, 'btn btn-sm btn-info')}
onClick={() => {
// show new comments as we scroll up
showNewComments()
buttonRef.current?.scrollIntoView({ behavior: 'smooth' })
}}
>
{text}
</span>
)
}
// 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 (
<>
<span
ref={ref}
onClick={showNewComments}
className='fw-bold d-flex align-items-center gap-2 px-3 pointer'
style={{ visibility: newCommentsCount > 0 ? 'visible' : 'hidden' }}
>
{text}
<div className={styles.newCommentDot} />
</span>
{topLevel && newCommentsCount > 0 && (
<FloatingComments buttonRef={ref} showNewComments={showNewComments} text={text} />
)}
</>
)
}

View File

@ -1,47 +1,84 @@
import { useQuery, useApolloClient } from '@apollo/client' import preserveScroll from './preserve-scroll'
import { SSR } from '../lib/constants'
import { GET_NEW_COMMENTS } from '../fragments/comments' import { GET_NEW_COMMENTS } from '../fragments/comments'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } from '../lib/comments' import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
const POLL_INTERVAL = 1000 * 10 // 10 seconds const POLL_INTERVAL = 1000 * 5 // 5 seconds
// merge new comment into item's newComments // prepares and creates a fragment for injection into the cache
// and prevent duplicates by checking if the comment is already in item's newComments or existing comments // also handles side effects like updating comment counts and viewedAt timestamps
function mergeNewComment (item, newComment) { function prepareComments (item, cache, newComment) {
const existingNewComments = item.newComments || []
const existingComments = item.comments?.comments || [] const existingComments = item.comments?.comments || []
// is the incoming new comment already in item's new comments or existing comments? // is the incoming new comment already in item's existing comments?
if (existingNewComments.includes(newComment.id) || existingComments.some(comment => comment.id === newComment.id)) { // if so, we don't need to update the cache
return item if (existingComments.some(comment => comment.id === newComment.id)) return item
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
const itemHierarchy = item.path.split('.')
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// update commentsViewedAt to now, and add the number of new comments
const rootId = itemHierarchy[0]
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
// an item can either have a comments.comments field, or not
const payload = item.comments
? {
...item,
ncomments: item.ncomments + totalNComments,
comments: {
...item.comments,
comments: [injectedComment, ...item.comments.comments]
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...item,
ncomments: item.ncomments + totalNComments
} }
return { ...item, newComments: [...existingNewComments, newComment.id] } return payload
} }
function cacheNewComments (client, rootId, newComments, sort) { function cacheNewComments (cache, rootId, newComments, sort) {
for (const newComment of newComments) { for (const newComment of newComments) {
const { parentId } = newComment const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId) const topLevel = Number(parentId) === Number(rootId)
// if the comment is a top level comment, update the item // if the comment is a top level comment, update the item, else update the parent comment
if (topLevel) { if (topLevel) {
// merge the new comment into the item's newComments field, checking for duplicates updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment))
} else { } else {
// if the comment is a reply, update the parent comment // if the comment is too deep, we can skip it
// merge the new comment into the parent comment's newComments field, checking for duplicates const depth = calculateDepth(newComment.path, rootId, parentId)
commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
} }
} }
} }
// useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt // useLiveComments fetches new comments under an item (rootId),
// and inserts them into the newComment client field of their parent comment/post. // that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) { export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}` const latestKey = `liveCommentsLatest:${rootId}`
const client = useApolloClient() const { cache } = useApolloClient()
const [latest, setLatest] = useState(after) const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
@ -72,8 +109,9 @@ export default function useLiveComments (rootId, after, sort) {
useEffect(() => { useEffect(() => {
if (!data?.newComments?.comments?.length) return if (!data?.newComments?.comments?.length) return
// merge and cache new comments in their parent comment/post // directly inject new comments into the cache, preserving scroll position
cacheNewComments(client, rootId, data.newComments.comments, sort) // quirk: scroll is preserved even if we are not injecting new comments due to dedupe
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
// update latest timestamp to the latest comment created at // update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations // save it to session storage, to persist between client-side navigations
@ -82,5 +120,5 @@ export default function useLiveComments (rootId, after, sort) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.sessionStorage.setItem(latestKey, newLatest) window.sessionStorage.setItem(latestKey, newLatest)
} }
}, [data, client, rootId, sort, latest]) }, [data, cache, rootId, sort, latest])
} }

View File

@ -47,7 +47,6 @@ export const COMMENT_FIELDS = gql`
otsHash otsHash
ncomments ncomments
nDirectComments nDirectComments
newComments @client
injected @client injected @client
imgproxyUrls imgproxyUrls
rel rel
@ -176,7 +175,6 @@ export const COMMENT_WITH_NEW_RECURSIVE = gql`
...CommentsRecursive ...CommentsRecursive
} }
} }
newComments @client
} }
` `
@ -190,7 +188,6 @@ export const COMMENT_WITH_NEW_LIMITED = gql`
...CommentFields ...CommentFields
} }
} }
newComments @client
} }
` `
@ -199,7 +196,6 @@ export const COMMENT_WITH_NEW_MINIMAL = gql`
fragment CommentWithNewMinimal on Item { fragment CommentWithNewMinimal on Item {
...CommentFields ...CommentFields
newComments @client
} }
` `

View File

@ -59,7 +59,6 @@ export const ITEM_FIELDS = gql`
bio bio
ncomments ncomments
nDirectComments nDirectComments
newComments @client
commentSats commentSats
commentCredits commentCredits
lastCommentAt lastCommentAt

View File

@ -4,6 +4,7 @@ import { decodeCursor, LIMIT } from './cursor'
import { COMMENTS_LIMIT, SSR } from './constants' import { COMMENTS_LIMIT, SSR } from './constants'
import { RetryLink } from '@apollo/client/link/retry' import { RetryLink } from '@apollo/client/link/retry'
import { isMutationOperation, isQueryOperation } from '@apollo/client/utilities' import { isMutationOperation, isQueryOperation } from '@apollo/client/utilities'
function isFirstPage (cursor, existingThings, limit = LIMIT) { function isFirstPage (cursor, existingThings, limit = LIMIT) {
if (cursor) { if (cursor) {
const decursor = decodeCursor(cursor) const decursor = decodeCursor(cursor)
@ -323,11 +324,6 @@ function getClient (uri) {
} }
} }
}, },
newComments: {
read (newComments) {
return newComments || []
}
},
injected: { injected: {
read (injected) { read (injected) {
return injected || false return injected || false

View File

@ -17,11 +17,10 @@ export function updateAncestorsCommentCount (cache, ancestors, increment) {
}) })
} }
// live comments - cache manipulations
// updates the item query in the cache // updates the item query in the cache
// this is used by live comments to update a top level item's newComments field // this is used by live comments to update a top level item's comments field
export function itemUpdateQuery (client, id, sort, fn) { export function updateItemQuery (cache, id, sort, fn) {
client.cache.updateQuery({ cache.updateQuery({
query: ITEM_FULL, query: ITEM_FULL,
// updateQuery needs the correct variables to update the correct item // updateQuery needs the correct variables to update the correct item
// the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists // the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists
@ -33,8 +32,8 @@ export function itemUpdateQuery (client, id, sort, fn) {
} }
// updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether // updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether
export function commentUpdateFragment (client, id, fn) { export function updateCommentFragment (cache, id, fn) {
let result = client.cache.updateFragment({ let result = cache.updateFragment({
id: `Item:${id}`, id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE, fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive' fragmentName: 'CommentWithNewRecursive'
@ -46,7 +45,7 @@ export function commentUpdateFragment (client, id, fn) {
// sometimes comments can start to reach their depth limit, and lack adherence to the CommentsRecursive fragment // sometimes comments can start to reach their depth limit, and lack adherence to the CommentsRecursive fragment
// for this reason, we update the fragment with a limited version that only includes the CommentFields fragment // for this reason, we update the fragment with a limited version that only includes the CommentFields fragment
if (!result) { if (!result) {
result = client.cache.updateFragment({ result = cache.updateFragment({
id: `Item:${id}`, id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_LIMITED, fragment: COMMENT_WITH_NEW_LIMITED,
fragmentName: 'CommentWithNewLimited' fragmentName: 'CommentWithNewLimited'
@ -58,7 +57,7 @@ export function commentUpdateFragment (client, id, fn) {
// at the deepest level, the comment can't have any children, here we update only the newComments field. // at the deepest level, the comment can't have any children, here we update only the newComments field.
if (!result) { if (!result) {
result = client.cache.updateFragment({ result = cache.updateFragment({
id: `Item:${id}`, id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_MINIMAL, fragment: COMMENT_WITH_NEW_MINIMAL,
fragmentName: 'CommentWithNewMinimal' fragmentName: 'CommentWithNewMinimal'
@ -71,19 +70,16 @@ export function commentUpdateFragment (client, id, fn) {
return result return result
} }
// reads a nested comments fragment from the cache export function calculateDepth (path, rootId, parentId) {
// this is used to read a comment and its children comments // calculate depth by counting path segments from root to parent
// it has a fallback for comments nearing the depth limit, that lack the CommentsRecursive fragment const pathSegments = path.split('.')
export function readCommentsFragment (client, id) { const rootIndex = pathSegments.indexOf(rootId.toString())
return client.cache.readFragment({ const parentIndex = pathSegments.indexOf(parentId.toString())
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE, // depth is the distance from root to parent in the path
fragmentName: 'CommentWithNewRecursive' const depth = parentIndex - rootIndex
}) || client.cache.readFragment({
id: `Item:${id}`, return depth
fragment: COMMENT_WITH_NEW_LIMITED,
fragmentName: 'CommentWithNewLimited'
})
} }
// finds the most recent createdAt timestamp from an array of comments // finds the most recent createdAt timestamp from an array of comments