Skip to content

Enhancements to live comments #2269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 95 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
f115148
check new comments every 10 seconds
Soxasora Apr 18, 2025
c813e59
enhance: clear newComments on child comments when we show a topLevel …
Soxasora Apr 18, 2025
c41a468
handle comments of comments, new structure to clear newComments on ch…
Soxasora Apr 18, 2025
2a1e9e9
use original recursive comments data structure
Soxasora Apr 19, 2025
b064c63
correct comment structure after deduplication
Soxasora Apr 19, 2025
e0542ce
faster newComments query deduplication, don't need to know how many c…
Soxasora Apr 19, 2025
e307674
cleanup: comments on newComments fetches and dedupes
Soxasora Apr 19, 2025
beb10af
cleanup, use correct function declarations
Soxasora Apr 22, 2025
4add86e
stop polling after 30 minutes, pause polling if user is not on the page
Soxasora Apr 28, 2025
8716849
ActionTooltip indicating that the user is in a live comment section
Soxasora Apr 28, 2025
8f19b72
handleVisibilityChange to control polling by visibility
Soxasora Apr 28, 2025
7e06381
paused polling styling, check activity on 1 minute intervals and visi…
Soxasora Apr 28, 2025
553592e
user can resume polling without refreshing the page
Soxasora Apr 28, 2025
4774022
Merge branch 'master' into live_comments
Soxasora Jun 23, 2025
e9b7b15
better naming, straightforward dedupeComment on newComment arrival
Soxasora Jun 24, 2025
65b61ab
cleanup: better naming, get latest comment creation, correct order of…
Soxasora Jun 25, 2025
8126858
cleanup: refactor live comments related functions to use-live-comment…
Soxasora Jun 25, 2025
08bbba4
refactor: clearer naming, optimized polling and date retrieval logic,…
Soxasora Jun 25, 2025
6f417cc
ui: place ShowNewComments in the bottom-right corner of nested comments
Soxasora Jun 27, 2025
274927d
fix: make updateQuery sort-aware to correctly inject the comment in t…
Soxasora Jun 27, 2025
907c71d
cleanup: better naming; fix: usecallback on live comments component; …
Soxasora Jun 27, 2025
e797011
fix: don't show unpaid comments; cleanup: compact cache merge/dedupe,…
Soxasora Jun 29, 2025
d15a024
fix: read new comments fragments to inject fresh new comments, fixing…
Soxasora Jun 30, 2025
6a93d2a
enhance: queuedComments Ref, cache-and-network fetch policy; freshNew…
Soxasora Jul 1, 2025
a29f9e3
cleanup: detailed comments and better ShowNewComment text
Soxasora Jul 1, 2025
f710457
fix: while showing new comments, also update ncomments for UI and pag…
Soxasora Jul 1, 2025
40d56fe
refactor: ShowNewComments is its own component; cleanup: proven usele…
Soxasora Jul 1, 2025
d025407
Merge branch 'master' into live_comments
Soxasora Jul 1, 2025
c7095a7
enhance: direct latest comment createdAt calc with reduce
Soxasora Jul 1, 2025
05785db
cleanup queue on unmount
Soxasora Jul 1, 2025
efb12d6
feat: live comments indicator for bottomed-out replies, ncomments upd…
Soxasora Jul 3, 2025
450f7bc
Merge branch 'master' into live_comments
Soxasora Jul 7, 2025
31b8937
enhance: give the possibility to show all new comments of a thread, e…
Soxasora Jul 7, 2025
a702b7b
enhance: change favicon on new comments; warn: prop-drilling
Soxasora Jul 9, 2025
5f0ca6c
refactor: merge ShowAllNewComments with ShowNewComments, better usage…
Soxasora Jul 9, 2025
0c58834
hotfix: isThread should be recognized when an item has 2 items in its…
Soxasora Jul 9, 2025
ee18316
fix regression: topLevel comments not showing
Soxasora Jul 10, 2025
d1abb2e
fix: avoid trying to show new comments even after the depth limit; to…
Soxasora Jul 10, 2025
535afb8
favicon-new-comment, fix favicon showing also when there aren't new c…
Soxasora Jul 12, 2025
25e8e12
enhance: highlight new comments when shown; nit-fixes and cleanups
Soxasora Jul 14, 2025
e065f17
cleanup: move cache manipulation functions, comments for comments.js
Soxasora Jul 14, 2025
1700652
enhance: highlight new comment with injected field, recursive injecti…
Soxasora Jul 15, 2025
09c01e5
Merge branch 'master' into live_comments
huumn Jul 15, 2025
f24ad00
backport live comments logic enhancements
Soxasora Jul 16, 2025
5d64ea7
hotfix: handle undefined item.comments.comments on dedupe
Soxasora Jul 16, 2025
f929764
merge: live_comments -> live_comments_enhancements; remove bad favico…
Soxasora Jul 16, 2025
a8dcbc0
hotfix: fix lint after merge
Soxasora Jul 16, 2025
cfd4a0c
hotfix: limited fragment for recursive comment collection; protect fr…
Soxasora Jul 16, 2025
93dab59
merge: missing memo deps, limited fragment for non-recursive comments…
Soxasora Jul 16, 2025
b8ec07e
docs: clarify ncomments updates
Soxasora Jul 16, 2025
c36043d
merge: live_comments -> live_comments enhancements
Soxasora Jul 16, 2025
902ba22
cleanup: remove unused export
Soxasora Jul 16, 2025
08dae56
Merge branch 'master' into live_comments
huumn Jul 16, 2025
41e339a
count and show only the direct new comments and recursively their chi…
Soxasora Jul 17, 2025
53b2611
fix regression on top level counting
Soxasora Jul 17, 2025
bd04ed9
hotfix: introduce readNestedCommentsFragment in lib/comments.js
Soxasora Jul 17, 2025
ef947e9
Merge branch 'master' into live_comments
huumn Jul 17, 2025
f3eb47f
fix: count also existing comments of a new comment; cleanup: use read…
Soxasora Jul 17, 2025
a66f1fe
Merge branch 'master' into live_comments
huumn Jul 17, 2025
2279971
add support for comments at the deepest level
Soxasora Jul 18, 2025
5d3f3bd
cleanup: remove logs
Soxasora Jul 18, 2025
c71accb
revert counting on ReplyOnAnotherPage, TODO for enhancements PR
Soxasora Jul 18, 2025
9736661
move ShowNewComments to CommentsHeader for top level comments
Soxasora Jul 18, 2025
38237db
fix: update commentsViewedAfterComment to support ncomments
Soxasora Jul 18, 2025
730956c
Merge branch 'master' into live_comments
Soxasora Jul 18, 2025
b1b49d7
fix typo, lint
Soxasora Jul 18, 2025
df6b7f8
cleanup: remove old CSS
Soxasora Jul 18, 2025
6a17825
enhance: inject topLevel and its children new comments, simplify inje…
Soxasora Jul 18, 2025
1d6e69b
cleanup: remove unused topLevel prop
Soxasora Jul 18, 2025
29e078b
fix: deepest comments don't have CommentsRecursive structure, don't a…
Soxasora Jul 20, 2025
60ba222
move top level ShowNewComments above CommentsHeader; preserve space t…
Soxasora Jul 21, 2025
74dd1bc
merge: live_comments -> live_comments_enhancements
Soxasora Jul 21, 2025
9d1ddc5
cleanup: remove unused item on CommentsHeader
Soxasora Jul 21, 2025
a99e8d3
enhance: scroll and load new comments via a floating button using Int…
Soxasora Jul 21, 2025
c0dce15
merge: live_comments -> live_comments_enhancements
Soxasora Jul 21, 2025
b7554f7
Merge branch 'master' into live_comments_enhancements
Soxasora Jul 21, 2025
dafbe80
style: transparent and animated floating button, new comment dot colo…
Soxasora Jul 22, 2025
4057efe
cleanup: less redundancy between the two types of buttons; enhance: s…
Soxasora Jul 22, 2025
8df1a20
enhance: outline newly injected comments using root item's lastCommentAt
Soxasora Jul 22, 2025
ad513c9
cleanup: remove transparency of floating comments button, remove othe…
Soxasora Jul 22, 2025
911df45
adapt and restore showing all new comments of a thread
Soxasora Jul 22, 2025
0b13261
fix: respect deepest comments structure on injection, adjust depth li…
Soxasora Jul 22, 2025
acf5a9c
fix: avoid double outlines because of all conditions being met
Soxasora Jul 22, 2025
d9bba7d
cleanup: remove favicon, dedicate space for useVisibility, correct co…
Soxasora Jul 22, 2025
adef9c9
ux: show all new comments of a thread only if its children have them
Soxasora Jul 23, 2025
3791b87
mark injected comments in the cache for reliable outlining
Soxasora Jul 23, 2025
8e3c639
cleanup: clearer structure, more explaining
Soxasora Jul 23, 2025
71f86e8
Merge branch 'master' into live_comments_enhancements
Soxasora Jul 23, 2025
6ac8cc9
optimize: better closure usage, remove duplicate code, immutable payl…
Soxasora Jul 23, 2025
f616f6e
cleanup: further clarifications
Soxasora Jul 23, 2025
98b5f00
safer rootLastCommentAt usage for injected comments outlining
Soxasora Jul 23, 2025
662929e
Merge branch 'master' into live_comments_enhancements
huumn Jul 23, 2025
b5b15b9
hotfix: ignore nDirectComments server updates when the item being upd…
Soxasora Jul 23, 2025
a803c68
simpler show all new comments text for thread comments, regardless of…
Soxasora Jul 23, 2025
d3be789
fix: reference the correct Item for newComments reading, during nDire…
Soxasora Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
}

