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:
parent
1bda8a6de2
commit
0e842e9915
@ -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
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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' />
|
||||||
|
53
components/preserve-scroll.js
Normal file
53
components/preserve-scroll.js
Normal 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()
|
||||||
|
}
|
@ -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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -59,7 +59,6 @@ export const ITEM_FIELDS = gql`
|
|||||||
bio
|
bio
|
||||||
ncomments
|
ncomments
|
||||||
nDirectComments
|
nDirectComments
|
||||||
newComments @client
|
|
||||||
commentSats
|
commentSats
|
||||||
commentCredits
|
commentCredits
|
||||||
lastCommentAt
|
lastCommentAt
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user