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

Draft
wants to merge 47 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 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
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
3 changes: 3 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,8 @@ export default gql`
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
newComments(rootId: ID, after: Date): Comments!
injected: Boolean!
path: String
position: Int
prior: Int
Expand Down
34 changes: 31 additions & 3 deletions 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 @@ -139,7 +140,30 @@ export default function Comment ({
}
}, [item.id, cache, router.query.commentId])

const unsetOutline = () => {
ref.current.classList.add('outline-new-comment-unset')
// if the comment is injected, we need to change injected to false
// so that the next time the comment is rendered, it won't be outlined
if (item.injected) {
cache.writeFragment({
id: `Item:${item.id}`,
fragment: gql`
fragment CommentInjected on Item {
injected @client
}`,
data: {
injected: false
}
})
}
}

useEffect(() => {
// an injected new comment needs a different class to reliably outline every new comment
if (item.injected && me?.id !== item.user?.id) {
ref.current.classList.add('outline-new-injected-comment')
}

if (router.query.commentsViewedAt &&
me?.id !== item.user?.id &&
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
Expand All @@ -159,8 +183,8 @@ export default function Comment ({
return (
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
onMouseEnter={unsetOutline}
onTouchStart={unsetOutline}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
Expand Down Expand Up @@ -260,6 +284,9 @@ 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'>
<ShowNewComments comments={item.comments.comments} newComments={item.newComments} itemId={item.id} item={item} depth={depth} />
</div>
</Reply>}
{children}
<div className={styles.comments}>
Expand Down Expand Up @@ -304,8 +331,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
153 changes: 153 additions & 0 deletions components/show-new-comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useCallback, useMemo } from 'react'
import { useApolloClient } from '@apollo/client'
import styles from './comment.module.css'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
itemUpdateQuery,
commentUpdateFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount
} 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))
}

// 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) => {
// 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
}

// marking it as injected so that the new comment can be outlined
return { ...fragment, injected: true }
}).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)

// 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 freshNewComments array
// as such, the next visit will not outline other new comments that have not been injected yet
const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt)
const rootId = data.path.split('.')[0]
commentsViewedAfterComment(rootId, latestCommentCreatedAt)

// return the updated item with the new comments injected
return {
...data,
comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] },
ncomments: data.ncomments + totalNComments,
newComments: []
}
}
}

// recursively processes and displays all new comments for a thread
// handles comment injection at each level, respecting depth limits
function showAllNewCommentsRecursively (client, item, currentDepth = 1) {
// handle new comments at this item level
if (item.newComments && item.newComments.length > 0) {
const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments)

if (dedupedNewComments.length > 0) {
const payload = prepareComments(client, dedupedNewComments)
commentUpdateFragment(client, item.id, payload)
}
}

// read the updated item from the cache
// this is necessary because the item may have been updated by the time we get to the child comments
const updatedItem = client.cache.readFragment({
id: `Item:${item.id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive'
})

// recursively handle new comments in child comments
if (updatedItem.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) {
for (const childComment of updatedItem.comments.comments) {
showAllNewCommentsRecursively(client, childComment, currentDepth + 1)
}
}
}

// recursively collects all new comments from an item and its children
// by respecting the depth limit, we avoid collecting new comments to inject in places
// that are too deep in the tree
export function collectAllNewComments (item, currentDepth = 1) {
let allNewComments = [...(item.newComments || [])]

// dedupe against the existing comments at this level
if (item.comments?.comments) {
allNewComments = dedupeNewComments(allNewComments, item.comments.comments)

if (currentDepth < (COMMENT_DEPTH_LIMIT - 1)) {
for (const comment of item.comments.comments) {
allNewComments.push(...collectAllNewComments(comment, currentDepth + 1))
}
}
}

return allNewComments
}

// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field
export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newComments = [], depth = 1 }) {
const client = useApolloClient()
// if item is provided, we're showing all new comments for a thread,
// otherwise we're showing new comments for a comment
const isThread = !topLevel && item?.path.split('.').length === 2
const allNewComments = useMemo(() => {
if (isThread) {
return collectAllNewComments(item, depth)
}
return dedupeNewComments(newComments, comments)
}, [newComments, comments, item, depth])

const showNewComments = useCallback(() => {
if (topLevel) {
const payload = prepareComments(client, allNewComments)
itemUpdateQuery(client, itemId, sort, payload)
} else {
showAllNewCommentsRecursively(client, item, depth)
}
}, [client, itemId, allNewComments, topLevel, sort])

if (allNewComments.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`}
>
show {isThread ? 'all' : ''} new comments
<div className={styles.newCommentDot} />
</div>
)
}
Loading