export default function Comment ({
item, children, replyOpen, includeParent, topLevel,
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) {
const [edit, setEdit] = useState()
Expand Down Expand Up @@ -141,12 +141,20 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId])

useEffect(() => {
if (me?.id === item.user?.id) return
const itemCreatedAt = new Date(item.createdAt).getTime()

if (router.query.commentsViewedAt &&
me?.id !== item.user?.id &&
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
!item.injected &&
itemCreatedAt > router.query.commentsViewedAt) {
ref.current.classList.add('outline-new-comment')
// newly injected comments have to use a different class to outline every new comment
} else if (rootLastCommentAt &&
item.injected &&
itemCreatedAt > new Date(rootLastCommentAt).getTime()) {
ref.current.classList.add('outline-new-injected-comment')
}
}, [item.id])
}, [item.id, rootLastCommentAt])

const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
// Don't show OP badge when anon user comments on anon user posts
Expand Down Expand Up @@ -261,19 +269,17 @@ export default function Comment ({
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
{item.newComments?.length > 0 && (
<div className='ms-auto'>
<ShowNewComments item={item} depth={depth} />
</div>
)}
<div className='ms-auto'>
<ShowNewComments item={item} depth={depth} />
</div>
</Reply>}
{children}
<div className={styles.comments}>
{!noComments && item.comments?.comments
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
))}
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nhas={item.ncomments} />}
</>
Expand Down
26 changes: 22 additions & 4 deletions components/comment.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,33 @@

