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

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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