From f115148594479c305448b699328684138aae86f3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 05:34:20 -0500 Subject: [PATCH 01/50] check new comments every 10 seconds --- api/resolvers/item.js | 26 ++++++++++ api/typeDefs/item.js | 6 +++ components/comment.js | 65 ++++++++++++++++++++++- components/comment.module.css | 23 +++++++++ components/comments.js | 10 +++- components/item-full.js | 23 +++++---- components/use-live-comments.js | 91 +++++++++++++++++++++++++++++++++ fragments/comments.js | 28 ++++++++++ fragments/items.js | 1 + lib/apollo.js | 6 +++ 10 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 components/use-live-comments.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 781409e09e..ee7d898687 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -727,6 +727,32 @@ export default { homeMaxBoost: homeAgg._max.boost || 0, subMaxBoost: subAgg?._max.boost || 0 } + }, + newComments: async (parent, { rootId, after }, { models, me }) => { + console.log('rootId', rootId) + console.log('after', after) + const item = await models.item.findUnique({ where: { id: Number(rootId) } }) + if (!item) { + throw new GqlInputError('item not found') + } + + 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 + WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) + AND "Item"."created_at" > $2 + ORDER BY "Item"."created_at" ASC` + }, Number(rootId), after) + + console.log('comments', comments) + + return { + comments + } } }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a40a99ae1b..95bbaf7888 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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): NewComments } type BoostPositions { @@ -96,6 +97,10 @@ export default gql` comments: [Item!]! } + type NewComments { + comments: [Item] + } + enum InvoiceActionState { PENDING PENDING_HELD @@ -148,6 +153,7 @@ export default gql` ncomments: Int! nDirectComments: Int! comments(sort: String, cursor: String): Comments! + newComments(rootId: ID, after: Date): NewComments path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 73e64d0c33..d0e04a4666 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,6 +28,8 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' +import { ITEM_FULL } from '@/fragments/items' +import { COMMENT_WITH_NEW } from '@/fragments/comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -111,9 +113,12 @@ export default function Comment ({ const router = useRouter() const root = useRoot() const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) - const { cache } = useApolloClient() + useEffect(() => { + console.log('item', item) + }, [item]) + useEffect(() => { const comment = cache.readFragment({ id: `Item:${router.query.commentId}`, @@ -275,6 +280,9 @@ export default function Comment ({ : null} {/* TODO: add link to more comments if they're limited */} + {item.newComments?.length > 0 && ( + + )} ) )} @@ -338,3 +346,58 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } + +export function ShowNewComments ({ newComments = [], itemId, updateQuery = false }) { + const client = useApolloClient() + + const showNewComments = () => { + if (updateQuery) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: itemId } + }, (data) => { + if (!data) return data + const { item } = data + return { + item: { + ...item, + comments: dedupeComments(item, newComments), + newComments: [] + } + } + }) + } else { + client.cache.updateFragment({ + id: `Item:${itemId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + return { + ...data, + comments: dedupeComments(data, newComments), + newComments: [] + } + }) + } + } + + const dedupeComments = (item) => { + const existingComments = item?.comments?.comments || [] + const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) + const updatedComments = [...filtered, ...existingComments] + return updatedComments + } + + return ( + +
+
+ {newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'} +
+
+
+ + ) +} diff --git a/components/comment.module.css b/components/comment.module.css index 6f24ab6157..215993d64f 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -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; + } } \ No newline at end of file diff --git a/components/comments.js b/components/comments.js index cb5d864167..d0af8abfae 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,5 +1,5 @@ import { Fragment, useMemo } from 'react' -import Comment, { CommentSkeleton } from './comment' +import Comment, { CommentSkeleton, ShowNewComments } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' import Navbar from 'react-bootstrap/Navbar' @@ -8,6 +8,7 @@ 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' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -64,9 +65,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() + // update item.newComments in cache + useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -102,6 +105,9 @@ export default function Comments ({ count={comments?.length} Skeleton={CommentsSkeleton} />} + {newComments?.length > 0 && ( + + )} ) } diff --git a/components/item-full.js b/components/item-full.js index 72c60b9c63..02621981b8 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -182,17 +182,18 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props ? : }
)} - {item.comments && -
- -
} +
+ +
diff --git a/components/use-live-comments.js b/components/use-live-comments.js new file mode 100644 index 0000000000..9d6fa94ed1 --- /dev/null +++ b/components/use-live-comments.js @@ -0,0 +1,91 @@ +import { useQuery, useApolloClient } from '@apollo/client' +import { SSR } from '../lib/constants' +import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' +import { ITEM_FULL } from '../fragments/items' +import { useState } from 'react' + +export function useLiveComments (rootId, after) { + const client = useApolloClient() + const [lastChecked, setLastChecked] = useState(after) + const { data, error } = useQuery(GET_NEW_COMMENTS, SSR + ? {} + : { + pollInterval: 10000, + variables: { rootId, after: lastChecked } + }) + + console.log('error', error) + + if (data && data.newComments) { + saveNewComments(client, rootId, data.newComments.comments) + const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + if (latestCommentCreatedAt) { + setLastChecked(latestCommentCreatedAt) + } + } + + return null +} + +export function saveNewComments (client, rootId, newComments) { + console.log('newComments', newComments) + for (const comment of newComments) { + console.log('comment', comment) + const parentId = comment.parentId + if (Number(parentId) === Number(rootId)) { + console.log('parentId', parentId) + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: rootId } + }, (data) => { + console.log('data', data) + if (!data) return data + console.log('dataTopLevel', data) + + const { item } = data + + return { item: dedupeComment(item, comment) } + }) + } else { + console.log('not top level', parentId) + client.cache.updateFragment({ + id: `Item:${parentId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + console.log('data', data) + + return dedupeComment(data, comment) + }) + console.log('fragment', client.cache.readFragment({ + id: `Item:${parentId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + })) + } + } +} + +function dedupeComment (item, newComment) { + const existingNewComments = item.newComments || [] + const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) + const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] + console.log(item) + const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) + const final = { ...item, newComments: filteredComments } + console.log('final', final) + return final +} + +function getLastCommentCreatedAt (comments) { + if (comments.length === 0) return null + let latest = comments[0].createdAt + for (const comment of comments) { + if (comment.createdAt > latest) { + latest = comment.createdAt + } + } + return latest +} diff --git a/fragments/comments.js b/fragments/comments.js index 2fd28d0f18..8dab8904d9 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -47,6 +47,7 @@ export const COMMENT_FIELDS = gql` otsHash ncomments nDirectComments + newComments @client imgproxyUrls rel apiKey @@ -116,3 +117,30 @@ export const COMMENTS = gql` } } }` + +export const COMMENT_WITH_NEW = gql` + ${COMMENT_FIELDS} + ${COMMENTS} + + fragment CommentWithNew on Item { + ...CommentFields + comments { + comments { + ...CommentsRecursive + } + } + newComments @client + } +` + +export const GET_NEW_COMMENTS = gql` + ${COMMENT_FIELDS} + + query GetNewComments($rootId: ID, $after: Date) { + newComments(rootId: $rootId, after: $after) { + comments { + ...CommentFields + } + } + } +` diff --git a/fragments/items.js b/fragments/items.js index 151587a202..95eed0421a 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -59,6 +59,7 @@ export const ITEM_FIELDS = gql` bio ncomments nDirectComments + newComments @client commentSats commentCredits lastCommentAt diff --git a/lib/apollo.js b/lib/apollo.js index 3739ba3fd8..a7dcdb01b7 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -313,6 +313,12 @@ function getClient (uri) { } } }, + newComments: { + read (newComments) { + console.log('newComments', newComments) + return newComments || [] + } + }, meAnonSats: { read (existingAmount, { readField }) { if (SSR) return null From c813e59f9d88aea99e89f670ff923f311ffc1773 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 09:27:57 -0500 Subject: [PATCH 02/50] enhance: clear newComments on child comments when we show a topLevel new comment; cleanup: resolvers, logs --- api/resolvers/item.js | 13 +-------- components/comment.js | 48 ++++++++++++++++++++++++--------- components/comments.js | 6 ++--- components/use-live-comments.js | 10 ++----- lib/apollo.js | 1 - 5 files changed, 41 insertions(+), 37 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ee7d898687..35875993c5 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -729,13 +729,6 @@ export default { } }, newComments: async (parent, { rootId, after }, { models, me }) => { - console.log('rootId', rootId) - console.log('after', after) - const item = await models.item.findUnique({ where: { id: Number(rootId) } }) - if (!item) { - throw new GqlInputError('item not found') - } - const comments = await itemQueryWithMeta({ me, models, @@ -748,11 +741,7 @@ export default { ORDER BY "Item"."created_at" ASC` }, Number(rootId), after) - console.log('comments', comments) - - return { - comments - } + return { comments } } }, diff --git a/components/comment.js b/components/comment.js index d0e04a4666..427feadb58 100644 --- a/components/comment.js +++ b/components/comment.js @@ -115,10 +115,6 @@ export default function Comment ({ const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) const { cache } = useApolloClient() - useEffect(() => { - console.log('item', item) - }, [item]) - useEffect(() => { const comment = cache.readFragment({ id: `Item:${router.query.commentId}`, @@ -347,24 +343,45 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } -export function ShowNewComments ({ newComments = [], itemId, updateQuery = false }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { const client = useApolloClient() + const [loading, setLoading] = useState(false) const showNewComments = () => { - if (updateQuery) { + setLoading(true) + if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, variables: { id: itemId } }, (data) => { if (!data) return data const { item } = data - return { - item: { - ...item, - comments: dedupeComments(item, newComments), - newComments: [] + + const updatedComments = { + ...item.comments, + comments: dedupeComments(item, newComments) + } + // first merge in new comments, then clear newComments for the item + const mergedItem = { + ...item, + comments: updatedComments, + newComments: [] + } + // then recursively clear newComments for all nested comments + const clearAllNew = (comment) => { + return { + ...comment, + newComments: [], + comments: comment.comments + ? { + ...comment.comments, + comments: comment.comments.comments.map(child => clearAllNew(child)) + } + : comment.comments } } + const finalItem = clearAllNew(mergedItem) + return { item: finalItem } }) } else { client.cache.updateFragment({ @@ -381,18 +398,23 @@ export function ShowNewComments ({ newComments = [], itemId, updateQuery = false } }) } + setLoading(false) } - const dedupeComments = (item) => { + const dedupeComments = (item, newComments) => { const existingComments = item?.comments?.comments || [] const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) const updatedComments = [...filtered, ...existingComments] return updatedComments } + if (loading && Skeleton) { + return + } + return ( -
+
{newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'}
diff --git a/components/comments.js b/components/comments.js index d0af8abfae..f77581d1e2 100644 --- a/components/comments.js +++ b/components/comments.js @@ -91,6 +91,9 @@ export default function Comments ({ }} /> : null} + {newComments?.length > 0 && ( + + )} {pins.map(item => ( @@ -105,9 +108,6 @@ export default function Comments ({ count={comments?.length} Skeleton={CommentsSkeleton} />} - {newComments?.length > 0 && ( - - )} ) } diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 9d6fa94ed1..44c46877bc 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,15 +7,13 @@ import { useState } from 'react' export function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) - const { data, error } = useQuery(GET_NEW_COMMENTS, SSR + const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { pollInterval: 10000, variables: { rootId, after: lastChecked } }) - console.log('error', error) - if (data && data.newComments) { saveNewComments(client, rootId, data.newComments.comments) const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) @@ -28,7 +26,6 @@ export function useLiveComments (rootId, after) { } export function saveNewComments (client, rootId, newComments) { - console.log('newComments', newComments) for (const comment of newComments) { console.log('comment', comment) const parentId = comment.parentId @@ -72,11 +69,8 @@ function dedupeComment (item, newComment) { const existingNewComments = item.newComments || [] const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] - console.log(item) const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) - const final = { ...item, newComments: filteredComments } - console.log('final', final) - return final + return { ...item, newComments: filteredComments } } function getLastCommentCreatedAt (comments) { diff --git a/lib/apollo.js b/lib/apollo.js index a7dcdb01b7..dc7b8127bd 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -315,7 +315,6 @@ function getClient (uri) { }, newComments: { read (newComments) { - console.log('newComments', newComments) return newComments || [] } }, From c41a4689cefb1873af7f6217599aebb52e0399d6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 14:04:34 -0500 Subject: [PATCH 03/50] handle comments of comments, new structure to clear newComments on childs --- api/typeDefs/item.js | 8 +--- components/comment.js | 47 ++++++++++--------- ...{use-live-comments.js => comments-live.js} | 0 components/comments.js | 2 +- fragments/comments.js | 5 ++ 5 files changed, 33 insertions(+), 29 deletions(-) rename components/{use-live-comments.js => comments-live.js} (100%) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 95bbaf7888..4d19c74401 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,7 +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): NewComments + newComments(rootId: ID, after: Date): Comments! } type BoostPositions { @@ -97,10 +97,6 @@ export default gql` comments: [Item!]! } - type NewComments { - comments: [Item] - } - enum InvoiceActionState { PENDING PENDING_HELD @@ -153,7 +149,7 @@ export default gql` ncomments: Int! nDirectComments: Int! comments(sort: String, cursor: String): Comments! - newComments(rootId: ID, after: Date): NewComments + newComments(rootId: ID, after: Date): Comments! path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 427feadb58..ffc386a22b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -359,7 +359,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S const updatedComments = { ...item.comments, - comments: dedupeComments(item, newComments) + comments: dedupeComments(item.comments, newComments) } // first merge in new comments, then clear newComments for the item const mergedItem = { @@ -368,44 +368,47 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S newComments: [] } // then recursively clear newComments for all nested comments - const clearAllNew = (comment) => { - return { - ...comment, - newComments: [], - comments: comment.comments - ? { - ...comment.comments, - comments: comment.comments.comments.map(child => clearAllNew(child)) - } - : comment.comments - } - } - const finalItem = clearAllNew(mergedItem) + const finalItem = clearNewComments(mergedItem) return { item: finalItem } }) } else { - client.cache.updateFragment({ + const updatedData = client.cache.updateFragment({ id: `Item:${itemId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data + console.log('previous data', data) + return { ...data, - comments: dedupeComments(data, newComments), + comments: dedupeComments(data.comments, newComments), newComments: [] } }) + console.log('new data', updatedData) } setLoading(false) } - const dedupeComments = (item, newComments) => { - const existingComments = item?.comments?.comments || [] - const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) - const updatedComments = [...filtered, ...existingComments] - return updatedComments + const dedupeComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments?.map(c => c.id)) + const filteredNew = newComments.filter(c => !existingIds.has(c.id)) + return [...filteredNew, ...existingComments.comments] + } + + const clearNewComments = comment => { + return { + ...comment, + newComments: [], + comments: comment?.comments?.comments + ? { + ...comment.comments, + comments: comment.comments.comments.map(clearNewComments) + } + : comment.comments + } } if (loading && Skeleton) { @@ -414,7 +417,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S return ( -
+
{newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'}
diff --git a/components/use-live-comments.js b/components/comments-live.js similarity index 100% rename from components/use-live-comments.js rename to components/comments-live.js diff --git a/components/comments.js b/components/comments.js index f77581d1e2..024b7c5eac 100644 --- a/components/comments.js +++ b/components/comments.js @@ -8,7 +8,7 @@ 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 { useLiveComments } from './comments-live' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() diff --git a/fragments/comments.js b/fragments/comments.js index 8dab8904d9..cbaddc5a75 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -140,6 +140,11 @@ export const GET_NEW_COMMENTS = gql` newComments(rootId: $rootId, after: $after) { comments { ...CommentFields + comments { + comments { + ...CommentFields + } + } } } } From 2a1e9e99abf508c1c30cad3e2b216e0799c3b07a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 04:01:01 -0500 Subject: [PATCH 04/50] use original recursive comments data structure --- components/comment.js | 39 +++++++-------------------------------- fragments/comments.js | 11 +++-------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/components/comment.js b/components/comment.js index ffc386a22b..24a0ea6bca 100644 --- a/components/comment.js +++ b/components/comment.js @@ -345,10 +345,8 @@ export function CommentSkeleton ({ skeletonChildren }) { export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { const client = useApolloClient() - const [loading, setLoading] = useState(false) const showNewComments = () => { - setLoading(true) if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, @@ -357,19 +355,14 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S if (!data) return data const { item } = data - const updatedComments = { - ...item.comments, - comments: dedupeComments(item.comments, newComments) - } - // first merge in new comments, then clear newComments for the item - const mergedItem = { - ...item, - comments: updatedComments, - newComments: [] + console.log('item', item) + return { + item: { + ...item, + comments: dedupeComments(item.comments, newComments), + newComments: [] + } } - // then recursively clear newComments for all nested comments - const finalItem = clearNewComments(mergedItem) - return { item: finalItem } }) } else { const updatedData = client.cache.updateFragment({ @@ -389,7 +382,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S }) console.log('new data', updatedData) } - setLoading(false) } const dedupeComments = (existingComments = [], newComments = []) => { @@ -398,23 +390,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S return [...filteredNew, ...existingComments.comments] } - const clearNewComments = comment => { - return { - ...comment, - newComments: [], - comments: comment?.comments?.comments - ? { - ...comment.comments, - comments: comment.comments.comments.map(clearNewComments) - } - : comment.comments - } - } - - if (loading && Skeleton) { - return - } - return (
diff --git a/fragments/comments.js b/fragments/comments.js index cbaddc5a75..f7e325c4cd 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -134,17 +134,12 @@ export const COMMENT_WITH_NEW = gql` ` export const GET_NEW_COMMENTS = gql` - ${COMMENT_FIELDS} - + ${COMMENTS} + query GetNewComments($rootId: ID, $after: Date) { newComments(rootId: $rootId, after: $after) { comments { - ...CommentFields - comments { - comments { - ...CommentFields - } - } + ...CommentsRecursive } } } From b064c63e3b349485f98db4a5f2f3b01fe48d5af4 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 04:45:11 -0500 Subject: [PATCH 05/50] correct comment structure after deduplication --- components/comment.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/comment.js b/components/comment.js index 24a0ea6bca..500cd7d6ec 100644 --- a/components/comment.js +++ b/components/comment.js @@ -113,6 +113,7 @@ export default function Comment ({ const router = useRouter() const root = useRoot() const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) + const { cache } = useApolloClient() useEffect(() => { @@ -387,7 +388,10 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S const dedupeComments = (existingComments = [], newComments = []) => { const existingIds = new Set(existingComments.comments?.map(c => c.id)) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return [...filteredNew, ...existingComments.comments] + return { + ...existingComments, + comments: [...filteredNew, ...(existingComments.comments || [])] + } } return ( From e0542ce529ce618507f218a5fce2ef2d5ab82e1d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 12:34:06 -0500 Subject: [PATCH 06/50] faster newComments query deduplication, don't need to know how many comments are there --- components/comment.js | 2 +- components/comments-live.js | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/components/comment.js b/components/comment.js index 500cd7d6ec..41bb94fb9d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -398,7 +398,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S
- {newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'} + load new comments
diff --git a/components/comments-live.js b/components/comments-live.js index 44c46877bc..8c1fdadd8f 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -66,11 +66,24 @@ export function saveNewComments (client, rootId, newComments) { } function dedupeComment (item, newComment) { + const existingCommentIds = new Set( + (item.comments?.comments || []).map(c => c.id) + ) const existingNewComments = item.newComments || [] - const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) - const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] - const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) - return { ...item, newComments: filteredComments } + + // is the incoming new comment already in item's new comments? + if (existingNewComments.some(c => c.id === newComment.id)) { + return item + } + + // if the incoming new comment is not in item's new comments, add it + // sanity check: and if somehow the incoming new comment is in + // item's new comments, remove it + const updatedNewComments = !existingCommentIds.has(newComment.id) + ? [...existingNewComments, newComment] + : existingNewComments.filter(c => c.id !== newComment.id) + + return { ...item, newComments: updatedNewComments } } function getLastCommentCreatedAt (comments) { From e3076743683f3ad1c0034782734d2b45e8cf4f93 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 12:41:31 -0500 Subject: [PATCH 07/50] cleanup: comments on newComments fetches and dedupes --- components/comment.js | 8 ++------ components/comments-live.js | 29 ++++++++++------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/components/comment.js b/components/comment.js index 41bb94fb9d..8f17e06d6a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -344,7 +344,7 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } -export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { const client = useApolloClient() const showNewComments = () => { @@ -356,7 +356,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S if (!data) return data const { item } = data - console.log('item', item) return { item: { ...item, @@ -366,22 +365,19 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S } }) } else { - const updatedData = client.cache.updateFragment({ + client.cache.updateFragment({ id: `Item:${itemId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data - console.log('previous data', data) - return { ...data, comments: dedupeComments(data.comments, newComments), newComments: [] } }) - console.log('new data', updatedData) } } diff --git a/components/comments-live.js b/components/comments-live.js index 8c1fdadd8f..5f3311dac0 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -27,45 +27,36 @@ export function useLiveComments (rootId, after) { export function saveNewComments (client, rootId, newComments) { for (const comment of newComments) { - console.log('comment', comment) - const parentId = comment.parentId - if (Number(parentId) === Number(rootId)) { - console.log('parentId', parentId) + const { parentId } = comment + const topLevel = Number(parentId) === Number(rootId) + + // if the comment is a top level comment, update the item + if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, variables: { id: rootId } }, (data) => { - console.log('data', data) if (!data) return data - console.log('dataTopLevel', data) - - const { item } = data - - return { item: dedupeComment(item, comment) } + // we return the entire item, not just the newComments + return { item: dedupeComment(data?.item, comment) } }) } else { - console.log('not top level', parentId) + // if the comment is a reply, update the parent comment client.cache.updateFragment({ id: `Item:${parentId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data - - console.log('data', data) - + // here we return the parent comment with the new comment added return dedupeComment(data, comment) }) - console.log('fragment', client.cache.readFragment({ - id: `Item:${parentId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - })) } } } function dedupeComment (item, newComment) { + // get the existing comment ids for faster lookup const existingCommentIds = new Set( (item.comments?.comments || []).map(c => c.id) ) From beb10af5aa8e581b1460b00be2225b0f00f2d960 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 22 Apr 2025 07:35:51 -0500 Subject: [PATCH 08/50] cleanup, use correct function declarations --- components/comments-live.js | 4 ++-- components/comments.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index 5f3311dac0..a7321059dd 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -4,7 +4,7 @@ import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useState } from 'react' -export function useLiveComments (rootId, after) { +export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const { data } = useQuery(GET_NEW_COMMENTS, SSR @@ -25,7 +25,7 @@ export function useLiveComments (rootId, after) { return null } -export function saveNewComments (client, rootId, newComments) { +function saveNewComments (client, rootId, newComments) { for (const comment of newComments) { const { parentId } = comment const topLevel = Number(parentId) === Number(rootId) diff --git a/components/comments.js b/components/comments.js index 024b7c5eac..23f7ce8d87 100644 --- a/components/comments.js +++ b/components/comments.js @@ -8,7 +8,7 @@ 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 './comments-live' +import useLiveComments from './comments-live' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -69,6 +69,7 @@ export default function Comments ({ }) { const router = useRouter() // update item.newComments in cache + // TODO use UserActivation to poll only when the user is actively on page useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) From 4add86e1c8a5bc4916009df299b7b4bc76181d4d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 05:01:57 -0500 Subject: [PATCH 09/50] stop polling after 30 minutes, pause polling if user is not on the page --- components/comments-live.js | 29 ++++++++++++++++++++++++++--- components/comments.js | 11 ++++++++--- components/header.module.css | 24 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index a7321059dd..aac7fdfd89 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -2,15 +2,38 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useState } from 'react' +import { useEffect, useState } from 'react' export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) + const [polling, setPolling] = useState(true) + const engagedAt = new Date() + + useEffect(() => { + if (engagedAt) { + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 60 * 30) { + document.addEventListener('visibilitychange', () => { + const isActive = document.visibilityState === 'visible' + setPolling(isActive) + }) + + return () => { + document.removeEventListener('visibilitychange', () => {}) + } + } else { + setPolling(false) + } + } + }, []) + const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: 10000, + pollInterval: polling ? 10000 : null, variables: { rootId, after: lastChecked } }) @@ -22,7 +45,7 @@ export default function useLiveComments (rootId, after) { } } - return null + return { polling } } function saveNewComments (client, rootId, newComments) { diff --git a/components/comments.js b/components/comments.js index 23f7ce8d87..902763c910 100644 --- a/components/comments.js +++ b/components/comments.js @@ -10,7 +10,7 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -29,6 +29,11 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} + {livePolling && ( + +
+ + )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -79,7 +84,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} livePolling={livePolling} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ diff --git a/components/header.module.css b/components/header.module.css index 1134e8480f..cc3027342b 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -109,4 +109,28 @@ 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; +} + +@keyframes pulse { + 0% { + background-color: #FADA5E; + opacity: 0.7; + } + 50% { + background-color: #F6911D; + opacity: 1; + } + 100% { + background-color: #FADA5E; + opacity: 0.7; + } } \ No newline at end of file From 871684901968b62a07cb4b5a1b1f51db052879fb Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 05:12:45 -0500 Subject: [PATCH 10/50] ActionTooltip indicating that the user is in a live comment section --- components/comments.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/comments.js b/components/comments.js index 902763c910..e922bd1684 100644 --- a/components/comments.js +++ b/components/comments.js @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' +import ActionTooltip from './action-tooltip' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() @@ -31,7 +32,9 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {livePolling && ( -
+ +
+ )}
From 8f19b72d5660728283a66ba8c90507d795b40dd7 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 14:33:51 +0200 Subject: [PATCH 11/50] handleVisibilityChange to control polling by visibility --- components/comments-live.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index aac7fdfd89..f8d429f8d3 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -8,27 +8,28 @@ export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const [polling, setPolling] = useState(true) - const engagedAt = new Date() + const [engagedAt] = useState(new Date()) useEffect(() => { - if (engagedAt) { - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { - document.addEventListener('visibilitychange', () => { - const isActive = document.visibilityState === 'visible' - setPolling(isActive) - }) + const handleVisibilityChange = () => { + const isActive = document.visibilityState === 'visible' + setPolling(isActive) + } + + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() - return () => { - document.removeEventListener('visibilitychange', () => {}) - } - } else { - setPolling(false) + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 60 * 30) { + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) } + } else { + setPolling(false) } - }, []) + }, [engagedAt]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} From 7e06381f69876dc5a8b8c038fbd75606c5cd9eaf Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 15:35:33 +0200 Subject: [PATCH 12/50] paused polling styling, check activity on 1 minute intervals and visibility change, light cleanup --- components/comments-live.js | 29 +++++++++++++++++------------ components/comments.js | 26 +++++++++++++++++--------- components/header.module.css | 8 ++++++-- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index f8d429f8d3..82a5fe0242 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -11,23 +11,28 @@ export default function useLiveComments (rootId, after) { const [engagedAt] = useState(new Date()) useEffect(() => { - const handleVisibilityChange = () => { + const checkActivity = () => { + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() const isActive = document.visibilityState === 'visible' - setPolling(isActive) + + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 30 * 60) { + setPolling(isActive) + } else { + setPolling(false) + } } - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() + // check activity every minute + const interval = setInterval(checkActivity, 1000 * 60) - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { - document.addEventListener('visibilitychange', handleVisibilityChange) + // check activity also on visibility change + document.addEventListener('visibilitychange', checkActivity) - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - } else { - setPolling(false) + return () => { + document.removeEventListener('visibilitychange', checkActivity) + clearInterval(interval) } }, [engagedAt]) diff --git a/components/comments.js b/components/comments.js index e922bd1684..cdf44b4fce 100644 --- a/components/comments.js +++ b/components/comments.js @@ -10,6 +10,7 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' import ActionTooltip from './action-tooltip' +import classNames from 'classnames' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() @@ -30,13 +31,21 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {livePolling && ( - - -
- - - )} + {livePolling + ? ( + + +
+ + + ) + : ( + + +
+ + + )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -101,7 +109,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( diff --git a/components/header.module.css b/components/header.module.css index cc3027342b..33cf610325 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -111,7 +111,6 @@ z-index: 1000; } - .newCommentDot { width: 10px; height: 10px; @@ -120,6 +119,11 @@ animation: pulse 2s infinite; } +.newCommentDot.paused { + background-color: var(--bs-grey-darkmode); + animation: none; +} + @keyframes pulse { 0% { background-color: #FADA5E; @@ -133,4 +137,4 @@ background-color: #FADA5E; opacity: 0.7; } -} \ No newline at end of file +} From 553592e07102595b1b3b030efd181a4ce9e53e87 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 22:47:56 +0200 Subject: [PATCH 13/50] user can resume polling without refreshing the page --- components/comments-live.js | 14 ++++++++++---- components/comments.js | 14 +++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index 82a5fe0242..020e4011c1 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -8,7 +8,14 @@ export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const [polling, setPolling] = useState(true) - const [engagedAt] = useState(new Date()) + const [engagedAt, setEngagedAt] = useState(new Date()) + + // reset engagedAt when polling is toggled + useEffect(() => { + if (polling) { + setEngagedAt(new Date()) + } + }, [polling]) useEffect(() => { const checkActivity = () => { @@ -17,7 +24,7 @@ export default function useLiveComments (rootId, after) { const isActive = document.visibilityState === 'visible' // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 30 * 60) { + if (timeSinceEngaged < 1000 * 60 * 30) { setPolling(isActive) } else { setPolling(false) @@ -26,7 +33,6 @@ export default function useLiveComments (rootId, after) { // check activity every minute const interval = setInterval(checkActivity, 1000 * 60) - // check activity also on visibility change document.addEventListener('visibilitychange', checkActivity) @@ -51,7 +57,7 @@ export default function useLiveComments (rootId, after) { } } - return { polling } + return { polling, setPolling } } function saveNewComments (client, rootId, newComments) { diff --git a/components/comments.js b/components/comments.js index cdf44b4fce..a5696c3ec1 100644 --- a/components/comments.js +++ b/components/comments.js @@ -12,7 +12,7 @@ import useLiveComments from './comments-live' import ActionTooltip from './action-tooltip' import classNames from 'classnames' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling, setLivePolling }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -41,8 +41,12 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm ) : ( - -
+ +
setLivePolling(true)} + style={{ cursor: 'pointer' }} + /> )} @@ -86,7 +90,7 @@ export default function Comments ({ }) { const router = useRouter() // update item.newComments in cache - const { polling: livePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) + const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -95,7 +99,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} livePolling={livePolling} setLivePolling={setLivePolling} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ From e9b7b15a2b9d3a93516d70d6f0fc55ce31bf1b64 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 24 Jun 2025 12:25:52 +0200 Subject: [PATCH 14/50] better naming, straightforward dedupeComment on newComment arrival --- components/comment.js | 2 +- components/comments-live.js | 41 +++++++++++++++---------------------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/components/comment.js b/components/comment.js index 8f17e06d6a..d638088a5a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -386,7 +386,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] + comments: [...(existingComments.comments || []), ...filteredNew] } } diff --git a/components/comments-live.js b/components/comments-live.js index 020e4011c1..e665750793 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -49,20 +49,22 @@ export default function useLiveComments (rootId, after) { variables: { rootId, after: lastChecked } }) - if (data && data.newComments) { - saveNewComments(client, rootId, data.newComments.comments) - const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) - if (latestCommentCreatedAt) { - setLastChecked(latestCommentCreatedAt) + useEffect(() => { + if (data && data.newComments) { + saveNewComments(client, rootId, data.newComments.comments) + const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + if (latestCommentCreatedAt) { + setLastChecked(latestCommentCreatedAt) + } } - } + }, [data, client, rootId]) return { polling, setPolling } } function saveNewComments (client, rootId, newComments) { - for (const comment of newComments) { - const { parentId } = comment + for (const newComment of newComments) { + const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item @@ -73,7 +75,7 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // we return the entire item, not just the newComments - return { item: dedupeComment(data?.item, comment) } + return { item: dedupeComment(data?.item, newComment) } }) } else { // if the comment is a reply, update the parent comment @@ -84,32 +86,21 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // here we return the parent comment with the new comment added - return dedupeComment(data, comment) + return dedupeComment(data, newComment) }) } } } function dedupeComment (item, newComment) { - // get the existing comment ids for faster lookup - const existingCommentIds = new Set( - (item.comments?.comments || []).map(c => c.id) - ) const existingNewComments = item.newComments || [] + const existingComments = item.comments?.comments || [] - // is the incoming new comment already in item's new comments? - if (existingNewComments.some(c => c.id === newComment.id)) { + // is the incoming new comment already in item's new comments or existing comments? + if (existingNewComments.some(c => c.id === newComment.id) || existingComments.some(c => c.id === newComment.id)) { return item } - - // if the incoming new comment is not in item's new comments, add it - // sanity check: and if somehow the incoming new comment is in - // item's new comments, remove it - const updatedNewComments = !existingCommentIds.has(newComment.id) - ? [...existingNewComments, newComment] - : existingNewComments.filter(c => c.id !== newComment.id) - - return { ...item, newComments: updatedNewComments } + return { ...item, newComments: [...existingNewComments, newComment] } } function getLastCommentCreatedAt (comments) { From 65b61abee8cfef07b8b22807d2229f01eb2a9270 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 13:00:48 +0200 Subject: [PATCH 15/50] cleanup: better naming, get latest comment creation, correct order of comment injection --- components/comment.js | 2 +- components/comments-live.js | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/components/comment.js b/components/comment.js index d638088a5a..8f17e06d6a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -386,7 +386,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, - comments: [...(existingComments.comments || []), ...filteredNew] + comments: [...filteredNew, ...(existingComments.comments || [])] } } diff --git a/components/comments-live.js b/components/comments-live.js index e665750793..0532f2080f 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react' export default function useLiveComments (rootId, after) { const client = useApolloClient() - const [lastChecked, setLastChecked] = useState(after) + const [latest, setLatest] = useState(after) const [polling, setPolling] = useState(true) const [engagedAt, setEngagedAt] = useState(new Date()) @@ -46,18 +46,19 @@ export default function useLiveComments (rootId, after) { ? {} : { pollInterval: polling ? 10000 : null, - variables: { rootId, after: lastChecked } + variables: { rootId, after: latest } }) useEffect(() => { if (data && data.newComments) { saveNewComments(client, rootId, data.newComments.comments) - const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + // check new comments created after the latest new comment + const latestCommentCreatedAt = getLatestCommentCreatedAt(data.newComments.comments, latest) if (latestCommentCreatedAt) { - setLastChecked(latestCommentCreatedAt) + setLatest(latestCommentCreatedAt) } } - }, [data, client, rootId]) + }, [data, client, rootId, latest]) return { polling, setPolling } } @@ -103,13 +104,14 @@ function dedupeComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment] } } -function getLastCommentCreatedAt (comments) { +function getLatestCommentCreatedAt (comments, latest) { if (comments.length === 0) return null - let latest = comments[0].createdAt + for (const comment of comments) { if (comment.createdAt > latest) { latest = comment.createdAt } } + return latest } From 8126858e37e36f0fba4dacabd42961925f198af6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 13:39:02 +0200 Subject: [PATCH 16/50] cleanup: refactor live comments related functions to use-live-comments.js --- components/comment.js | 61 +------------------ components/comments.js | 4 +- ...{comments-live.js => use-live-comments.js} | 59 ++++++++++++++++++ 3 files changed, 62 insertions(+), 62 deletions(-) rename components/{comments-live.js => use-live-comments.js} (69%) diff --git a/components/comment.js b/components/comment.js index 8f17e06d6a..a7667ee69d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,8 +28,7 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' -import { ITEM_FULL } from '@/fragments/items' -import { COMMENT_WITH_NEW } from '@/fragments/comments' +import { ShowNewComments } from './use-live-comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -343,61 +342,3 @@ export function CommentSkeleton ({ skeletonChildren }) {
) } - -export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { - const client = useApolloClient() - - const showNewComments = () => { - if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: itemId } - }, (data) => { - if (!data) return data - const { item } = data - - return { - item: { - ...item, - comments: dedupeComments(item.comments, newComments), - newComments: [] - } - } - }) - } else { - client.cache.updateFragment({ - id: `Item:${itemId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - - return { - ...data, - comments: dedupeComments(data.comments, newComments), - newComments: [] - } - }) - } - } - - const dedupeComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments?.map(c => c.id)) - const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return { - ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] - } - } - - return ( - -
-
- load new comments -
-
-
- - ) -} diff --git a/components/comments.js b/components/comments.js index a5696c3ec1..54eace4ca8 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,5 +1,5 @@ import { Fragment, useMemo } from 'react' -import Comment, { CommentSkeleton, ShowNewComments } from './comment' +import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' import Navbar from 'react-bootstrap/Navbar' @@ -8,7 +8,7 @@ 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 './comments-live' +import useLiveComments, { ShowNewComments } from './use-live-comments' import ActionTooltip from './action-tooltip' import classNames from 'classnames' diff --git a/components/comments-live.js b/components/use-live-comments.js similarity index 69% rename from components/comments-live.js rename to components/use-live-comments.js index 0532f2080f..7300d64e37 100644 --- a/components/comments-live.js +++ b/components/use-live-comments.js @@ -3,6 +3,7 @@ import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useEffect, useState } from 'react' +import styles from './comments.module.css' export default function useLiveComments (rootId, after) { const client = useApolloClient() @@ -115,3 +116,61 @@ function getLatestCommentCreatedAt (comments, latest) { return latest } + +export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { + const client = useApolloClient() + + const showNewComments = () => { + if (topLevel) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: itemId } + }, (data) => { + if (!data) return data + const { item } = data + + return { + item: { + ...item, + comments: dedupeComments(item.comments, newComments), + newComments: [] + } + } + }) + } else { + client.cache.updateFragment({ + id: `Item:${itemId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + return { + ...data, + comments: dedupeComments(data.comments, newComments), + newComments: [] + } + }) + } + } + + const dedupeComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments?.map(c => c.id)) + const filteredNew = newComments.filter(c => !existingIds.has(c.id)) + return { + ...existingComments, + comments: [...filteredNew, ...(existingComments.comments || [])] + } + } + + return ( + +
+
+ load new comments +
+
+
+ + ) +} From 08bbba4cc1c9c26371ff603ac54a042e4a0b4bf5 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 14:15:16 +0200 Subject: [PATCH 17/50] refactor: clearer naming, optimized polling and date retrieval logic, use of constants, general cleanup --- components/use-live-comments.js | 54 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 7300d64e37..44c8fe3baa 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -3,7 +3,11 @@ import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useEffect, useState } from 'react' -import styles from './comments.module.css' +import styles from './comment.module.css' + +const POLL_INTERVAL = 1000 * 10 // 10 seconds +const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes +const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute export default function useLiveComments (rootId, after) { const client = useApolloClient() @@ -25,7 +29,7 @@ export default function useLiveComments (rootId, after) { const isActive = document.visibilityState === 'visible' // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { + if (timeSinceEngaged < ACTIVITY_TIMEOUT) { setPolling(isActive) } else { setPolling(false) @@ -33,11 +37,12 @@ export default function useLiveComments (rootId, after) { } // check activity every minute - const interval = setInterval(checkActivity, 1000 * 60) + const interval = setInterval(checkActivity, ACTIVITY_CHECK_INTERVAL) // check activity also on visibility change document.addEventListener('visibilitychange', checkActivity) return () => { + // cleanup document.removeEventListener('visibilitychange', checkActivity) clearInterval(interval) } @@ -46,25 +51,22 @@ export default function useLiveComments (rootId, after) { const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: polling ? 10000 : null, + pollInterval: polling ? POLL_INTERVAL : null, variables: { rootId, after: latest } }) useEffect(() => { - if (data && data.newComments) { - saveNewComments(client, rootId, data.newComments.comments) - // check new comments created after the latest new comment - const latestCommentCreatedAt = getLatestCommentCreatedAt(data.newComments.comments, latest) - if (latestCommentCreatedAt) { - setLatest(latestCommentCreatedAt) - } - } - }, [data, client, rootId, latest]) + if (!data?.newComments) return + + cacheNewComments(client, rootId, data.newComments.comments) + // check new comments created after the latest new comment + setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + }, [data, client, rootId]) return { polling, setPolling } } -function saveNewComments (client, rootId, newComments) { +function cacheNewComments (client, rootId, newComments) { for (const newComment of newComments) { const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) @@ -77,7 +79,7 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // we return the entire item, not just the newComments - return { item: dedupeComment(data?.item, newComment) } + return { item: mergeNewComments(data?.item, newComment) } }) } else { // if the comment is a reply, update the parent comment @@ -88,13 +90,13 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // here we return the parent comment with the new comment added - return dedupeComment(data, newComment) + return mergeNewComments(data, newComment) }) } } } -function dedupeComment (item, newComment) { +function mergeNewComments (item, newComment) { const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -106,15 +108,15 @@ function dedupeComment (item, newComment) { } function getLatestCommentCreatedAt (comments, latest) { - if (comments.length === 0) return null - - for (const comment of comments) { - if (comment.createdAt > latest) { - latest = comment.createdAt - } - } - - return latest + if (comments.length === 0) return latest + + // timestamp comparison via Math.max on bare timestamps + // convert all createdAt to timestamps + const timestamps = comments.map(c => new Date(c.createdAt).getTime()) + // find the latest timestamp + const maxTimestamp = Math.max(...timestamps, new Date(latest).getTime()) + // convert back to ISO string + return new Date(maxTimestamp).toISOString() } export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { From 6f417cc422d782823962be71fc74ac514e7e712d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 27 Jun 2025 12:26:46 +0200 Subject: [PATCH 18/50] ui: place ShowNewComments in the bottom-right corner of nested comments --- components/comment.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/comment.js b/components/comment.js index a7667ee69d..e053d215fc 100644 --- a/components/comment.js +++ b/components/comment.js @@ -261,6 +261,11 @@ export default function Comment ({ : !noReply && {root.bounty && !bountyPaid && } +
+ {item.newComments?.length > 0 && ( + + )} +
} {children}
@@ -276,9 +281,6 @@ export default function Comment ({ : null} {/* TODO: add link to more comments if they're limited */}
- {item.newComments?.length > 0 && ( - - )}
) )} From 274927d2632a4936ea7f8a476419251784251736 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 27 Jun 2025 12:28:22 +0200 Subject: [PATCH 19/50] fix: make updateQuery sort-aware to correctly inject the comment in the correct Item query --- components/comments.js | 7 +++++-- components/use-live-comments.js | 27 +++++++++++++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/components/comments.js b/components/comments.js index 54eace4ca8..34da7c6680 100644 --- a/components/comments.js +++ b/components/comments.js @@ -89,8 +89,10 @@ export default function Comments ({ commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { const router = useRouter() + // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP + const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) // update item.newComments in cache - const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) + const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt, sort) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -113,7 +115,8 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP + )} {pins.map(item => ( diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 44c8fe3baa..505a82972e 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -9,7 +9,7 @@ const POLL_INTERVAL = 1000 * 10 // 10 seconds const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute -export default function useLiveComments (rootId, after) { +export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) const [polling, setPolling] = useState(true) @@ -58,7 +58,7 @@ export default function useLiveComments (rootId, after) { useEffect(() => { if (!data?.newComments) return - cacheNewComments(client, rootId, data.newComments.comments) + cacheNewComments(client, rootId, data.newComments.comments, sort) // check new comments created after the latest new comment setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) }, [data, client, rootId]) @@ -66,7 +66,7 @@ export default function useLiveComments (rootId, after) { return { polling, setPolling } } -function cacheNewComments (client, rootId, newComments) { +function cacheNewComments (client, rootId, newComments, sort) { for (const newComment of newComments) { const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) @@ -75,7 +75,7 @@ function cacheNewComments (client, rootId, newComments) { if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, - variables: { id: rootId } + variables: { id: rootId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP }, (data) => { if (!data) return data // we return the entire item, not just the newComments @@ -119,14 +119,14 @@ function getLatestCommentCreatedAt (comments, latest) { return new Date(maxTimestamp).toISOString() } -export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { const client = useApolloClient() const showNewComments = () => { if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, - variables: { id: itemId } + variables: { id: itemId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP }, (data) => { if (!data) return data const { item } = data @@ -166,13 +166,12 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) } return ( - -
-
- load new comments -
-
-
- +
+ load new comments +
+
) } From 907c71d4ea5d855972fd6f8d8b91d136497c850f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 28 Jun 2025 01:54:58 +0200 Subject: [PATCH 20/50] cleanup: better naming; fix: usecallback on live comments component; fix leak on useEffect because of missing sort atomic apollo cache manipulations; manage top sort not being present in item query cache queue nested comments without a parent, retry on the next poll fix commit messages --- components/comments.js | 27 +------ components/use-live-comments.js | 139 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 96 deletions(-) diff --git a/components/comments.js b/components/comments.js index 34da7c6680..c4fb8a6c66 100644 --- a/components/comments.js +++ b/components/comments.js @@ -9,10 +9,8 @@ import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments, { ShowNewComments } from './use-live-comments' -import ActionTooltip from './action-tooltip' -import classNames from 'classnames' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling, setLivePolling }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -31,25 +29,6 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {livePolling - ? ( - - -
- - - ) - : ( - - -
setLivePolling(true)} - style={{ cursor: 'pointer' }} - /> - - - )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -101,7 +80,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 505a82972e..37edef4025 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,101 +2,97 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState, useRef } from 'react' import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds -const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes -const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute + +function itemUpdateQuery (client, id, sort, fn) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: sort === 'top' ? { id } : { id, sort } + }, (data) => fn(data)) +} + +function commentUpdateFragment (client, id, fn) { + client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => fn(data)) +} export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) - const [polling, setPolling] = useState(true) - const [engagedAt, setEngagedAt] = useState(new Date()) - - // reset engagedAt when polling is toggled - useEffect(() => { - if (polling) { - setEngagedAt(new Date()) - } - }, [polling]) - - useEffect(() => { - const checkActivity = () => { - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() - const isActive = document.visibilityState === 'visible' - - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < ACTIVITY_TIMEOUT) { - setPolling(isActive) - } else { - setPolling(false) - } - } - - // check activity every minute - const interval = setInterval(checkActivity, ACTIVITY_CHECK_INTERVAL) - // check activity also on visibility change - document.addEventListener('visibilitychange', checkActivity) - - return () => { - // cleanup - document.removeEventListener('visibilitychange', checkActivity) - clearInterval(interval) - } - }, [engagedAt]) + const queuedCommentsRef = useRef([]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: polling ? POLL_INTERVAL : null, + pollInterval: POLL_INTERVAL, variables: { rootId, after: latest } }) useEffect(() => { if (!data?.newComments) return - cacheNewComments(client, rootId, data.newComments.comments, sort) - // check new comments created after the latest new comment - setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) - }, [data, client, rootId]) + // live comments can be orphans if the parent comment is not in the cache + // queue them up and retry later, when the parent decides they want the children. + const allComments = [...queuedCommentsRef.current, ...data.newComments.comments] + const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) - return { polling, setPolling } + // keep the queued comments in the ref for the next poll + queuedCommentsRef.current = queuedComments + + // update latest timestamp to the latest comment created at + setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + }, [data, client, rootId, sort]) } function cacheNewComments (client, rootId, newComments, sort) { + const queuedComments = [] + for (const newComment of newComments) { + console.log('newComment', newComment) const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: rootId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - }, (data) => { + console.log('topLevel', topLevel) + itemUpdateQuery(client, rootId, sort, (data) => { if (!data) return data - // we return the entire item, not just the newComments - return { item: mergeNewComments(data?.item, newComment) } + return { item: mergeNewComment(data?.item, newComment) } }) } else { - // if the comment is a reply, update the parent comment - client.cache.updateFragment({ + // check if parent exists in cache before attempting update + const parentExists = client.cache.readFragment({ id: `Item:${parentId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - // here we return the parent comment with the new comment added - return mergeNewComments(data, newComment) }) + + if (parentExists) { + // if the comment is a reply, update the parent comment + console.log('reply', parentId) + commentUpdateFragment(client, parentId, (data) => { + if (!data) return data + return mergeNewComment(data, newComment) + }) + } else { + // parent not in cache, queue for retry + queuedComments.push(newComment) + } } } + + return { queuedComments } } -function mergeNewComments (item, newComment) { +// merge new comment into item's newComments +// if the new comment is already in item's newComments or existing comments, do nothing +function mergeNewComment (item, newComment) { const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -122,42 +118,41 @@ function getLatestCommentCreatedAt (comments, latest) { export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { const client = useApolloClient() - const showNewComments = () => { + const showNewComments = useCallback(() => { if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: itemId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - }, (data) => { + console.log('topLevel', topLevel) + itemUpdateQuery(client, itemId, sort, (data) => { + console.log('data', data) if (!data) return data const { item } = data return { item: { ...item, - comments: dedupeComments(item.comments, newComments), + comments: injectComments(item.comments, newComments), newComments: [] } } }) } else { - client.cache.updateFragment({ - id: `Item:${itemId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { + console.log('reply', itemId) + commentUpdateFragment(client, itemId, (data) => { + console.log('data', data) if (!data) return data return { ...data, - comments: dedupeComments(data.comments, newComments), + comments: injectComments(data.comments, newComments), newComments: [] } }) } - } + }, [client, itemId, newComments, topLevel, sort]) - const dedupeComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments?.map(c => c.id)) + // inject new comments into existing comments + // if the new comment is already in existing comments, do nothing + const injectComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments.map(c => c.id)) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, From e797011a3f21550d152249942e26322bcd8e2e10 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 30 Jun 2025 00:11:19 +0200 Subject: [PATCH 21/50] fix: don't show unpaid comments; cleanup: compact cache merge/dedupe, queue comments via state --- api/resolvers/item.js | 7 ++- components/use-live-comments.js | 82 +++++++++++++-------------------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 4485f6ca97..02a4ace5e4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -742,8 +742,11 @@ export default { ${SELECT} FROM "Item" -- comments can be nested, so we need to get all comments that are descendants of the root - WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) - AND "Item"."created_at" > $2 + ${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) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 37edef4025..66b637311e 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,16 +2,20 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useState } from 'react' import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds +// the item query is used to update the item's newComments field function itemUpdateQuery (client, id, sort, fn) { client.cache.updateQuery({ query: ITEM_FULL, variables: sort === 'top' ? { id } : { id, sort } - }, (data) => fn(data)) + }, (data) => { + if (!data) return data + return { item: fn(data.item) } + }) } function commentUpdateFragment (client, id, fn) { @@ -19,13 +23,21 @@ function commentUpdateFragment (client, id, fn) { id: `Item:${id}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' - }, (data) => fn(data)) + }, (data) => { + if (!data) return data + return { ...data, ...fn(data) } + }) +} + +function dedupeComments (existing = [], incoming = []) { + const existingIds = new Set(existing.map(c => c.id)) + return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] } export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) - const queuedCommentsRef = useRef([]) + const [queue, setQueue] = useState([]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} @@ -39,11 +51,11 @@ export default function useLiveComments (rootId, after, sort) { // live comments can be orphans if the parent comment is not in the cache // queue them up and retry later, when the parent decides they want the children. - const allComments = [...queuedCommentsRef.current, ...data.newComments.comments] + const allComments = [...queue, ...data.newComments.comments] const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) - // keep the queued comments in the ref for the next poll - queuedCommentsRef.current = queuedComments + // keep the queued comments for the next poll + setQueue(queuedComments) // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) @@ -61,10 +73,7 @@ function cacheNewComments (client, rootId, newComments, sort) { // if the comment is a top level comment, update the item if (topLevel) { console.log('topLevel', topLevel) - itemUpdateQuery(client, rootId, sort, (data) => { - if (!data) return data - return { item: mergeNewComment(data?.item, newComment) } - }) + itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { // check if parent exists in cache before attempting update const parentExists = client.cache.readFragment({ @@ -76,10 +85,7 @@ function cacheNewComments (client, rootId, newComments, sort) { if (parentExists) { // if the comment is a reply, update the parent comment console.log('reply', parentId) - commentUpdateFragment(client, parentId, (data) => { - if (!data) return data - return mergeNewComment(data, newComment) - }) + commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } else { // parent not in cache, queue for retry queuedComments.push(newComment) @@ -93,6 +99,7 @@ function cacheNewComments (client, rootId, newComments, sort) { // merge new comment into item's newComments // if the new comment is already in item's newComments or existing comments, do nothing function mergeNewComment (item, newComment) { + console.log('mergeNewComment', item, newComment) const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -119,47 +126,24 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s const client = useApolloClient() const showNewComments = useCallback(() => { + const payload = (data) => { + if (!data) return data + return { + ...data, + comments: { ...data.comments, comments: dedupeComments(data.comments.comments, newComments) }, + newComments: [] + } + } + if (topLevel) { console.log('topLevel', topLevel) - itemUpdateQuery(client, itemId, sort, (data) => { - console.log('data', data) - if (!data) return data - const { item } = data - - return { - item: { - ...item, - comments: injectComments(item.comments, newComments), - newComments: [] - } - } - }) + itemUpdateQuery(client, itemId, sort, payload) } else { console.log('reply', itemId) - commentUpdateFragment(client, itemId, (data) => { - console.log('data', data) - if (!data) return data - - return { - ...data, - comments: injectComments(data.comments, newComments), - newComments: [] - } - }) + commentUpdateFragment(client, itemId, payload) } }, [client, itemId, newComments, topLevel, sort]) - // inject new comments into existing comments - // if the new comment is already in existing comments, do nothing - const injectComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments.map(c => c.id)) - const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return { - ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] - } - } - return (
Date: Mon, 30 Jun 2025 11:51:57 +0200 Subject: [PATCH 22/50] fix: read new comments fragments to inject fresh new comments, fixing dropped comments; ui: show amount of new comments refactor: correct function positioning; cleanup: useless logs --- components/use-live-comments.js | 73 +++++++++++++++++---------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 66b637311e..56399e8811 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,33 +7,6 @@ import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds -// the item query is used to update the item's newComments field -function itemUpdateQuery (client, id, sort, fn) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: sort === 'top' ? { id } : { id, sort } - }, (data) => { - if (!data) return data - return { item: fn(data.item) } - }) -} - -function commentUpdateFragment (client, id, fn) { - client.cache.updateFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - return { ...data, ...fn(data) } - }) -} - -function dedupeComments (existing = [], incoming = []) { - const existingIds = new Set(existing.map(c => c.id)) - return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] -} - export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) @@ -62,17 +35,37 @@ export default function useLiveComments (rootId, after, sort) { }, [data, client, rootId, sort]) } +// the item query is used to update the item's newComments field +function itemUpdateQuery (client, id, sort, fn) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: sort === 'top' ? { id } : { id, sort } + }, (data) => { + if (!data) return data + return { item: fn(data.item) } + }) +} + +function commentUpdateFragment (client, id, fn) { + client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + return fn(data) + }) +} + function cacheNewComments (client, rootId, newComments, sort) { const queuedComments = [] for (const newComment of newComments) { - console.log('newComment', newComment) const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item if (topLevel) { - console.log('topLevel', topLevel) itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { // check if parent exists in cache before attempting update @@ -84,7 +77,6 @@ function cacheNewComments (client, rootId, newComments, sort) { if (parentExists) { // if the comment is a reply, update the parent comment - console.log('reply', parentId) commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } else { // parent not in cache, queue for retry @@ -99,7 +91,6 @@ function cacheNewComments (client, rootId, newComments, sort) { // merge new comment into item's newComments // if the new comment is already in item's newComments or existing comments, do nothing function mergeNewComment (item, newComment) { - console.log('mergeNewComment', item, newComment) const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -107,9 +98,16 @@ function mergeNewComment (item, newComment) { if (existingNewComments.some(c => c.id === newComment.id) || existingComments.some(c => c.id === newComment.id)) { return item } + return { ...item, newComments: [...existingNewComments, newComment] } } +// dedupe comments by id +function dedupeComments (existing = [], incoming = []) { + const existingIds = new Set(existing.map(c => c.id)) + return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] +} + function getLatestCommentCreatedAt (comments, latest) { if (comments.length === 0) return latest @@ -127,19 +125,22 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s const showNewComments = useCallback(() => { const payload = (data) => { - if (!data) return data + // fresh newComments + const freshNewComments = newComments.map(c => client.cache.readFragment({ + id: `Item:${c.id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + })) return { ...data, - comments: { ...data.comments, comments: dedupeComments(data.comments.comments, newComments) }, + comments: { ...data.comments, comments: dedupeComments(data.comments.comments, freshNewComments) }, newComments: [] } } if (topLevel) { - console.log('topLevel', topLevel) itemUpdateQuery(client, itemId, sort, payload) } else { - console.log('reply', itemId) commentUpdateFragment(client, itemId, payload) } }, [client, itemId, newComments, topLevel, sort]) @@ -149,7 +150,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - load new comments + show ({newComments.length}) new comments
) From 6a93d2a34fe72c84499daaaf8e7f6a204b625bfe Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Jul 2025 12:05:55 +0200 Subject: [PATCH 23/50] enhance: queuedComments Ref, cache-and-network fetch policy; freshNewComments readFragment fallback to received comment --- components/use-live-comments.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 56399e8811..202d1a87d8 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,7 +2,7 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds @@ -10,13 +10,14 @@ const POLL_INTERVAL = 1000 * 10 // 10 seconds export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) - const [queue, setQueue] = useState([]) + const queue = useRef([]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { pollInterval: POLL_INTERVAL, - variables: { rootId, after: latest } + variables: { rootId, after: latest }, + nextFetchPolicy: 'cache-and-network' }) useEffect(() => { @@ -24,11 +25,11 @@ export default function useLiveComments (rootId, after, sort) { // live comments can be orphans if the parent comment is not in the cache // queue them up and retry later, when the parent decides they want the children. - const allComments = [...queue, ...data.newComments.comments] + const allComments = [...queue.current, ...data.newComments.comments] const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) // keep the queued comments for the next poll - setQueue(queuedComments) + queue.current = queuedComments // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) @@ -126,11 +127,15 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s const showNewComments = useCallback(() => { const payload = (data) => { // fresh newComments - const freshNewComments = newComments.map(c => client.cache.readFragment({ - id: `Item:${c.id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - })) + const freshNewComments = newComments.map(c => { + const fragment = client.cache.readFragment({ + id: `Item:${c.id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }) + return fragment || c + }) + return { ...data, comments: { ...data.comments, comments: dedupeComments(data.comments.comments, freshNewComments) }, From a29f9e3c877f9d7a1354307d76f7dddae45c471d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Jul 2025 20:21:49 +0200 Subject: [PATCH 24/50] cleanup: detailed comments and better ShowNewComment text --- components/comments.js | 9 +++----- components/use-live-comments.js | 37 +++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/components/comments.js b/components/comments.js index c4fb8a6c66..6c3089e470 100644 --- a/components/comments.js +++ b/components/comments.js @@ -68,10 +68,8 @@ export default function Comments ({ commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { const router = useRouter() - // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) - // update item.newComments in cache - useLiveComments(parentId, lastCommentAt || parentCreatedAt, sort) + // 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]) @@ -94,8 +92,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - + )} {pins.map(item => ( diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 202d1a87d8..1d8c6bd615 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,6 +7,8 @@ import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds +// useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt +// and inserts them into the newComment client field of their parent comment/post. export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) @@ -16,6 +18,7 @@ export default function useLiveComments (rootId, after, sort) { ? {} : { pollInterval: POLL_INTERVAL, + // only get comments newer than the passed latest timestamp variables: { rootId, after: latest }, nextFetchPolicy: 'cache-and-network' }) @@ -23,10 +26,10 @@ export default function useLiveComments (rootId, after, sort) { useEffect(() => { if (!data?.newComments) return - // live comments can be orphans if the parent comment is not in the cache - // queue them up and retry later, when the parent decides they want the children. - const allComments = [...queue.current, ...data.newComments.comments] - const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) + // sometimes new comments can arrive as orphans because their parent might not be in the cache yet + // queue them up, retry until the parent shows up. + const newComments = [...data.newComments.comments, ...queue.current] + const { queuedComments } = cacheNewComments(client, rootId, newComments, sort) // keep the queued comments for the next poll queue.current = queuedComments @@ -40,13 +43,16 @@ export default function useLiveComments (rootId, after, sort) { function itemUpdateQuery (client, id, sort, fn) { client.cache.updateQuery({ query: ITEM_FULL, - variables: sort === 'top' ? { id } : { id, sort } + // 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 + variables: sort ? { id, sort } : { id } }, (data) => { if (!data) return data return { item: fn(data.item) } }) } +// update the newComments field of a nested comment fragment function commentUpdateFragment (client, id, fn) { client.cache.updateFragment({ id: `Item:${id}`, @@ -67,9 +73,11 @@ function cacheNewComments (client, rootId, newComments, sort) { // if the comment is a top level comment, update the item if (topLevel) { + // merge the new comment into the item's newComments field, checking for duplicates itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { - // check if parent exists in cache before attempting update + // if the comment is a reply, update the parent comment + // but first check if parent exists in cache before attempting update const parentExists = client.cache.readFragment({ id: `Item:${parentId}`, fragment: COMMENT_WITH_NEW, @@ -77,7 +85,7 @@ function cacheNewComments (client, rootId, newComments, sort) { }) if (parentExists) { - // if the comment is a reply, update the parent comment + // merge the new comment into the parent comment's newComments field, checking for duplicates commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } else { // parent not in cache, queue for retry @@ -90,7 +98,7 @@ function cacheNewComments (client, rootId, newComments, sort) { } // merge new comment into item's newComments -// if the new comment is already in item's newComments or existing comments, do nothing +// and prevent duplicates by checking if the comment is already in item's newComments or existing comments function mergeNewComment (item, newComment) { const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -103,7 +111,9 @@ function mergeNewComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment] } } -// dedupe comments by id +// even though we already deduplicated comments during the newComments merge +// refetches, client-side navigation, etc. can cause duplicates to appear +// we'll make sure to deduplicate them here, by id function dedupeComments (existing = [], incoming = []) { const existingIds = new Set(existing.map(c => c.id)) return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] @@ -121,18 +131,23 @@ function getLatestCommentCreatedAt (comments, latest) { return new Date(maxTimestamp).toISOString() } +// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { const client = useApolloClient() const showNewComments = useCallback(() => { const payload = (data) => { - // fresh newComments + // TODO: it might be sane to pass the cache ref to the ShowNewComments component + // TODO: and use it to read the latest newComments from the cache + // newComments can have themselves new comments between the time the button is clicked and the query is executed + // so we need to read the latest newComments from the cache const freshNewComments = newComments.map(c => { const fragment = client.cache.readFragment({ id: `Item:${c.id}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }) + // if the comment is not in the cache, return the original comment return fragment || c }) @@ -155,7 +170,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - show ({newComments.length}) new comments + {newComments.length > 0 ? `${newComments.length} new comments` : 'new comment'}
) From f7104573b2338c85a9636f7a461ebf05c6343ec1 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Jul 2025 21:02:02 +0200 Subject: [PATCH 25/50] fix: while showing new comments, also update ncomments for UI and pagination --- components/use-live-comments.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 1d8c6bd615..027bb8cefd 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -111,14 +111,6 @@ function mergeNewComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment] } } -// even though we already deduplicated comments during the newComments merge -// refetches, client-side navigation, etc. can cause duplicates to appear -// we'll make sure to deduplicate them here, by id -function dedupeComments (existing = [], incoming = []) { - const existingIds = new Set(existing.map(c => c.id)) - return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] -} - function getLatestCommentCreatedAt (comments, latest) { if (comments.length === 0) return latest @@ -151,9 +143,13 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s return fragment || c }) + // deduplicate the fresh new comments with the existing comments + const dedupedComments = dedupeComments(data.comments.comments, freshNewComments) + return { ...data, - comments: { ...data.comments, comments: dedupeComments(data.comments.comments, freshNewComments) }, + comments: { ...data.comments, comments: dedupedComments }, + ncomments: data.ncomments + (dedupedComments.length || 0), newComments: [] } } @@ -170,8 +166,16 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - {newComments.length > 0 ? `${newComments.length} new comments` : 'new comment'} + {newComments.length > 1 ? `${newComments.length} new comments` : 'show new comment'}
) } + +// even though we already deduplicated comments during the newComments merge, +// refetches, client-side navigation, etc. can cause duplicates to appear, +// so we'll make sure to deduplicate them here, by id +function dedupeComments (existing = [], incoming = []) { + const existingIds = new Set(existing.map(c => c.id)) + return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] +} From 40d56fe29d0adf9d49fe1d408133b6543bf262da Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Jul 2025 22:12:22 +0200 Subject: [PATCH 26/50] refactor: ShowNewComments is its own component; cleanup: proven useless dedupe on ShowNewComments, count nested ncomments from fresh new comments --- components/comment.js | 2 +- components/comments.js | 3 +- components/show-new-comments.js | 61 +++++++++++++++++++++++++++++++ components/use-live-comments.js | 64 ++------------------------------- 4 files changed, 67 insertions(+), 63 deletions(-) create mode 100644 components/show-new-comments.js diff --git a/components/comment.js b/components/comment.js index e053d215fc..9dd2d29831 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,7 +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 './use-live-comments' +import { ShowNewComments } from './show-new-comments' function Parent ({ item, rootText }) { const root = useRoot() diff --git a/components/comments.js b/components/comments.js index 6c3089e470..542b63469d 100644 --- a/components/comments.js +++ b/components/comments.js @@ -8,7 +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, { ShowNewComments } from './use-live-comments' +import useLiveComments from './use-live-comments' +import { ShowNewComments } from './show-new-comments' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() diff --git a/components/show-new-comments.js b/components/show-new-comments.js new file mode 100644 index 0000000000..424b47a510 --- /dev/null +++ b/components/show-new-comments.js @@ -0,0 +1,61 @@ +import { useCallback } from 'react' +import { useApolloClient } from '@apollo/client' +import { COMMENT_WITH_NEW } from '../fragments/comments' +import styles from './comment.module.css' +import { itemUpdateQuery, commentUpdateFragment } from './use-live-comments' + +function prepareComments (client, newComments) { + return (data) => { + // TODO: it might be sane to pass the cache ref to the ShowNewComments component + // TODO: and use it to read the latest newComments from the cache + // newComments can have themselves new comments between the time the button is clicked and the query is executed + // so we need to read the latest newComments from the cache + const freshNewComments = newComments.map(c => { + const fragment = client.cache.readFragment({ + id: `Item:${c.id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }) + // if the comment is not in the cache, return the original comment + return fragment || c + }) + + // count the total number of comments including nested comments + let ncomments = data.ncomments + freshNewComments.length + for (const comment of freshNewComments) { + ncomments += (comment.ncomments || 0) + } + + return { + ...data, + comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] }, + ncomments, + newComments: [] + } + } +} + +// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field +export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { + const client = useApolloClient() + + const showNewComments = useCallback(() => { + const payload = prepareComments(client, newComments) + + if (topLevel) { + itemUpdateQuery(client, itemId, sort, payload) + } else { + commentUpdateFragment(client, itemId, payload) + } + }, [client, itemId, newComments, topLevel, sort]) + + return ( +
+ {newComments.length > 1 ? `${newComments.length} new comments` : 'show new comment'} +
+
+ ) +} diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 027bb8cefd..e4aaca4bdf 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,8 +2,7 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useCallback, useEffect, useRef, useState } from 'react' -import styles from './comment.module.css' +import { useEffect, useRef, useState } from 'react' const POLL_INTERVAL = 1000 * 10 // 10 seconds @@ -40,7 +39,7 @@ export default function useLiveComments (rootId, after, sort) { } // the item query is used to update the item's newComments field -function itemUpdateQuery (client, id, sort, fn) { +export function itemUpdateQuery (client, id, sort, fn) { client.cache.updateQuery({ query: ITEM_FULL, // updateQuery needs the correct variables to update the correct item @@ -53,7 +52,7 @@ function itemUpdateQuery (client, id, sort, fn) { } // update the newComments field of a nested comment fragment -function commentUpdateFragment (client, id, fn) { +export function commentUpdateFragment (client, id, fn) { client.cache.updateFragment({ id: `Item:${id}`, fragment: COMMENT_WITH_NEW, @@ -122,60 +121,3 @@ function getLatestCommentCreatedAt (comments, latest) { // convert back to ISO string return new Date(maxTimestamp).toISOString() } - -// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field -export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { - const client = useApolloClient() - - const showNewComments = useCallback(() => { - const payload = (data) => { - // TODO: it might be sane to pass the cache ref to the ShowNewComments component - // TODO: and use it to read the latest newComments from the cache - // newComments can have themselves new comments between the time the button is clicked and the query is executed - // so we need to read the latest newComments from the cache - const freshNewComments = newComments.map(c => { - const fragment = client.cache.readFragment({ - id: `Item:${c.id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }) - // if the comment is not in the cache, return the original comment - return fragment || c - }) - - // deduplicate the fresh new comments with the existing comments - const dedupedComments = dedupeComments(data.comments.comments, freshNewComments) - - return { - ...data, - comments: { ...data.comments, comments: dedupedComments }, - ncomments: data.ncomments + (dedupedComments.length || 0), - newComments: [] - } - } - - if (topLevel) { - itemUpdateQuery(client, itemId, sort, payload) - } else { - commentUpdateFragment(client, itemId, payload) - } - }, [client, itemId, newComments, topLevel, sort]) - - return ( -
- {newComments.length > 1 ? `${newComments.length} new comments` : 'show new comment'} -
-
- ) -} - -// even though we already deduplicated comments during the newComments merge, -// refetches, client-side navigation, etc. can cause duplicates to appear, -// so we'll make sure to deduplicate them here, by id -function dedupeComments (existing = [], incoming = []) { - const existingIds = new Set(existing.map(c => c.id)) - return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] -} From c7095a7ea95d0e0d45c2ac871844fbd52ea38bb7 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Jul 2025 23:54:00 +0200 Subject: [PATCH 27/50] enhance: direct latest comment createdAt calc with reduce --- components/use-live-comments.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index e4aaca4bdf..bbaba9935e 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -35,6 +35,7 @@ export default function useLiveComments (rootId, after, sort) { // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + console.log('latest', latest) }, [data, client, rootId, sort]) } @@ -111,13 +112,8 @@ function mergeNewComment (item, newComment) { } function getLatestCommentCreatedAt (comments, latest) { - if (comments.length === 0) return latest - - // timestamp comparison via Math.max on bare timestamps - // convert all createdAt to timestamps - const timestamps = comments.map(c => new Date(c.createdAt).getTime()) - // find the latest timestamp - const maxTimestamp = Math.max(...timestamps, new Date(latest).getTime()) - // convert back to ISO string - return new Date(maxTimestamp).toISOString() + return comments.reduce( + (max, { createdAt }) => (createdAt > max ? createdAt : max), + latest + ) } From 05785dba188b93e9e5b76deae4af831cf47cda34 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 2 Jul 2025 00:04:22 +0200 Subject: [PATCH 28/50] cleanup queue on unmount --- components/use-live-comments.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index bbaba9935e..e7868696d2 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -35,8 +35,14 @@ export default function useLiveComments (rootId, after, sort) { // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) - console.log('latest', latest) }, [data, client, rootId, sort]) + + // cleanup queue on unmount to prevent memory leaks + useEffect(() => { + return () => { + queue.current = [] + } + }, []) } // the item query is used to update the item's newComments field From efb12d62d59f59ba8e625e1790e3efb79338fc39 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 3 Jul 2025 02:42:15 +0200 Subject: [PATCH 29/50] feat: live comments indicator for bottomed-out replies, ncomments updates; fix: nested comment structures - new comments indicator for bottomed-out replies - ncomments sync for parent and its ancestors - limited comments fragment for comments that don't have CommentsRecursive - reduce cache complexity by removing useless roundtrips ux: live comments indicator on bottomedOut replies fix: dedupe newComments before displaying ShowNewComments to avoid false positives enhance: store ids of new comments in the cache, instead of carrying full comments that would get discarded anyway hotfix: newComments deduplication ID mismatch, filter null comments from freshNewComments fix: ncomments not updating for all comment levels; refactor: share Reply update ancestors' ncomments function with ShowNewComments cleanup: better naming to indicate the total number of comments including nested comments fix: increment parent comment ncomments cleanup: Items that will have comments will always have a structure where item.comments is true cleanup: reduce code complexity checking the nested comment update result instead of preventively reading the fragment cleanup: avoid double-updating ncomments on parent fix: don't use CommentsRecursive for bottomed-out comments cleanup: better fragment naming; add TODO for absolute bottom comments --- components/comment.js | 5 +-- components/comments.js | 2 +- components/item-full.js | 25 +++++++------- components/reply.js | 13 ++----- components/show-new-comments.js | 61 +++++++++++++++++++++------------ components/use-live-comments.js | 40 ++++++++++++--------- fragments/comments.js | 29 ++++++++++++++-- lib/comments.js | 14 ++++++++ 8 files changed, 124 insertions(+), 65 deletions(-) create mode 100644 lib/comments.js diff --git a/components/comment.js b/components/comment.js index 9dd2d29831..3159026c6f 100644 --- a/components/comment.js +++ b/components/comment.js @@ -263,7 +263,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
{item.newComments?.length > 0 && ( - + )}
} @@ -310,8 +310,9 @@ function ReplyOnAnotherPage ({ item }) { } return ( - + {text} + {item.newComments?.length > 0 &&
} ) } diff --git a/components/comments.js b/components/comments.js index 542b63469d..fa8ce9c7b7 100644 --- a/components/comments.js +++ b/components/comments.js @@ -93,7 +93,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( diff --git a/components/item-full.js b/components/item-full.js index 02621981b8..5129f55cd3 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -182,18 +182,19 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props ? : }
)} -
- -
+ {item.comments && +
+ +
} diff --git a/components/reply.js b/components/reply.js index ec12d0a5fc..e89816c705 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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, @@ -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 diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 424b47a510..1951b7814f 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,60 +1,79 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useApolloClient } from '@apollo/client' -import { COMMENT_WITH_NEW } from '../fragments/comments' +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) => { - // TODO: it might be sane to pass the cache ref to the ShowNewComments component - // TODO: and use it to read the latest newComments from the cache - // newComments can have themselves new comments between the time the button is clicked and the query is executed - // so we need to read the latest newComments from the cache - const freshNewComments = newComments.map(c => { + // 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:${c.id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' }) - // if the comment is not in the cache, return the original comment - return fragment || c - }) - // count the total number of comments including nested comments - let ncomments = data.ncomments + freshNewComments.length + 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) { - ncomments += (comment.ncomments || 0) + 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, + ncomments: data.ncomments + totalNComments, newComments: [] } } } // ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field -export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { +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(() => { - const payload = prepareComments(client, newComments) + // 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, newComments, topLevel, sort]) + }, [client, itemId, dedupedNewComments, topLevel, sort]) + + if (dedupedNewComments.length === 0) { + return null + } return (
- {newComments.length > 1 ? `${newComments.length} new comments` : 'show new comment'} + {dedupedNewComments.length > 1 + ? `${dedupedNewComments.length} new comments` + : 'show new comment'}
) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index e7868696d2..f5396444a5 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -1,6 +1,6 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' -import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' +import { GET_NEW_COMMENTS, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useEffect, useRef, useState } from 'react' @@ -60,14 +60,29 @@ export function itemUpdateQuery (client, id, sort, fn) { // update the newComments field of a nested comment fragment export function commentUpdateFragment (client, id, fn) { - client.cache.updateFragment({ + let result = client.cache.updateFragment({ id: `Item:${id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' }, (data) => { if (!data) return data return fn(data) }) + + // sometimes comments can 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 + if (!result) { + result = client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_LIMITED, + fragmentName: 'CommentWithNewLimited' + }, (data) => { + if (!data) return data + return fn(data) + }) + } + + return result } function cacheNewComments (client, rootId, newComments, sort) { @@ -83,17 +98,10 @@ function cacheNewComments (client, rootId, newComments, sort) { itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { // if the comment is a reply, update the parent comment - // but first check if parent exists in cache before attempting update - const parentExists = client.cache.readFragment({ - id: `Item:${parentId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }) + // merge the new comment into the parent comment's newComments field, checking for duplicates + const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) - if (parentExists) { - // merge the new comment into the parent comment's newComments field, checking for duplicates - commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) - } else { + if (!result) { // parent not in cache, queue for retry queuedComments.push(newComment) } @@ -110,11 +118,11 @@ function mergeNewComment (item, newComment) { const existingComments = item.comments?.comments || [] // is the incoming new comment already in item's new comments or existing comments? - if (existingNewComments.some(c => c.id === newComment.id) || existingComments.some(c => c.id === newComment.id)) { + if (existingNewComments.includes(newComment.id) || existingComments.some(c => c.id === newComment.id)) { return item } - return { ...item, newComments: [...existingNewComments, newComment] } + return { ...item, newComments: [...existingNewComments, newComment.id] } } function getLatestCommentCreatedAt (comments, latest) { diff --git a/fragments/comments.js b/fragments/comments.js index f7e325c4cd..06b1605cba 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -118,11 +118,11 @@ export const COMMENTS = gql` } }` -export const COMMENT_WITH_NEW = gql` +export const COMMENT_WITH_NEW_RECURSIVE = gql` ${COMMENT_FIELDS} ${COMMENTS} - fragment CommentWithNew on Item { + fragment CommentWithNewRecursive on Item { ...CommentFields comments { comments { @@ -133,6 +133,31 @@ export const COMMENT_WITH_NEW = gql` } ` +export const COMMENT_WITH_NEW_LIMITED = gql` + ${COMMENT_FIELDS} + + fragment CommentWithNewLimited on Item { + ...CommentFields + comments { + comments { + ...CommentFields + } + } + newComments @client + } +` + +// TODO: fragment for comments without item.comments field +// TODO: remove if useless to pursue +export const COMMENT_WITH_NEW = gql` + ${COMMENT_FIELDS} + + fragment CommentWithNew on Item { + ...CommentFields + newComments @client + } +` + export const GET_NEW_COMMENTS = gql` ${COMMENTS} diff --git a/lib/comments.js b/lib/comments.js new file mode 100644 index 0000000000..b41e5c754f --- /dev/null +++ b/lib/comments.js @@ -0,0 +1,14 @@ +export function updateAncestorsCommentCount (cache, ancestors, increment) { + // update all ancestors + ancestors.forEach(id => { + cache.modify({ + id: `Item:${id}`, + fields: { + ncomments (existingNComments = 0) { + return existingNComments + increment + } + }, + optimistic: true + }) + }) +} From f24ad002ca517a98ffd3ce6b488e0d4a7f95f44c Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 09:58:02 +0200 Subject: [PATCH 30/50] backport live comments logic enhancements use-live-comments: - remove useless dedupe against already present comments - check newComments.comments length to tell if there are new comments - code reordering show-new-comments: - show all new comments recursively for nested comments - get always the newest comments to inject also their own child new comments - update local storage commentsViewedAt on comment injection - respect depth on comment injection comments.js - apollo cache manipulations now live here --- components/comment.js | 10 +-- components/show-new-comments.js | 108 +++++++++++++++++++++----- components/use-live-comments.js | 132 ++++++++++---------------------- lib/comments.js | 54 +++++++++++++ 4 files changed, 191 insertions(+), 113 deletions(-) diff --git a/components/comment.js b/components/comment.js index 3159026c6f..b371e1dc34 100644 --- a/components/comment.js +++ b/components/comment.js @@ -261,11 +261,11 @@ export default function Comment ({ : !noReply && {root.bounty && !bountyPaid && } -
- {item.newComments?.length > 0 && ( - - )} -
+ {item.newComments?.length > 0 && ( +
+ +
+ )}
} {children}
diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 1951b7814f..a7f16f1bb6 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,14 +1,29 @@ 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' +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 + // newComments is an array of comment ids that allows usto 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}`, @@ -33,6 +48,14 @@ function prepareComments (client, newComments) { 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] }, @@ -42,27 +65,76 @@ function prepareComments (client, 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 = false, comments, newComments = [], itemId, sort }) { +export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newComments = [], depth = 1 }) { 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 allNewComments = useMemo(() => { + if (!topLevel) { + return collectAllNewComments(item, depth) + } + return dedupeNewComments(newComments, comments) + }, [newComments, comments, item, depth]) const showNewComments = useCallback(() => { - // fetch the latest version of the comments from the cache by their ids - const payload = prepareComments(client, dedupedNewComments) - if (topLevel) { + const payload = prepareComments(client, allNewComments) itemUpdateQuery(client, itemId, sort, payload) } else { - commentUpdateFragment(client, itemId, payload) + showAllNewCommentsRecursively(client, item, depth) } - }, [client, itemId, dedupedNewComments, topLevel, sort]) + }, [client, itemId, allNewComments, topLevel, sort]) - if (dedupedNewComments.length === 0) { + if (allNewComments.length === 0) { return null } @@ -71,8 +143,8 @@ export function ShowNewComments ({ topLevel = false, comments, newComments = [], 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` + {allNewComments.length > 1 + ? `${allNewComments.length} new comments` : 'show new comment'}
diff --git a/components/use-live-comments.js b/components/use-live-comments.js index f5396444a5..0d8b107af3 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -1,11 +1,50 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' -import { GET_NEW_COMMENTS, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' -import { ITEM_FULL } from '../fragments/items' +import { GET_NEW_COMMENTS } from '../fragments/comments' import { useEffect, useRef, useState } from 'react' +import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } from '../lib/comments' const POLL_INTERVAL = 1000 * 10 // 10 seconds +// merge new comment into item's newComments +// and prevent duplicates by checking if the comment is already in item's newComments or existing comments +function mergeNewComment (item, newComment) { + const existingNewComments = item.newComments || [] + + // is the incoming new comment already in item's new comments or existing comments? + if (existingNewComments.includes(newComment.id)) { + return item + } + + return { ...item, newComments: [...existingNewComments, newComment.id] } +} + +function cacheNewComments (client, rootId, newComments, sort) { + const queuedComments = [] + + for (const newComment of newComments) { + const { parentId } = newComment + const topLevel = Number(parentId) === Number(rootId) + + // if the comment is a top level comment, update the item + if (topLevel) { + // merge the new comment into the item's newComments field, checking for duplicates + itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) + } else { + // if the comment is a reply, update the parent comment + // merge the new comment into the parent comment's newComments field, checking for duplicates + const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) + + if (!result) { + // parent not in cache, queue for retry + queuedComments.push(newComment) + } + } + } + + return { queuedComments } +} + // useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt // and inserts them into the newComment client field of their parent comment/post. export default function useLiveComments (rootId, after, sort) { @@ -23,7 +62,7 @@ export default function useLiveComments (rootId, after, sort) { }) useEffect(() => { - if (!data?.newComments) return + if (!data?.newComments?.comments?.length) return // sometimes new comments can arrive as orphans because their parent might not be in the cache yet // queue them up, retry until the parent shows up. @@ -44,90 +83,3 @@ export default function useLiveComments (rootId, after, sort) { } }, []) } - -// the item query is used to update the item's newComments field -export function itemUpdateQuery (client, id, sort, fn) { - client.cache.updateQuery({ - query: ITEM_FULL, - // 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 - variables: sort ? { id, sort } : { id } - }, (data) => { - if (!data) return data - return { item: fn(data.item) } - }) -} - -// update the newComments field of a nested comment fragment -export function commentUpdateFragment (client, id, fn) { - let result = client.cache.updateFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW_RECURSIVE, - fragmentName: 'CommentWithNewRecursive' - }, (data) => { - if (!data) return data - return fn(data) - }) - - // sometimes comments can 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 - if (!result) { - result = client.cache.updateFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW_LIMITED, - fragmentName: 'CommentWithNewLimited' - }, (data) => { - if (!data) return data - return fn(data) - }) - } - - return result -} - -function cacheNewComments (client, rootId, newComments, sort) { - const queuedComments = [] - - for (const newComment of newComments) { - const { parentId } = newComment - const topLevel = Number(parentId) === Number(rootId) - - // if the comment is a top level comment, update the item - if (topLevel) { - // merge the new comment into the item's newComments field, checking for duplicates - itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) - } else { - // if the comment is a reply, update the parent comment - // merge the new comment into the parent comment's newComments field, checking for duplicates - const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) - - if (!result) { - // parent not in cache, queue for retry - queuedComments.push(newComment) - } - } - } - - return { queuedComments } -} - -// merge new comment into item's newComments -// and prevent duplicates by checking if the comment is already in item's newComments or existing comments -function mergeNewComment (item, newComment) { - const existingNewComments = item.newComments || [] - const existingComments = item.comments?.comments || [] - - // is the incoming new comment already in item's new comments or existing comments? - if (existingNewComments.includes(newComment.id) || existingComments.some(c => c.id === newComment.id)) { - return item - } - - return { ...item, newComments: [...existingNewComments, newComment.id] } -} - -function getLatestCommentCreatedAt (comments, latest) { - return comments.reduce( - (max, { createdAt }) => (createdAt > max ? createdAt : max), - latest - ) -} diff --git a/lib/comments.js b/lib/comments.js index b41e5c754f..8f842d89dd 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -1,3 +1,7 @@ +import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments' +import { ITEM_FULL } from '../fragments/items' + +// updates the ncomments field of all ancestors of an item/comment in the cache export function updateAncestorsCommentCount (cache, ancestors, increment) { // update all ancestors ancestors.forEach(id => { @@ -12,3 +16,53 @@ export function updateAncestorsCommentCount (cache, ancestors, increment) { }) }) } + +// live comments - cache manipulations +// updates the item query in the cache +// this is used by live comments to update a top level item's newComments field +export function itemUpdateQuery (client, id, sort, fn) { + client.cache.updateQuery({ + query: ITEM_FULL, + // 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 + variables: sort ? { id, sort } : { id } + }, (data) => { + if (!data) return data + return { item: fn(data.item) } + }) +} + +// updates a comment fragment in the cache, with a fallback for comments lacking CommentsRecursive +export function commentUpdateFragment (client, id, fn) { + let result = client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' + }, (data) => { + if (!data) return data + return fn(data) + }) + + // sometimes comments can 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 + if (!result) { + result = client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_LIMITED, + fragmentName: 'CommentWithNewLimited' + }, (data) => { + if (!data) return data + return fn(data) + }) + } + + return result +} + +// finds the most recent createdAt timestamp from an array of comments +export function getLatestCommentCreatedAt (comments, latest) { + return comments.reduce( + (max, { createdAt }) => (createdAt > max ? createdAt : max), + latest + ) +} From 5d64ea7c8a35c8ed83a794901565c1fd102c332d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 10:15:06 +0200 Subject: [PATCH 31/50] hotfix: handle undefined item.comments.comments on dedupe --- components/comment.js | 2 +- components/show-new-comments.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/comment.js b/components/comment.js index b371e1dc34..9f3f28a1a3 100644 --- a/components/comment.js +++ b/components/comment.js @@ -263,7 +263,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && } {item.newComments?.length > 0 && (
- +
)} } diff --git a/components/show-new-comments.js b/components/show-new-comments.js index a7f16f1bb6..431cb8a990 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -13,7 +13,7 @@ import { // filters out new comments, by id, that already exist in the item's comments // preventing duplicate comments from being injected -function dedupeNewComments (newComments, comments) { +function dedupeNewComments (newComments, comments = []) { const existingIds = new Set(comments.map(c => c.id)) return newComments.filter(id => !existingIds.has(id)) } From cfd4a0c0da2592399c96561002a20722c91351a3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 14:06:55 +0200 Subject: [PATCH 32/50] hotfix: limited fragment for recursive comment collection; protect from null fragments; add missing deps to memoization --- components/show-new-comments.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 431cb8a990..259779aee5 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -2,7 +2,7 @@ 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 { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments' import { commentsViewedAfterComment } from '../lib/new-comments' import { itemUpdateQuery, @@ -80,14 +80,19 @@ function showAllNewCommentsRecursively (client, item, currentDepth = 1) { // 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 + // comments nearing the depth limit lack the recursive structure, so we also need to read the limited fragment const updatedItem = client.cache.readFragment({ id: `Item:${item.id}`, fragment: COMMENT_WITH_NEW_RECURSIVE, fragmentName: 'CommentWithNewRecursive' + }) || client.cache.readFragment({ + id: `Item:${item.id}`, + fragment: COMMENT_WITH_NEW_LIMITED, + fragmentName: 'CommentWithNewLimited' }) // recursively handle new comments in child comments - if (updatedItem.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { + if (updatedItem?.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { for (const childComment of updatedItem.comments.comments) { showAllNewCommentsRecursively(client, childComment, currentDepth + 1) } @@ -123,7 +128,7 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo return collectAllNewComments(item, depth) } return dedupeNewComments(newComments, comments) - }, [newComments, comments, item, depth]) + }, [newComments, comments, item, depth, topLevel]) const showNewComments = useCallback(() => { if (topLevel) { @@ -132,7 +137,7 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo } else { showAllNewCommentsRecursively(client, item, depth) } - }, [client, itemId, allNewComments, topLevel, sort]) + }, [client, itemId, allNewComments, topLevel, sort, item, depth]) if (allNewComments.length === 0) { return null From b8ec07eb3d6d326c7f7e61b6d8da69d6fc468bf0 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 14:36:26 +0200 Subject: [PATCH 33/50] docs: clarify ncomments updates --- components/show-new-comments.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 259779aee5..1ea93276b4 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -22,7 +22,7 @@ function dedupeNewComments (newComments, comments = []) { // 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 usto read the latest newComments from the cache, + // 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({ @@ -38,9 +38,10 @@ function prepareComments (client, newComments) { return fragment }).filter(Boolean) - // count the total number of new comments including its nested new comments + // count total comments being injected: each new comment + all their existing nested comments let totalNComments = freshNewComments.length for (const comment of freshNewComments) { + // add all nested comments (subtree) under this newly injected comment to the total totalNComments += (comment.ncomments || 0) } @@ -68,11 +69,11 @@ function prepareComments (client, 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) { + // handle new comments at this item level only const payload = prepareComments(client, dedupedNewComments) commentUpdateFragment(client, item.id, payload) } From 902ba22dd0285dcb62b8938cf507c1b1389da146 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 15:56:14 +0200 Subject: [PATCH 34/50] cleanup: remove unused export --- components/show-new-comments.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 1ea93276b4..38a10afe38 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -103,7 +103,7 @@ function showAllNewCommentsRecursively (client, item, 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) { +function collectAllNewComments (item, currentDepth = 1) { let allNewComments = [...(item.newComments || [])] // dedupe against the existing comments at this level From 41e339aae6c92883fbcc9e1dfa3a781b23c8ab24 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 17 Jul 2025 21:11:14 +0200 Subject: [PATCH 35/50] count and show only the direct new comments and recursively their children enhance: dedupe against existing comments only in the component enhance: recursive count/injection share the same logic --- components/show-new-comments.js | 93 ++++++++++++++------------------- components/use-live-comments.js | 4 +- 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 38a10afe38..c8ef668e4c 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,14 +1,15 @@ -import { useCallback, useMemo } from 'react' +import { useCallback } 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, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments' +import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' import { commentsViewedAfterComment } from '../lib/new-comments' import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt, - updateAncestorsCommentCount + updateAncestorsCommentCount, + readNestedCommentsFragment } from '../lib/comments' // filters out new comments, by id, that already exist in the item's comments @@ -66,81 +67,63 @@ function prepareComments (client, 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) { +// 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 (item.newComments && item.newComments.length > 0) { const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments) + onLevel(dedupedNewComments, item) - if (dedupedNewComments.length > 0) { - // handle new comments at this item level only - const payload = prepareComments(client, dedupedNewComments) - commentUpdateFragment(client, item.id, payload) + for (const newCommentId of dedupedNewComments) { + const newComment = readNestedCommentsFragment(client, newCommentId) + traverseNewComments(client, newComment, onLevel, currentDepth + 1) } } +} - // 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 - // comments nearing the depth limit lack the recursive structure, so we also need to read the limited fragment - const updatedItem = client.cache.readFragment({ - id: `Item:${item.id}`, - fragment: COMMENT_WITH_NEW_RECURSIVE, - fragmentName: 'CommentWithNewRecursive' - }) || client.cache.readFragment({ - id: `Item:${item.id}`, - fragment: COMMENT_WITH_NEW_LIMITED, - fragmentName: 'CommentWithNewLimited' - }) - - // 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 processes and displays all new comments and its children +// handles comment injection at each level, respecting depth limits +function injectNewComments (client, item, currentDepth = 1) { + traverseNewComments(client, item, (newComments, item) => { + if (newComments.length > 0) { + const payload = prepareComments(client, newComments) + commentUpdateFragment(client, item.id, payload) } - } + }, currentDepth) } -// 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 -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) +// counts all new comments for an item and its children +function countAllNewComments (client, item, currentDepth = 1) { + let totalNComments = 0 - if (currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { - for (const comment of item.comments.comments) { - allNewComments.push(...collectAllNewComments(comment, currentDepth + 1)) - } - } - } + // count by traversing all new comments and their children + traverseNewComments(client, item, (newComments) => { + totalNComments += newComments.length + }, currentDepth) - return allNewComments + return totalNComments } // 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() - const allNewComments = useMemo(() => { - if (!topLevel) { - return collectAllNewComments(item, depth) - } - return dedupeNewComments(newComments, comments) - }, [newComments, comments, item, depth, topLevel]) + const allNewComments = !topLevel + ? countAllNewComments(client, item, depth) + : dedupeNewComments(newComments, comments) const showNewComments = useCallback(() => { if (topLevel) { const payload = prepareComments(client, allNewComments) itemUpdateQuery(client, itemId, sort, payload) } else { - showAllNewCommentsRecursively(client, item, depth) + injectNewComments(client, item, depth) } - }, [client, itemId, allNewComments, topLevel, sort, item, depth]) + }, [client, itemId, allNewComments, sort, item, depth]) - if (allNewComments.length === 0) { + if (allNewComments === 0) { return null } @@ -149,8 +132,8 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - {allNewComments.length > 1 - ? `${allNewComments.length} new comments` + {allNewComments > 1 + ? `${allNewComments} new comments` : 'show new comment'}
diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 0d8b107af3..26edbcb8d3 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,11 +7,11 @@ import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } fro const POLL_INTERVAL = 1000 * 10 // 10 seconds // merge new comment into item's newComments -// and prevent duplicates by checking if the comment is already in item's newComments or existing comments +// and prevent duplicates by checking if the comment is already in item's newComments function mergeNewComment (item, newComment) { const existingNewComments = item.newComments || [] - // is the incoming new comment already in item's new comments or existing comments? + // is the incoming new comment already in item's new comments? if (existingNewComments.includes(newComment.id)) { return item } From 53b26112dc8b80e071f0e7de1c80652026226ecb Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 17 Jul 2025 21:32:41 +0200 Subject: [PATCH 36/50] fix regression on top level counting --- components/show-new-comments.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index c8ef668e4c..c046ff6f8d 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -110,20 +110,19 @@ function countAllNewComments (client, item, currentDepth = 1) { export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newComments = [], depth = 1 }) { const client = useApolloClient() - const allNewComments = !topLevel - ? countAllNewComments(client, item, depth) - : dedupeNewComments(newComments, comments) + const newCommentIds = topLevel ? dedupeNewComments(newComments, comments) : [] + const newCommentsCount = topLevel ? newCommentIds.length : countAllNewComments(client, item, depth) const showNewComments = useCallback(() => { if (topLevel) { - const payload = prepareComments(client, allNewComments) + const payload = prepareComments(client, newCommentIds) itemUpdateQuery(client, itemId, sort, payload) } else { injectNewComments(client, item, depth) } - }, [client, itemId, allNewComments, sort, item, depth]) + }, [topLevel, client, itemId, newCommentIds, sort, item, depth]) - if (allNewComments === 0) { + if (newCommentsCount === 0) { return null } @@ -132,8 +131,8 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - {allNewComments > 1 - ? `${allNewComments} new comments` + {newCommentsCount > 1 + ? `${newCommentsCount} new comments` : 'show new comment'}
From bd04ed9942c14189cd55f07e8d9685e67c317ba4 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 17 Jul 2025 21:33:47 +0200 Subject: [PATCH 37/50] hotfix: introduce readNestedCommentsFragment in lib/comments.js --- lib/comments.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/comments.js b/lib/comments.js index 8f842d89dd..85f15e0650 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -59,6 +59,21 @@ export function commentUpdateFragment (client, id, fn) { return result } +// reads a nested comments fragment from the cache +// this is used to read a comment and its children comments +// it has a fallback for comments nearing the depth limit, that lack the CommentsRecursive fragment +export function readNestedCommentsFragment (client, id) { + return client.cache.readFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' + }) || client.cache.readFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_LIMITED, + fragmentName: 'CommentWithNewLimited' + }) +} + // finds the most recent createdAt timestamp from an array of comments export function getLatestCommentCreatedAt (comments, latest) { return comments.reduce( From f3eb47fcd55c517197b78ff99d197dc2dadc47fe Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 17 Jul 2025 22:13:32 +0200 Subject: [PATCH 38/50] fix: count also existing comments of a new comment; cleanup: use readCommentFragment also for prepareComments; reduce freshNewComments usage --- components/show-new-comments.js | 35 ++++++++++++++------------------- lib/comments.js | 2 +- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index c046ff6f8d..65136ab192 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -2,14 +2,13 @@ import { useCallback } 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, - readNestedCommentsFragment + readCommentsFragment } from '../lib/comments' // filters out new comments, by id, that already exist in the item's comments @@ -21,22 +20,12 @@ function dedupeNewComments (newComments, comments = []) { // 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) { +function prepareComments ({ client, newCommentIds, 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 + const freshNewComments = newComments || newCommentIds.map(id => { + return readCommentsFragment(client, id) }).filter(Boolean) // count total comments being injected: each new comment + all their existing nested comments @@ -74,10 +63,13 @@ function traverseNewComments (client, item, onLevel, currentDepth = 1) { if (item.newComments && item.newComments.length > 0) { const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments) - onLevel(dedupedNewComments, item) + const freshNewComments = dedupedNewComments.map(id => { + return readCommentsFragment(client, id) + }).filter(Boolean) + + onLevel(freshNewComments, item) - for (const newCommentId of dedupedNewComments) { - const newComment = readNestedCommentsFragment(client, newCommentId) + for (const newComment of freshNewComments) { traverseNewComments(client, newComment, onLevel, currentDepth + 1) } } @@ -88,7 +80,7 @@ function traverseNewComments (client, item, onLevel, currentDepth = 1) { function injectNewComments (client, item, currentDepth = 1) { traverseNewComments(client, item, (newComments, item) => { if (newComments.length > 0) { - const payload = prepareComments(client, newComments) + const payload = prepareComments({ client, newComments }) commentUpdateFragment(client, item.id, payload) } }, currentDepth) @@ -101,6 +93,9 @@ function countAllNewComments (client, item, currentDepth = 1) { // 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 + } }, currentDepth) return totalNComments @@ -115,7 +110,7 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo const showNewComments = useCallback(() => { if (topLevel) { - const payload = prepareComments(client, newCommentIds) + const payload = prepareComments({ client, newCommentIds }) itemUpdateQuery(client, itemId, sort, payload) } else { injectNewComments(client, item, depth) diff --git a/lib/comments.js b/lib/comments.js index 85f15e0650..3db9f76639 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -62,7 +62,7 @@ export function commentUpdateFragment (client, id, fn) { // reads a nested comments fragment from the cache // this is used to read a comment and its children comments // it has a fallback for comments nearing the depth limit, that lack the CommentsRecursive fragment -export function readNestedCommentsFragment (client, id) { +export function readCommentsFragment (client, id) { return client.cache.readFragment({ id: `Item:${id}`, fragment: COMMENT_WITH_NEW_RECURSIVE, From 22799714ac361be2e8d01c17ea8b495c3cc7098f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 11:20:19 +0200 Subject: [PATCH 39/50] add support for comments at the deepest level fixes: - client-side navigation re-fetched all new comments because 'after' was cached, now the latest new comment time persists in sessionStorage enhancements: - use CommentWithNewMinimal fragment fallback for comments at the deepest level - tweak ReplyOnAnotherPage to show also how many direct new comments are there cleanup: - queue management is not needed anymore, therefore it has been removed --- components/comment.js | 2 +- components/use-live-comments.js | 47 +++++++++++++-------------------- fragments/comments.js | 6 ++--- lib/comments.js | 18 ++++++++++--- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/components/comment.js b/components/comment.js index 9f3f28a1a3..898c69900b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -306,7 +306,7 @@ function ReplyOnAnotherPage ({ item }) { let text = 'reply on another page' if (item.ncomments > 0) { - text = `view all ${item.ncomments} replies` + text = `view all ${item.ncomments + item.newComments?.length} replies` } return ( diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 26edbcb8d3..3721bac0b1 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -1,7 +1,7 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS } from '../fragments/comments' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } from '../lib/comments' const POLL_INTERVAL = 1000 * 10 // 10 seconds @@ -20,8 +20,6 @@ function mergeNewComment (item, newComment) { } function cacheNewComments (client, rootId, newComments, sort) { - const queuedComments = [] - for (const newComment of newComments) { const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) @@ -33,24 +31,23 @@ function cacheNewComments (client, rootId, newComments, sort) { } else { // if the comment is a reply, update the parent comment // merge the new comment into the parent comment's newComments field, checking for duplicates - const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) - - if (!result) { - // parent not in cache, queue for retry - queuedComments.push(newComment) - } + commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } } - - return { queuedComments } } // useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt // and inserts them into the newComment client field of their parent comment/post. export default function useLiveComments (rootId, after, sort) { + const latestKey = `liveCommentsLatest:${rootId}` const client = useApolloClient() - const [latest, setLatest] = useState(after) - const queue = useRef([]) + const [latest, setLatest] = useState(() => { + // if we're on the client, get the latest timestamp from session storage, otherwise use the passed after timestamp + if (typeof window !== 'undefined') { + return window.sessionStorage.getItem(latestKey) || after + } + return after + }) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} @@ -62,24 +59,18 @@ export default function useLiveComments (rootId, after, sort) { }) useEffect(() => { + console.log('data', data) if (!data?.newComments?.comments?.length) return - // sometimes new comments can arrive as orphans because their parent might not be in the cache yet - // queue them up, retry until the parent shows up. - const newComments = [...data.newComments.comments, ...queue.current] - const { queuedComments } = cacheNewComments(client, rootId, newComments, sort) - - // keep the queued comments for the next poll - queue.current = queuedComments + // merge and cache new comments in their parent comment/post + cacheNewComments(client, rootId, data.newComments.comments, sort) // update latest timestamp to the latest comment created at - setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) - }, [data, client, rootId, sort]) - - // cleanup queue on unmount to prevent memory leaks - useEffect(() => { - return () => { - queue.current = [] + // save it to session storage, to persist between client-side navigations + const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest) + setLatest(newLatest) + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(latestKey, newLatest) } - }, []) + }, [data, client, rootId, sort, latest]) } diff --git a/fragments/comments.js b/fragments/comments.js index 06b1605cba..be6385d2df 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -147,12 +147,10 @@ export const COMMENT_WITH_NEW_LIMITED = gql` } ` -// TODO: fragment for comments without item.comments field -// TODO: remove if useless to pursue -export const COMMENT_WITH_NEW = gql` +export const COMMENT_WITH_NEW_MINIMAL = gql` ${COMMENT_FIELDS} - fragment CommentWithNew on Item { + fragment CommentWithNewMinimal on Item { ...CommentFields newComments @client } diff --git a/lib/comments.js b/lib/comments.js index 3db9f76639..a892cc83cf 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -1,4 +1,4 @@ -import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments' +import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_MINIMAL } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' // updates the ncomments field of all ancestors of an item/comment in the cache @@ -32,7 +32,7 @@ export function itemUpdateQuery (client, id, sort, fn) { }) } -// updates a comment fragment in the cache, with a fallback for comments lacking CommentsRecursive +// updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether export function commentUpdateFragment (client, id, fn) { let result = client.cache.updateFragment({ id: `Item:${id}`, @@ -43,7 +43,7 @@ export function commentUpdateFragment (client, id, fn) { return fn(data) }) - // sometimes comments can 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 if (!result) { result = client.cache.updateFragment({ @@ -56,6 +56,18 @@ export function commentUpdateFragment (client, id, fn) { }) } + // at the deepest level, the comment can't have any children, here we update only the newComments field. + if (!result) { + result = client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_MINIMAL, + fragmentName: 'CommentWithNewMinimal' + }, (data) => { + if (!data) return data + return fn(data) + }) + } + return result } From 5d3f3bd5cd4266a9e295bd878af5561eabfd9b8e Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 11:24:06 +0200 Subject: [PATCH 40/50] cleanup: remove logs --- components/use-live-comments.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 3721bac0b1..60d93dc755 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -59,7 +59,6 @@ export default function useLiveComments (rootId, after, sort) { }) useEffect(() => { - console.log('data', data) if (!data?.newComments?.comments?.length) return // merge and cache new comments in their parent comment/post From c71accb7c6e727c69999fb46d14f2b8f70bb4f9f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 11:39:28 +0200 Subject: [PATCH 41/50] revert counting on ReplyOnAnotherPage, TODO for enhancements PR --- components/comment.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/comment.js b/components/comment.js index 898c69900b..9f3f28a1a3 100644 --- a/components/comment.js +++ b/components/comment.js @@ -306,7 +306,7 @@ function ReplyOnAnotherPage ({ item }) { let text = 'reply on another page' if (item.ncomments > 0) { - text = `view all ${item.ncomments + item.newComments?.length} replies` + text = `view all ${item.ncomments} replies` } return ( From 9736661f6f84b4da99cfb0152b0ac44801c6ae9b Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 11:54:03 +0200 Subject: [PATCH 42/50] move ShowNewComments to CommentsHeader for top level comments --- components/comments.js | 11 +++++++---- components/show-new-comments.js | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/components/comments.js b/components/comments.js index fa8ce9c7b7..d3f3dbbea2 100644 --- a/components/comments.js +++ b/components/comments.js @@ -11,7 +11,7 @@ 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 }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, newComments, comments, parentId }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -30,6 +30,9 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} + {newComments?.length > 0 && ( + + )}
: null} - {newComments?.length > 0 && ( - - )} {pins.map(item => ( diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 65136ab192..6059bc109f 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -124,7 +124,7 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo return (
{newCommentsCount > 1 ? `${newCommentsCount} new comments` From 38237dbd404183671452affff917dfa426549c03 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 16:22:08 +0200 Subject: [PATCH 43/50] fix: update commentsViewedAfterComment to support ncomments --- components/show-new-comments.js | 4 ++-- lib/new-comments.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 6059bc109f..8f88df070c 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -41,10 +41,10 @@ function prepareComments ({ client, newCommentIds, newComments }) { // 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 + // as such, the next visit will not outline other new comments that are older than this one. const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt) const rootId = data.path.split('.')[0] - commentsViewedAfterComment(rootId, latestCommentCreatedAt) + commentsViewedAfterComment(rootId, latestCommentCreatedAt, totalNComments) // return the updated item with the new comments injected return { diff --git a/lib/new-comments.js b/lib/new-comments.js index 20ae8dd493..61923bc82d 100644 --- a/lib/new-comments.js +++ b/lib/new-comments.js @@ -8,10 +8,10 @@ export function commentsViewed (item) { } } -export function commentsViewedAfterComment (rootId, createdAt) { +export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) { window.localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${rootId}`, new Date(createdAt).getTime()) const existingRootComments = window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${rootId}`) || 0 - window.localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1) + window.localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + ncomments) } export function commentsViewedAt (item) { From b1b49d737ecf9a080f146f8efa4cfa7f3eb10d9c Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 21:01:54 +0200 Subject: [PATCH 44/50] fix typo, lint --- lib/new-comments.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-comments.js b/lib/new-comments.js index b7c4d036cf..cf84a387c9 100644 --- a/lib/new-comments.js +++ b/lib/new-comments.js @@ -16,7 +16,7 @@ export function commentsViewedNum (itemId) { return Number(window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${itemId}`)) } -export function commentsViewedAfterComment (rootId, createdAt ncomments = 1) { +export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) { window.localStorage.setItem(`${COMMENTS_VIEW_PREFIX}:${rootId}`, new Date(createdAt).getTime()) window.localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, commentsViewedNum(rootId) + ncomments) } From df6b7f8ba19ce0a34ea02ec5919ae25b7dc95f25 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Jul 2025 21:39:28 +0200 Subject: [PATCH 45/50] cleanup: remove old CSS --- components/header.module.css | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/components/header.module.css b/components/header.module.css index 33cf610325..e4be5d3da7 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -110,31 +110,3 @@ 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; - } -} From 6a1782508e6afea778a58790bf1bf25f923c86d3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Jul 2025 01:08:52 +0200 Subject: [PATCH 46/50] enhance: inject topLevel and its children new comments, simplify injection logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - top-level and nested comment handling share the same recursion logic - ShowNewComments references the item object for every type of comments — note: item from item-full.js is passed to comments.js - depth now starts at 0 to support top level comments - injection and counting now reach the deepest level, updating also the deepest comment --- components/comment.js | 2 +- components/comments.js | 12 +++---- components/item-full.js | 2 +- components/show-new-comments.js | 56 +++++++++++++++++---------------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/components/comment.js b/components/comment.js index 9f3f28a1a3..539bdf37e1 100644 --- a/components/comment.js +++ b/components/comment.js @@ -263,7 +263,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && } {item.newComments?.length > 0 && (
- +
)} } diff --git a/components/comments.js b/components/comments.js index d3f3dbbea2..443cea1bc9 100644 --- a/components/comments.js +++ b/components/comments.js @@ -11,7 +11,7 @@ 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, newComments, comments, parentId }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, item }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -30,8 +30,8 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {newComments?.length > 0 && ( - + {item.newComments?.length > 0 && ( + )}
@@ -69,7 +69,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm export default function Comments ({ parentId, pinned, bio, parentCreatedAt, - commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props + commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, item, ...props }) { const router = useRouter() // fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache @@ -93,9 +93,7 @@ export default function Comments ({ query: sort === defaultCommentSort(pinned, bio, parentCreatedAt) ? undefined : { sort } }, { scroll: false }) }} - newComments={newComments} - comments={comments} - parentId={parentId} + item={item} /> : null} {pins.map(item => ( diff --git a/components/item-full.js b/components/item-full.js index 5129f55cd3..8150bfcb13 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -191,8 +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} + item={item} />
} diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 8f88df070c..8d164892c9 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -20,17 +20,11 @@ function dedupeNewComments (newComments, comments = []) { // 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, newCommentIds, newComments }) { +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 || newCommentIds.map(id => { - return readCommentsFragment(client, id) - }).filter(Boolean) - // count total comments being injected: each new comment + all their existing nested comments - let totalNComments = freshNewComments.length - for (const comment of freshNewComments) { + 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) } @@ -40,16 +34,16 @@ function prepareComments ({ client, newCommentIds, newComments }) { 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 + // 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(freshNewComments, data.createdAt) + 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: [...freshNewComments, ...data.comments.comments] }, + comments: { ...data.comments, comments: [...newComments, ...data.comments.comments] }, ncomments: data.ncomments + totalNComments, newComments: [] } @@ -59,15 +53,20 @@ function prepareComments ({ client, newCommentIds, newComments }) { // 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 (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) }).filter(Boolean) - onLevel(freshNewComments, item) + // passing currentDepth allows children of top level comments + // to be updated by the commentUpdateFragment + onLevel(freshNewComments, item, currentDepth) for (const newComment of freshNewComments) { traverseNewComments(client, newComment, onLevel, currentDepth + 1) @@ -77,11 +76,18 @@ function traverseNewComments (client, item, onLevel, currentDepth = 1) { // recursively processes and displays all new comments and its children // handles comment injection at each level, respecting depth limits -function injectNewComments (client, item, currentDepth = 1) { - traverseNewComments(client, item, (newComments, item) => { +function injectNewComments (client, item, currentDepth, sort) { + traverseNewComments(client, item, (newComments, item, depth) => { if (newComments.length > 0) { const payload = prepareComments({ client, newComments }) - commentUpdateFragment(client, item.id, payload) + + // used to determine if by iterating through the new comments + // we are injecting topLevels (depth 0) or not + if (depth === 0) { + itemUpdateQuery(client, item.id, sort, payload) + } else { + commentUpdateFragment(client, item.id, payload) + } } }, currentDepth) } @@ -102,20 +108,16 @@ function countAllNewComments (client, item, currentDepth = 1) { } // 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 }) { +export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) { const client = useApolloClient() - const newCommentIds = topLevel ? dedupeNewComments(newComments, comments) : [] - const newCommentsCount = topLevel ? newCommentIds.length : countAllNewComments(client, item, depth) + // recurse through all new comments and their children + const newCommentsCount = countAllNewComments(client, item, depth) const showNewComments = useCallback(() => { - if (topLevel) { - const payload = prepareComments({ client, newCommentIds }) - itemUpdateQuery(client, itemId, sort, payload) - } else { - injectNewComments(client, item, depth) - } - }, [topLevel, client, itemId, newCommentIds, sort, item, depth]) + // top level comments are injected from depth 0, other comments are injected from their depth + injectNewComments(client, item, depth, sort) + }, [topLevel, client, sort, item, depth]) if (newCommentsCount === 0) { return null From 1d6e69b93958c904caa836501d052b15011a7119 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Jul 2025 01:20:14 +0200 Subject: [PATCH 47/50] cleanup: remove unused topLevel prop --- components/show-new-comments.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 8d164892c9..519bee370b 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -108,7 +108,7 @@ function countAllNewComments (client, item, currentDepth = 1) { } // ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field -export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) { +export function ShowNewComments ({ item, sort, depth = 0 }) { const client = useApolloClient() // recurse through all new comments and their children @@ -117,7 +117,7 @@ export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) { const showNewComments = useCallback(() => { // top level comments are injected from depth 0, other comments are injected from their depth injectNewComments(client, item, depth, sort) - }, [topLevel, client, sort, item, depth]) + }, [client, sort, item, depth]) if (newCommentsCount === 0) { return null From 29e078b4c8e5af32f8e92bcc5312f26b1266687c Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 20 Jul 2025 15:24:55 +0200 Subject: [PATCH 48/50] fix: deepest comments don't have CommentsRecursive structure, don't access it on injection --- components/show-new-comments.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 519bee370b..8b99cc2e9b 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -43,7 +43,7 @@ function prepareComments ({ client, newComments }) { // return the updated item with the new comments injected return { ...data, - comments: { ...data.comments, comments: [...newComments, ...data.comments.comments] }, + comments: { ...data.comments, comments: [...newComments, ...(data.comments?.comments || [])] }, ncomments: data.ncomments + totalNComments, newComments: [] } @@ -115,7 +115,8 @@ export function ShowNewComments ({ item, sort, depth = 0 }) { const newCommentsCount = countAllNewComments(client, item, depth) const showNewComments = useCallback(() => { - // top level comments are injected from depth 0, other comments are injected from their depth + // 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) }, [client, sort, item, depth]) From 60ba2225f747bf3826d7e0d5ddcfaac90da55935 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 21 Jul 2025 10:43:42 +0200 Subject: [PATCH 49/50] move top level ShowNewComments above CommentsHeader; preserve space to avoid vertical layout shifting --- components/comments.js | 4 +--- components/show-new-comments.js | 11 ++++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/components/comments.js b/components/comments.js index 443cea1bc9..cc4c7e2856 100644 --- a/components/comments.js +++ b/components/comments.js @@ -30,9 +30,6 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {item.newComments?.length > 0 && ( - - )}
+ {comments?.length > 0 ? 0 ? countAllNewComments(client, item, depth) : 0 const showNewComments = useCallback(() => { // a top level comment doesn't have depth, we pass 0 to signify this @@ -120,19 +120,16 @@ export function ShowNewComments ({ item, sort, depth = 0 }) { injectNewComments(client, item, depth, sort) }, [client, sort, item, depth]) - if (newCommentsCount === 0) { - return null - } - return ( -
0 ? 'visible' : 'hidden' }} > {newCommentsCount > 1 ? `${newCommentsCount} new comments` : 'show new comment'}
-
+ ) } From 9d1ddc5948f238b3eb68a3c2be1243d10315ef07 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 21 Jul 2025 11:14:09 +0200 Subject: [PATCH 50/50] cleanup: remove unused item on CommentsHeader --- components/comments.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/comments.js b/components/comments.js index cc4c7e2856..4d1c0bbfa8 100644 --- a/components/comments.js +++ b/components/comments.js @@ -11,7 +11,7 @@ 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, item }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -91,7 +91,6 @@ export default function Comments ({ query: sort === defaultCommentSort(pinned, bio, parentCreatedAt) ? undefined : { sort } }, { scroll: false }) }} - item={item} /> : null} {pins.map(item => (