@keyframes pulse {
0% {
background-color: #FADA5E;
background-color: #80d3ff;
opacity: 0.7;
}
50% {
background-color: #F6911D;
background-color: #007cbe;
opacity: 1;
}
100% {
background-color: #FADA5E;
background-color: #80d3ff;
opacity: 0.7;
}
}
}

.floatingComments {
position: fixed;
top: 72px;
left: 50%;
transform: translateX(-50%);
z-index: 1050;
animation: slideDown 0.3s ease-out;
}

@keyframes slideDown {
0% {
transform: translateX(-50%) translateY(-100px);
}
100% {
transform: translateX(-50%) translateY(0);
}
}
6 changes: 3 additions & 3 deletions components/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default function Comments ({

return (
<>
<ShowNewComments item={item} sort={router.query.sort} />
<ShowNewComments topLevel item={item} sort={router.query.sort} />
{comments?.length > 0
? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
Expand All @@ -95,11 +95,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} {...props} pin />
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} />
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter
Expand Down
206 changes: 135 additions & 71 deletions components/show-new-comments.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useCallback } from 'react'
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,
Expand All @@ -18,118 +20,180 @@ function dedupeNewComments (newComments, comments = []) {
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 ({ client, newComments }) {
return (data) => {
// count total comments being injected: each new comment + all their existing nested comments
let totalNComments = newComments.length
for (const comment of newComments) {
// add all nested comments (subtree) under this newly injected comment to the total
totalNComments += (comment.ncomments || 0)
}
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: []
}

// update all ancestors, but not the item itself
const ancestors = data.path.split('.').slice(0, -1)
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)
const rootId = data.path.split('.')[0]
commentsViewedAfterComment(rootId, latestCommentCreatedAt, totalNComments)

// return the updated item with the new comments injected
return {
...data,
comments: { ...data.comments, comments: [...newComments, ...(data.comments?.comments || [])] },
ncomments: data.ncomments + totalNComments,
newComments: []
}
}
return payload
}

