Skip to content

Live updates to comment threads #2115

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 58 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
58 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
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
cfd4a0c
hotfix: limited fragment for recursive comment collection; protect fr…
Soxasora Jul 16, 2025
b8ec07e
docs: clarify ncomments updates
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
9d1ddc5
cleanup: remove unused item on CommentsHeader
Soxasora Jul 21, 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
18 changes: 18 additions & 0 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,24 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { rootId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
-- comments can be nested, so we need to get all comments that are descendants of the root
${whereClause(
'"Item".path <@ (SELECT path FROM "Item" WHERE id = $1)',
activeOrMine(me),
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)

return { comments }
}
},

Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
}
type BoostPositions {
Expand Down Expand Up @@ -148,6 +149,7 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
newComments(rootId: ID, after: Date): Comments!
path: String
position: Int
prior: Int
Expand Down
9 changes: 8 additions & 1 deletion components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import LinkToContext from './link-to-context'
import Boost from './boost-button'
import { gql, useApolloClient } from '@apollo/client'
import classNames from 'classnames'
import { ShowNewComments } from './show-new-comments'

function Parent ({ item, rootText }) {
const root = useRoot()
Expand Down Expand Up @@ -260,6 +261,11 @@ 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} />}
<div className='ms-auto'>
{item.newComments?.length > 0 && (
<ShowNewComments comments={item.comments.comments} newComments={item.newComments} itemId={item.id} />
)}
</div>
</Reply>}
{children}
<div className={styles.comments}>
Expand Down Expand Up @@ -304,8 +310,9 @@ function ReplyOnAnotherPage ({ item }) {
}

return (
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='d-block pb-2 fw-bold text-muted'>
<Link href={`/items/${rootId}?commentId=${item.id}`} as={`/items/${rootId}`} className='pb-2 fw-bold d-flex align-items-center gap-2 text-muted'>
{text}
{item.newComments?.length > 0 && <div className={styles.newCommentDot} />}
</Link>
)
}
Expand Down
23 changes: 23 additions & 0 deletions components/comment.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,27 @@

.comment:has(.comment) + .comment{
padding-top: .5rem;
}

.newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}

@keyframes pulse {
0% {
background-color: #FADA5E;
opacity: 0.7;
}
50% {
background-color: #F6911D;
opacity: 1;
}
100% {
background-color: #FADA5E;
opacity: 0.7;
}
}
9 changes: 8 additions & 1 deletion components/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
import { ShowNewComments } from './show-new-comments'

export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
Expand Down Expand Up @@ -64,9 +66,11 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm

export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props
}) {
const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)

const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])

Expand All @@ -88,6 +92,9 @@ export default function Comments ({
}}
/>
: null}
{newComments?.length > 0 && (
<ShowNewComments topLevel comments={comments} newComments={newComments} itemId={parentId} sort={router.query.sort} />
)}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} {...props} pin />
Expand Down
30 changes: 29 additions & 1 deletion components/header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,32 @@
padding-top: 1px;
background-color: var(--bs-body-bg);
z-index: 1000;
}
}

.newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}

.newCommentDot.paused {
background-color: var(--bs-grey-darkmode);
animation: none;
}

@keyframes pulse {
0% {
background-color: #FADA5E;
opacity: 0.7;
}
50% {
background-color: #F6911D;
opacity: 1;
}
100% {
background-color: #FADA5E;
opacity: 0.7;
}
}
2 changes: 2 additions & 0 deletions components/item-full.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
comments={item.comments.comments}
commentsCursor={item.comments.cursor}
fetchMoreComments={fetchMoreComments}
newComments={item.newComments}
lastCommentAt={item.lastCommentAt}
/>
</div>}
</CarouselProvider>
Expand Down
13 changes: 2 additions & 11 deletions components/reply.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'

export default forwardRef(function Reply ({
item,
Expand Down Expand Up @@ -82,17 +83,7 @@ export default forwardRef(function Reply ({
const ancestors = item.path.split('.')

// update all ancestors
ancestors.forEach(id => {
cache.modify({
id: `Item:${id}`,
fields: {
ncomments (existingNComments = 0) {
return existingNComments + 1
}
},
optimistic: true
})
})
updateAncestorsCommentCount(cache, ancestors, 1)

// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did
Expand Down
80 changes: 80 additions & 0 deletions components/show-new-comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useCallback, useMemo } from 'react'
import { useApolloClient } from '@apollo/client'
import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments'
import styles from './comment.module.css'
import { itemUpdateQuery, commentUpdateFragment } from './use-live-comments'
import { updateAncestorsCommentCount } from '@/lib/comments'

function prepareComments (client, newComments) {
return (data) => {
// newComments is an array of comment ids that allows us
// to read the latest newComments from the cache, guaranteeing that we're not reading stale data
const freshNewComments = newComments.map(id => {
const fragment = client.cache.readFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive'
})

if (!fragment) {
return null
}

return fragment
}).filter(Boolean)

// count the total number of new comments including its nested new comments
let totalNComments = freshNewComments.length
for (const comment of freshNewComments) {
totalNComments += (comment.ncomments || 0)
}

// update all ancestors, but not the item itself
const ancestors = data.path.split('.').slice(0, -1)
updateAncestorsCommentCount(client.cache, ancestors, totalNComments)

return {
...data,
comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] },
ncomments: data.ncomments + totalNComments,
newComments: []
}
}
}

// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field
export function ShowNewComments ({ topLevel = false, comments, newComments = [], itemId, sort }) {
const client = useApolloClient()

const dedupedNewComments = useMemo(() => {
const existingIds = new Set(comments.map(c => c.id))
return newComments.filter(id => !existingIds.has(id))
}, [newComments, comments])

const showNewComments = useCallback(() => {
// fetch the latest version of the comments from the cache by their ids
const payload = prepareComments(client, dedupedNewComments)

if (topLevel) {
itemUpdateQuery(client, itemId, sort, payload)
} else {
commentUpdateFragment(client, itemId, payload)
}
}, [client, itemId, dedupedNewComments, topLevel, sort])

if (dedupedNewComments.length === 0) {
return null
}

return (
<div
onClick={showNewComments}
className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`}
>
{dedupedNewComments.length > 1
? `${dedupedNewComments.length} new comments`
: 'show new comment'}
<div className={styles.newCommentDot} />
</div>
)
}
Loading