// traverses all new comments and their children
// at each level, we can execute a callback giving the new comments and the item
function traverseNewComments (client, item, onLevel, currentDepth = 1) {
if (currentDepth > COMMENT_DEPTH_LIMIT) return
// if we're showing all new comments of a thread, we also consider their existing children
function traverseNewComments (client, item, onLevel, threadComment = false, currentDepth = 1) {
// 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 (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 => {
return readCommentsFragment(client, id)
// mark all new comments as injected, so we can outline them
return { ...readCommentsFragment(client, id), injected: true }
}).filter(Boolean)

// passing currentDepth allows children of top level comments
// to be updated by the commentUpdateFragment
onLevel(freshNewComments, item, currentDepth)
// at each level, we can execute a callback passing the current item's new comments, depth and ID
onLevel(freshNewComments, currentDepth, item.id)

for (const newComment of freshNewComments) {
traverseNewComments(client, newComment, onLevel, currentDepth + 1)
traverseNewComments(client, newComment, onLevel, threadComment, currentDepth + 1)
}
}

// if we're showing all new comments of a thread
// we consider every child comment recursively
if (threadComment && item.comments?.comments) {
for (const child of item.comments.comments) {
traverseNewComments(client, child, onLevel, threadComment, currentDepth + 1)
}
}
}

// recursively processes and displays all new comments and its children
// recursively processes and displays all new comments
// handles comment injection at each level, respecting depth limits
function injectNewComments (client, item, currentDepth, sort) {
traverseNewComments(client, item, (newComments, item, depth) => {
function injectNewComments (client, item, currentDepth, sort, threadComment = false) {
traverseNewComments(client, item, (newComments, depth, itemId) => {
if (newComments.length > 0) {
const payload = prepareComments({ client, newComments })

// used to determine if by iterating through the new comments
// we are injecting topLevels (depth 0) or not
// 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, item.id, sort, payload)
itemUpdateQuery(client, itemId, sort, (data) => prepareComments(data, client, newComments))
} else {
commentUpdateFragment(client, item.id, payload)
commentUpdateFragment(client, itemId, (data) => prepareComments(data, client, newComments))
}
}
}, currentDepth)
}, threadComment, currentDepth)
}

// counts all new comments for an item and its children
function countAllNewComments (client, item, currentDepth = 1) {
let totalNComments = 0
// counts all new comments of an item
function countAllNewComments (client, item, thread = false, currentDepth = 1) {
let newCommentsCount = 0
let threadChildren = false

// count by traversing the comment structure
traverseNewComments(client, item, (newComments, depth) => {
newCommentsCount += countNComments(newComments)

// count by traversing all new comments and their children
traverseNewComments(client, item, (newComments) => {
totalNComments += newComments.length
for (const newComment of newComments) {
totalNComments += newComment.ncomments || 0
// 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, currentDepth)

return totalNComments
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 ({ item, sort, depth = 0 }) {
export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) {
const client = useApolloClient()
const ref = useRef(null)

// a thread is a top-level comment
const thread = item.path?.split('.').length === 2

// recurse through all new comments and their children
const newCommentsCount = item.newComments?.length > 0 ? countAllNewComments(client, item, depth) : 0
// if the item is a thread, we consider every existing child comment
const { newCommentsCount, threadChildren } = countAllNewComments(client, item, thread, depth)

// 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 have depth, we pass 0 to signify this
// other comments are injected from their depth
injectNewComments(client, item, depth, sort)
// 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, depth, sort, threadComment)
}, [client, sort, item, depth])

const text = !threadComment
? `${newCommentsCount} new comment${newCommentsCount > 1 ? 's' : ''}`
: 'show all new comments'

return (
<span
onClick={showNewComments}
className='fw-bold d-flex align-items-center gap-2 px-3 pointer'
style={{ visibility: newCommentsCount > 0 ? 'visible' : 'hidden' }}
>
{newCommentsCount > 1
? `${newCommentsCount} new comments`
: 'show new comment'}
<div className={styles.newCommentDot} />
</span>
<>
<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} />
)}
</>
)
}
Loading