From f115148594479c305448b699328684138aae86f3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 05:34:20 -0500 Subject: [PATCH 01/43] 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 781409e09..ee7d89868 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 a40a99ae1..95bbaf788 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 73e64d0c3..d0e04a466 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 6f24ab615..215993d64 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 cb5d86416..d0af8abfa 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 72c60b9c6..02621981b 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 000000000..9d6fa94ed --- /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 2fd28d0f1..8dab8904d 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 151587a20..95eed0421 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 3739ba3fd..a7dcdb01b 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/43] 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 ee7d89868..35875993c 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 d0e04a466..427feadb5 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 d0af8abfa..f77581d1e 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 9d6fa94ed..44c46877b 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 a7dcdb01b..dc7b8127b 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/43] 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 95bbaf788..4d19c7440 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 427feadb5..ffc386a22 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 f77581d1e..024b7c5ea 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 8dab8904d..cbaddc5a7 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/43] 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 ffc386a22..24a0ea6bc 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 cbaddc5a7..f7e325c4c 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/43] 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 24a0ea6bc..500cd7d6e 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/43] 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 500cd7d6e..41bb94fb9 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 44c46877b..8c1fdadd8 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/43] 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 41bb94fb9..8f17e06d6 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 8c1fdadd8..5f3311dac 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/43] 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 5f3311dac..a7321059d 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 024b7c5ea..23f7ce8d8 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/43] 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 a7321059d..aac7fdfd8 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 23f7ce8d8..902763c91 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 1134e8480..cc3027342 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/43] 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 902763c91..e922bd168 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/43] 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 aac7fdfd8..f8d429f8d 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/43] 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 f8d429f8d..82a5fe024 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 e922bd168..cdf44b4fc 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 cc3027342..33cf61032 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/43] 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 82a5fe024..020e4011c 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 cdf44b4fc..a5696c3ec 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/43] 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 8f17e06d6..d638088a5 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 020e4011c..e66575079 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/43] 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 d638088a5..8f17e06d6 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 e66575079..0532f2080 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/43] 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 8f17e06d6..a7667ee69 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 a5696c3ec..54eace4ca 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 0532f2080..7300d64e3 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/43] 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 7300d64e3..44c8fe3ba 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/43] 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 a7667ee69..e053d215f 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/43] 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 54eace4ca..34da7c668 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 44c8fe3ba..505a82972 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/43] 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 34da7c668..c4fb8a6c6 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 505a82972..37edef402 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/43] 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 4485f6ca9..02a4ace5e 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 37edef402..66b637311 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/43] 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 66b637311..56399e881 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/43] 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 56399e881..202d1a87d 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/43] 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 c4fb8a6c6..6c3089e47 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 202d1a87d..1d8c6bd61 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/43] 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 1d8c6bd61..027bb8cef 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/43] 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 e053d215f..9dd2d2983 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 6c3089e47..542b63469 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 000000000..424b47a51 --- /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 027bb8cef..e4aaca4bd 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/43] 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 e4aaca4bd..bbaba9935 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/43] 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 bbaba9935..e7868696d 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/43] 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 9dd2d2983..3159026c6 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 542b63469..fa8ce9c7b 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 02621981b..5129f55cd 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 ec12d0a5f..e89816c70 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 424b47a51..1951b7814 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 e7868696d..f5396444a 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 f7e325c4c..06b1605cb 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 000000000..b41e5c754 --- /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 31b893720b2449ba67b12ddc6fab8de5cc071f52 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 7 Jul 2025 21:42:19 +0200 Subject: [PATCH 30/43] enhance: give the possibility to show all new comments of a thread, even nested --- components/comment.js | 8 ++-- components/show-new-comments.js | 67 ++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/components/comment.js b/components/comment.js index 3159026c6..c5f8099a3 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 './show-new-comments' +import { ShowAllNewComments, ShowNewComments } from './show-new-comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -262,9 +262,9 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
- {item.newComments?.length > 0 && ( - - )} + {item.path.split('.').length === 2 + ? + : }
} {children} diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 1951b7814..e5008352a 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -42,14 +42,71 @@ function prepareComments (client, newComments) { } } +function showAllNewCommentsRecursively (client, item) { + // 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) + } + } + + // recursively handle new comments in child comments + if (item.comments?.comments) { + for (const childComment of item.comments.comments) { + showAllNewCommentsRecursively(client, childComment) + } + } +} + +function dedupeNewComments (newComments, comments) { + const existingIds = new Set(comments.map(c => c.id)) + return newComments.filter(id => !existingIds.has(id)) +} + +function collectAllNewComments (item) { + const allNewComments = [...(item.newComments || [])] + if (item.comments?.comments) { + for (const comment of item.comments.comments) { + allNewComments.push(...collectAllNewComments(comment)) + } + } + return allNewComments +} + +// TODO: merge this with ShowNewComments +export function ShowAllNewComments ({ item }) { + const client = useApolloClient() + + const newComments = useMemo(() => collectAllNewComments(item), [item]) + + const showNewComments = useCallback(() => { + showAllNewCommentsRecursively(client, item) + }, [client, item]) + + if (newComments.length === 0) { + return null + } + + return ( +
+ {newComments.length > 1 + ? `show all ${newComments.length} new comments` + : 'show new comment'} +
+
+ ) +} + // ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field export function ShowNewComments ({ topLevel = false, comments, newComments = [], itemId, sort }) { const client = useApolloClient() - - const dedupedNewComments = useMemo(() => { - const existingIds = new Set(comments.map(c => c.id)) - return newComments.filter(id => !existingIds.has(id)) - }, [newComments, comments]) + const dedupedNewComments = useMemo(() => dedupeNewComments(newComments, comments), [newComments, comments]) const showNewComments = useCallback(() => { // fetch the latest version of the comments from the cache by their ids From a702b7bcd08291c22d0315b5121a1be7e2a152d6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 9 Jul 2025 09:21:02 +0200 Subject: [PATCH 31/43] enhance: change favicon on new comments; warn: prop-drilling --- components/comment.js | 6 +++--- components/comments.js | 18 +++++++++++++----- components/show-new-comments.js | 6 ++++-- components/use-live-comments.js | 4 +++- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/components/comment.js b/components/comment.js index c5f8099a3..6cc99dc43 100644 --- a/components/comment.js +++ b/components/comment.js @@ -98,7 +98,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) { export default function Comment ({ item, children, replyOpen, includeParent, topLevel, - rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry + rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry, setHasNewComments }) { const [edit, setEdit] = useState() const { me } = useMe() @@ -263,8 +263,8 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
{item.path.split('.').length === 2 - ? - : } + ? + : }
} {children} diff --git a/components/comments.js b/components/comments.js index fa8ce9c7b..035300575 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,4 +1,4 @@ -import { Fragment, useMemo } from 'react' +import { Fragment, useMemo, useState } from 'react' import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' @@ -10,6 +10,8 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './use-live-comments' import { ShowNewComments } from './show-new-comments' +import Head from 'next/head' +import { useHasNewNotes } from './use-has-new-notes' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -68,14 +70,20 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { + const [hasNewComments, setHasNewComments] = useState(false) const router = useRouter() // fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache - useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort) + useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort, setHasNewComments) + console.log('hasNewComments', hasNewComments) + const hasNewNotes = useHasNewNotes() const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) return ( <> + + + {comments?.length > 0 ? : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( - + ))} {comments.filter(({ position }) => !position).map(item => ( - + ))} {ncomments > FULL_COMMENTS_THRESHOLD && collectAllNewComments(item), [item]) const showNewComments = useCallback(() => { showAllNewCommentsRecursively(client, item) + setHasNewComments(false) }, [client, item]) if (newComments.length === 0) { @@ -104,7 +105,7 @@ export function ShowAllNewComments ({ item }) { } // 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 = false, comments, newComments = [], itemId, sort, setHasNewComments }) { const client = useApolloClient() const dedupedNewComments = useMemo(() => dedupeNewComments(newComments, comments), [newComments, comments]) @@ -117,6 +118,7 @@ export function ShowNewComments ({ topLevel = false, comments, newComments = [], } else { commentUpdateFragment(client, itemId, payload) } + setHasNewComments(false) }, [client, itemId, dedupedNewComments, topLevel, sort]) if (dedupedNewComments.length === 0) { diff --git a/components/use-live-comments.js b/components/use-live-comments.js index f5396444a..0b6cf5358 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -8,7 +8,7 @@ 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) { +export default function useLiveComments (rootId, after, sort, setHasNewComments) { const client = useApolloClient() const [latest, setLatest] = useState(after) const queue = useRef([]) @@ -35,6 +35,8 @@ export default function useLiveComments (rootId, after, sort) { // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + + setHasNewComments(true) }, [data, client, rootId, sort]) // cleanup queue on unmount to prevent memory leaks From 5f0ca6c00856860ab67b329ecdfc03d9bd64c631 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 9 Jul 2025 18:05:49 +0200 Subject: [PATCH 32/43] refactor: merge ShowAllNewComments with ShowNewComments, better usage of props --- components/comment.js | 8 ++--- components/comments.js | 2 +- components/show-new-comments.js | 64 +++++++++++++-------------------- 3 files changed, 28 insertions(+), 46 deletions(-) diff --git a/components/comment.js b/components/comment.js index 6cc99dc43..4db806f2a 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 { ShowAllNewComments, ShowNewComments } from './show-new-comments' +import { ShowNewComments } from './show-new-comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -262,9 +262,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
- {item.path.split('.').length === 2 - ? - : } +
} {children} @@ -273,7 +271,7 @@ export default function Comment ({ ? ( <> {item.comments.comments.map((item) => ( - + ))} {item.comments.comments.length < item.nDirectComments && } diff --git a/components/comments.js b/components/comments.js index 035300575..91a6cae01 100644 --- a/components/comments.js +++ b/components/comments.js @@ -101,7 +101,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 9277a1169..6164652c8 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -76,52 +76,36 @@ function collectAllNewComments (item) { return allNewComments } -// TODO: merge this with ShowNewComments -export function ShowAllNewComments ({ item, setHasNewComments }) { +export function ShowNewComments ({ sort, comments, newComments = [], itemId, item, setHasNewComments }) { const client = useApolloClient() - const newComments = useMemo(() => collectAllNewComments(item), [item]) - - const showNewComments = useCallback(() => { - showAllNewCommentsRecursively(client, item) - setHasNewComments(false) - }, [client, item]) - - if (newComments.length === 0) { - return null - } - - return ( -
- {newComments.length > 1 - ? `show all ${newComments.length} new comments` - : 'show new comment'} -
-
- ) -} - -// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field -export function ShowNewComments ({ topLevel = false, comments, newComments = [], itemId, sort, setHasNewComments }) { - const client = useApolloClient() - const dedupedNewComments = useMemo(() => dedupeNewComments(newComments, comments), [newComments, comments]) + const topLevel = !!sort + // if item is provided, we're showing all new comments for a thread, + // otherwise we're showing new comments for a comment + const isThread = !!item + const allNewComments = useMemo(() => { + if (isThread) { + return collectAllNewComments(item) + } + return dedupeNewComments(newComments, comments) + }, [isThread, item, newComments, comments]) const showNewComments = useCallback(() => { - // fetch the latest version of the comments from the cache by their ids - const payload = prepareComments(client, dedupedNewComments) - - if (topLevel) { - itemUpdateQuery(client, itemId, sort, payload) + if (isThread) { + showAllNewCommentsRecursively(client, item) } else { - commentUpdateFragment(client, itemId, payload) + // fetch the latest version of the comments from the cache by their ids + const payload = prepareComments(client, allNewComments) + if (topLevel) { + itemUpdateQuery(client, itemId, sort, payload) + } else { + commentUpdateFragment(client, itemId, payload) + } } setHasNewComments(false) - }, [client, itemId, dedupedNewComments, topLevel, sort]) + }, [client, itemId, allNewComments, topLevel, sort]) - if (dedupedNewComments.length === 0) { + if (allNewComments.length === 0) { return null } @@ -130,8 +114,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 + ? `${isThread ? 'show all ' : ''}${allNewComments.length} new comments` : 'show new comment'}
From 0c58834380a69a700b63ccdc0beab0183bb8c858 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 10 Jul 2025 00:27:10 +0200 Subject: [PATCH 33/43] hotfix: isThread should be recognized when an item has 2 items in its path --- components/show-new-comments.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 6164652c8..9f6787004 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -76,13 +76,14 @@ function collectAllNewComments (item) { return allNewComments } +// TODO: Fix bug where new comments out of depth are not shown export function ShowNewComments ({ sort, comments, newComments = [], itemId, item, setHasNewComments }) { const client = useApolloClient() const topLevel = !!sort // if item is provided, we're showing all new comments for a thread, // otherwise we're showing new comments for a comment - const isThread = !!item + const isThread = item.path.split('.').length === 2 const allNewComments = useMemo(() => { if (isThread) { return collectAllNewComments(item) From ee18316a627ec3c0c6dda8a857817f52c2e88e42 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 10 Jul 2025 20:50:08 +0200 Subject: [PATCH 34/43] fix regression: topLevel comments not showing --- components/comments.js | 3 ++- components/show-new-comments.js | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/comments.js b/components/comments.js index 91a6cae01..499f3860e 100644 --- a/components/comments.js +++ b/components/comments.js @@ -75,6 +75,7 @@ export default function Comments ({ // fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort, setHasNewComments) console.log('hasNewComments', hasNewComments) + console.log('sort', router.query.sort) const hasNewNotes = useHasNewNotes() const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -101,7 +102,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 9f6787004..70a06f737 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -77,13 +77,12 @@ function collectAllNewComments (item) { } // TODO: Fix bug where new comments out of depth are not shown -export function ShowNewComments ({ sort, comments, newComments = [], itemId, item, setHasNewComments }) { +export function ShowNewComments ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [] }) { const client = useApolloClient() - const topLevel = !!sort // if item is provided, we're showing all new comments for a thread, // otherwise we're showing new comments for a comment - const isThread = item.path.split('.').length === 2 + const isThread = !topLevel && item?.path.split('.').length === 2 const allNewComments = useMemo(() => { if (isThread) { return collectAllNewComments(item) From d1abb2ec0299e3731dbe1968cc2174183cdc1069 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 10 Jul 2025 21:22:23 +0200 Subject: [PATCH 35/43] fix: avoid trying to show new comments even after the depth limit; todo: two recursive counts might be too much --- components/comment.js | 2 +- components/show-new-comments.js | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/components/comment.js b/components/comment.js index 4db806f2a..acd23dc28 100644 --- a/components/comment.js +++ b/components/comment.js @@ -262,7 +262,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
- +
} {children} diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 70a06f737..d9e6e466b 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -4,6 +4,7 @@ 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' function prepareComments (client, newComments) { return (data) => { @@ -42,7 +43,7 @@ function prepareComments (client, newComments) { } } -function showAllNewCommentsRecursively (client, item) { +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) @@ -54,9 +55,9 @@ function showAllNewCommentsRecursively (client, item) { } // recursively handle new comments in child comments - if (item.comments?.comments) { + if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { for (const childComment of item.comments.comments) { - showAllNewCommentsRecursively(client, childComment) + showAllNewCommentsRecursively(client, childComment, currentDepth + 1) } } } @@ -66,18 +67,19 @@ function dedupeNewComments (newComments, comments) { return newComments.filter(id => !existingIds.has(id)) } -function collectAllNewComments (item) { +function collectAllNewComments (item, currentDepth = 1) { const allNewComments = [...(item.newComments || [])] - if (item.comments?.comments) { + if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { for (const comment of item.comments.comments) { - allNewComments.push(...collectAllNewComments(comment)) + console.log('comment', comment) + console.log('currentDepth', currentDepth) + allNewComments.push(...collectAllNewComments(comment, currentDepth + 1)) } } return allNewComments } -// TODO: Fix bug where new comments out of depth are not shown -export function ShowNewComments ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [] }) { +export function ShowNewComments ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) { const client = useApolloClient() // if item is provided, we're showing all new comments for a thread, @@ -85,14 +87,15 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, setHa const isThread = !topLevel && item?.path.split('.').length === 2 const allNewComments = useMemo(() => { if (isThread) { - return collectAllNewComments(item) + // TODO: well are we only collecting all new comments just for a fancy UI? + return collectAllNewComments(item, depth) } return dedupeNewComments(newComments, comments) - }, [isThread, item, newComments, comments]) + }, [isThread, item, newComments, comments, depth]) const showNewComments = useCallback(() => { if (isThread) { - showAllNewCommentsRecursively(client, item) + showAllNewCommentsRecursively(client, item, depth) } else { // fetch the latest version of the comments from the cache by their ids const payload = prepareComments(client, allNewComments) From 535afb803995c900dffea73f0236e9d6e9b9fe19 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 12 Jul 2025 13:19:25 +0200 Subject: [PATCH 36/43] favicon-new-comment, fix favicon showing also when there aren't new comments --- components/comments.js | 7 +++---- components/use-live-comments.js | 2 +- public/favicon-new-comment.png | Bin 0 -> 10411 bytes 3 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 public/favicon-new-comment.png diff --git a/components/comments.js b/components/comments.js index 499f3860e..0c4b98eed 100644 --- a/components/comments.js +++ b/components/comments.js @@ -70,12 +70,11 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { - const [hasNewComments, setHasNewComments] = useState(false) const router = useRouter() + const [hasNewComments, setHasNewComments] = useState(false) // fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort, setHasNewComments) - console.log('hasNewComments', hasNewComments) - console.log('sort', router.query.sort) + // xxx const hasNewNotes = useHasNewNotes() const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -83,7 +82,7 @@ export default function Comments ({ return ( <> - + {comments?.length > 0 ? { - 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. diff --git a/public/favicon-new-comment.png b/public/favicon-new-comment.png new file mode 100644 index 0000000000000000000000000000000000000000..53f48c988123807e3cc5311f7af0579b67b40c8c GIT binary patch literal 10411 zcmd6MWmr^Q*zTSIq(eGo1VI##5+tN0rMp9B%1Y%pm!#kYeIIG4v9Pg^7{2^$W#5Ppdg5BMUybm+ zM2}g$KGJShv+wB>+h@dSV+Q8w7xee z(aHCEHmbyZdfA_}PPpgCc|x1~fSKTq3zUW^!W8Pz{|wr|eBmH)1fJHo8zAC)#u`b9 z9S0DYzvuM40)T|}n-~#h5HOsQzX1R(yX1OBiDYs;7y$4o$Nz1mC_2tx;!+r1#$TEi z7#Z03*(VX4SFn!+V$U|A)L1{slSL`iVEnVFe=1QZbDpQI5!C+W;KkipCDijja3FhT zM4r__-V%a|D%yuZWc-pp?D?1IS-KGnveal)I%Dbg@z8w8UKp`5U3Z^TKDb!SDo$UT z{*={;m@i6CRN9v-+m*B8z4;L zPtr*8P1^abrtYjRJmvWtTdhPLzr;Ypr$AD@MrorMbCR@Vs+dN-xtOPXM8#btMrl;p zFt5$h^ow>l(PGABejPtByhha&1FX%DCAiI&*3lLpEGi}errFS^w?hjMOccT{d{ z-g2tq&cDq{&Em{5w;hK;cs*6C$9!`cvLi1?a7VmZQCNjptER2W%*uSrKFoW~x6OY! zaylYCI+#beI%JAuvSeQH+wxzzVm9$KDK`Z-Ex3;E3r(0Dc8wqV-4PFP5AO`Ca+ln{97~6Rp$Cn#O7;T!x1FSQblXOZyL_`KB_g zvy9v3S{Cl3f`3V5bjwEYMD!-lCx076OO{W*D*d4$q~WP?p)pe`Gp%>Pa$q~HX`@7? znU0grpFUVuRF+=W<(6){LcC5K^Je_bg{s{=$Km}U)?wP=vm?Gs<4cB1+sjd!A!EeH8)?>mdTiaYl-&8f_PI%l47lNEf99VAR~OCeLWEetQT%k~;f z+Mi``)} za5K<0xNCi(Sd+n+HO+3@x6q}#+B)cM#4jXhdONa*BGe-U&i2XYgbU}Tc$P~EZ*G0MG|ejXh%im^iAYhQ39w>MOW5w5_Lz zz(@SnB1BQrwTBB+QH9wvjv~I1{!Cuu)AVxb=$zvmD_>$pVn&_{u4Qs9^9*}EQ@x@Y zGjjs&rsuZp098Whz||)Gjq}+8iA}KFSYEA4xe^|!99t2yhc3CUM}u^$w9X{girM4- z4Lx=M>9^L{N~MO%n%M>q?UPlyc8O+E`Uq7r_jernDotNKERV;11}z4wlOL4UwN%tk ztvnY`qg4XOfBh0?4$yp`RGip9Xjgu&(`Z6sob**4VU;qatKs*SEXlCcp|q&{x>B-X zdtFn(aO&HIr|tawvTXgp(Sh_q{()WTm3DiDY^#;%)W48p+%l?@s4Xf(TiIoopI%lM z(ZjXF1H&lX#@t7`Lv^JKr$2e}F1<4S^anK_%1Y|n<{0Ov+n3zE&+r0iNCcJpRI`9$h=G%0~)Ee`X^rm$A*b(FL#V)R!%09;K|KqK*C1h*TSq!1!2L-)J4xL zx%*UWeP#VtbMetEVKNuET)p8{|Jm|RjQ(+p!#Um6mj#CU&t-z;f>OQ?4{OpDy7mXA zSA$2IAIn|NIR0Ui)**bBQkd^xZI+? zSByu+ecSxQKKd>GzF+NQl_|`2_1Xw#G}@_+kHEtW&3a6Hnh0GRml~3qqL-wPipiv< zrNX6bJ^eb8uvS=INHjX&tMTM{;9Rn^^00pQc~t9u_89vnVzj;7v-r#jQBqvN-%H<1 z+cM%Qcv5g*HnQM@Xh1Zr^tRHbGTn>NR zwWoSYp`2X>ysmCB-yhT;m08021h@EQ2LwoCVnK^2i2-G9CLq^J3HUUX1BCq+1rewR zf+4@3p^2#B{;!!JLMc07TWtRJmmOLFI1CS^5+OZjo{j}Sq-1i{B{+_60`2GKFJ6x9 zcs8O<*OUF3vE)|yfG!H^3INUe<=b2o-Wc!?OAXqcTusO<(V+rp1flDk3(Zt#mo&xW zmTQ^aM{;Xk#kxU@09p=qCoHS;SN{RFl~pFdZZD~p5^bArml*2IT~h9ok)5t631U)F zv-0s4U;eu>b!ga&kNu#zLSUJ`dGdpGWH6NpI{AM88noKh4DP{?h^1RO16wu_F3kC{#WnAC5L;&58uHyj~eH#bh?GdTdN!`w;D zP%5dbcOf97^NR;7qO2KDZFwm{w=V>Y(QVp08lOmh3iBbq3+oz1{|th>3tynd#)4&& z^7{e}_3p4P<+$~F*-Wj{5mtdg05Z^JTl*b+oL@P6J0dwlBMi7m|7#DZiV4flS5H*u z1t9SoECN_Ciq|O3eysXu+P;~ojyFLt;OKy@8@=}Ltkf%V=&*q7tT0t)3*UnwF_bE} zd-_lIRzd#M`zzF_XUU`C8(uFZgO2ZhrJa!tA7=BJ=0L#>iX}9{gBg}_OZ+B>^lydX zjbjg?lrDNHbGd-sm{sp6=dE#P;DcDVC1Hn(p4md+wiaP;NExuDm}_>le`X^Hd1<@jy8Bqg7Htq4eO z!xuU351K~;zwm68P1Pnkqb1I_`cudT2^1CXe$VpRyr=uqmqU6U99VKD11>E`u=Ah@ z2+abuFgn|Gda@5*k6ac+RxzC=^Wnp{AnRvXOd{f^6}r=n}USpSotH zD1EQD3gFpN$QeMyME~Ke;Vrxed?kQXLrL?~WR|Go?@mJqj8z03a8N z7akUTqzXma$9i5X*+;6Kqu>=k%UPew$i~K+^*H~40kqW_Bmh)?N7dTochVtKJvQ?O z_(IboTLJu61==|0!gf^d+&2U zjhe-ElEnt?>^~%)K0$%DXS>}TUPIn7^b$}W^68nmH(XP1m?#07o5Wi2!u>J=J744()w`q0l0%cYP2hqFhV@bclbvJodNub3!Wqm=rb< zzQLa{G`-YKWM;s4dW4u7e%gCYOmvr@RjgCXSTb^$D;%K) zc(Bw|GeMY@K|X6-M&)QKj3w$eg?SF{to9P`N%z<7e1b(IbRQV`%k73k-uE15Z?6)^Ztk` zOb&P!Cms4ofu_JC^_|V43Zd=kNq%*{w~Ah2HcM69y}frLD{vB>z14_Wu)IVxN!qsyUXxX6V>RsR8kAOEYEb4Mjb9 zQkU(^N;D&iHz~9hzE>=F!kw*JxtI_20;AN5)os7oX5?iaZMjX*qcrqM?q=(1@@pt9 zWn!r|7^$o~4q|N{$fNfO!Bv@!!Ufbc7V6>e>~Huo=dpG2egU9EJhKD%`c-?wvKRql zEX78o`%-b`Y+=>}nO=+^Wwo8Sab&-gBf`;MHg#%#AoRuPiCn&9pASY?^!1D_Q~uQv zEhog{+KB!a8JT}zaT5Hat*F!G6pPM`A|)^N&ITLK&y!J-?;)f} zHGAB6^9f!0HeYt4 zD7eFxrsQ7 z3`%*r?r0x7*C#NSQfw07YPRECp>NHfClk0ALxtEQdDVfRJFpeW@7rE}ksh8$|LxhL z31s;do!Sg<+e7hUrup(f$qNhdukTS$&!*aVl_Mou0`KZ+_&)tbZf0rD=l*oT;{D<3 zUcwz;gdkPWEGz4j*e(D+q zKMyqq`7(yK8C`|b>C+Gf*It_p03fFr9?m&N`GZ2dm9&)raX{bfJ{;Kb#rl>+)NrL5 ztG8owXrGd!Cj;)hucz+T)~s!tI95{*00jReKiEs+L}^YRa1WYw2U8@{+)X;)w&qQAJ=H{ z*BE*rEgpNeJ7af_Vojosk?Cn)o>%N5%q<$(a%lg&t+y3oUV1zZoUB;kXED9Lu!?tv zTHjap!r^LU2ENhVU_Pxe}pD!^mcC1U2rZ7T7(sT)uQaw^c>9A^@R0->9tAkB$lzqmI*U+W4iHh(mS`q|*W5nm% z{&hqlE}e3N3I+K3Zf{$d=<_jJg{kh!UA>-}AMeQmf*7erPapkdL`t_7tETSGY;{D9 z4b8$0L4RM;O8{`OLI^)N#((CmKPsO6lYQ2p>ZCH>ho1AUO5jJMy%`DVA2s^`4D{%v znQ6ziqv#k$2Xq`PLI4QC$3u(-Z;~df(~>b+XM1GMc^)@NYXSgMiNla?lCu71$I{)e zdbGC-Z3)6-PG)Xjp_!*2`oqpzeNu0>6ds=<3v@p$c53s3R*&*4Tt zSp{{V0#LsTT#DHbv8&i{A+?JQwrVY`X--{2>T`E>_|n!0?s>alrU+O6!5QrEXbXuhD3vqL;y-Tu=2N<4MEJ5-oMg30*N!Q~gZO<Ewk#(frr zJ>6Xz@*D|G14Nq+zfnOJ&Nd6GRhA2`FDs4%y^)bAPz@DTmJ9$05%|p|0{vxhWFba^ zP|dhq6V05HaX@E(E_6M55!gF;^RUuEcK{a;_=bniP{*lq0^Y4nOHD8Vqe!>KAroFZ z>2zn@)_u3tI(jaq`N=|~pWapi&C@ya=y7QGsHTZU1o%CSxFDOGA^gzV_k4wJz3PL# zf;0zc@hy+Qe~G2zi=DV@h;e7J)N1~SvaKTmHFC#e`>&%OOm-+jtR zOMK(pl9thLY2LjyiM71<`ZIMSvf_~ha+Fl7N;^(^yyxOIc5lvX0++!`PFUs3T7w{m!dy~GmrY@!;7A*Xu{R<(a&xYK+#gq>m)WN#kv zK0f3Agc<}@knfJtm5p}(7#z%kjwug#?=fj~Jvv$MDSU7vdpO4SCvgS;XQxt_0I}6u zFf88P>9pdrs&YxgkQ-wr4Ao(@+&8-IIw~c*x$fuDo)#=>PzwVw&ZwdV&cm zkk=*ZQzKglQ(araMVt&FMWg$TqhFquPI5N+h_v9NT|OB5f3?ryy_qY6N>qr=GpwxF zfKn0iJM;13gZAW{-03k@R8$YM77oS^bRbtwsAT&!0n7X8ur!^W#iZ1CWK%k&Bk~Jq zzp`1raw#24gP<<*63G?5EJTeh<#X$cermg@4p$5JAvo6uZA?7l?nfoWG<_rWtlBA4 z=T@ol8lw4WG;>It)q!!{6Q~r}&e-L&Vn_g{Nj22xg9rQ+v-CFT4k^)XowPXia6?Oh<7KiL)p5H-hsC-S*lu?4;VZzS z4iIT7po`$$a^mnPqwiGRn z2?rELOV4Yhozc|6m9Db6H<%)B8G zYOrIFhr9$(sgufZu$w&dpbn#eA!9BM?IBzjKDnHog);N`aHY|GyIEi{ZB_gpYCcN# zKgWR%QV#q^9WEc&{bF zOCWE@_D!(bw-fc{f1@g%OZ2Zjqy6T)_oW1foLgp!Tc7rCPP_!Wxt+l;Ux56*PysA% z5`P6x{fH?~s6FN`-$bs@`MvN&yQ>AgIl2-ciZpr*;(>##b}f@Xr=E?D;^I%!KcisT z%Vo|`8yZNKf57kmSwp)od1mVah?;6okS9(qnB0?J1ljxj`5*JHj(uA(c$^7jaS6_k zrpy+;5l6T9nv)|2f{MvYKr{`mC@U~m$MC7_5T86Qtm{WW%sX9itSzsDt}RYCXq5_q z_edA{dD>LifS#kwSaH&cf$%fh8{>8ewxNc!;U!AEuvc)gKpFtymaAtXG>eP1XC-Dj zt!{Z~VW0~{`JA9g<3C4_laTgYig#Mr!(KEa3BkpJ`%y}`|ysiyDsDU7ee2HbqPv0_uLv#O4Gr0mRYBp~B9V zukJH}`xTDPJ>88qv3mo9LB+4<0l$@?TzgZ<5}l-arc1s7X)O;D$< zp&9`^w(XDe^LnAuhQ0u_4m6)jSPu`Ocy>wSstNu3uyDC2+Ca?ny2~A^@(c?YKc}NJ zac4*P#WkC1Hd+Xe5#*=8pTC9)i!I`$aXKHIVl7c7Q3SZC_7V!=_@h5OjvntjSANwm z=M|1des4vPQ#Vp||8N-ijf~v6`*UF`JKcE|dI#SYm*vi)?eb86+~1&)Fw#D#H;zWS zb2qx(R4^&VFJzc7fFiZiF_oH2!=A>=&by~f9|PZy^(KO$+&#N|@MhmJQ{MRN`x6jZ z0C^!pL>-{*xy{GSxk#;pu?f#Y5+~6Pg#_ZNvdlKGN9%<^)Z{stkDn4Fr_I4oCcJ-x zB7XzXc<-bxGDx)PP=$~#DChTaeWcvKMVRixYpA(GI1VmjJH&vumPR`4C&_#4d*(_ArLJFlpm2m$iX~N|To#he$ z-NA$b-)c5)o2m9*IPLG0q_Thf=xFrgSddABYGNPN0m*WlDGt~jPlR>(8*8K|^w;ydkcLcB5Z=mXBCv~<$8PyNuVSU>)-vQ#q+_3lHg<)h-F_r1WH#=w6L zbz7V(t3f1IJ@2Zg;lw$Vu`G5d&92xEF3c^KfT%$ZpeYJOVrSDUM5uiuA4YuxH5MsM zPrYtW!o0oE#-!J)Pls4P+@+p8KW7x(iJW@aLETah#D$a1uC&YwmDT}R{ft1=rI zkn<0XX-1a&0+wpmNYX1fG0iGfl{L$M<__=a?*lQ{wPn|jPYFn#XO^W_2dDMF0*rKZ z_2g8-y014ANqf$A#XiRmo-lBn6T`HyXB+;Ukh>=jj7g%f`+A@P7%ot&XIKLyP|&rT z`B>8^_V@PF)koW2TyBZ+$Yreo<|E6Uv`oO)Er8rM2kSX{ky%cT5{Mdad*f-#=YoE% zp+@t=(;E{y{y(qVRhJpsxrITCH6<&tpr7mS1K)PgGdu z@M0{2UP^y(Ej=;xS<-J%#43rhgyXCh_BIO5D>qsIS=1DkfA|^=YF%|Oek$SDzLX_| zyk0pS7>GRlCPX5YmOEqJgS*w@kWIX&X=Qbwx7dR0Ak%r-OZPTprAqvZWySzlzrUNK zm;XVjjsyo-uR)E=8?CD0tO`9K;RUyBLmSK#(;{U@#~bG(wS|;34O0XsDz8WG{U7c| zH+Yg;+IYQY`u9AKcZ;vlP%3BCF?ZjXL}7BM@I>YKJe+NP#8HViYZED;TTB0j8fj(P*dlM?kr-EL$zN}!5JvMT zvL7t@L8W7`3`-8Wm9Wpj8Yo;n61p}+b%JHoIjkL=1UFblb-c)azV$9Pr(t46@ouuO zG88@N=C-d07+Ra`vA0?;Vz-nsrOTVJG)N;y0ayic3_wUNBa2lh)}%#(sArP69Fd+~ zuM#M+F4WZKVxHV4v^i-A&*^FDz9rV*TTX#8Njq>R>!AY&gMZ^yjXX_*a~hnDR$!Q& z^k0gSh={}{Xkg9--T!p$Uz_W=i3A~u3MLpL#d^CfRbAun+3Fa){zjgHXzW+szfQtE z$MbC(_KncsQ{I_*6C;|+g;afX%HA(@ND|@$NM-!3f&56-+)Y_g51?PR`MAo zI6ZSN{WRr2CO1FYP3;|jrrGwURs?s+*eU6AB$*O}gnBwVV!CvP$o<(fi$aD}c>9M> z2-XdR5CGi!-V|W2&Z*{^y1Kt3@vPB~@Il0ot?P5xn$fgh=F`@+O8L{+=R(gf z7Q6pMja8Clw`DsqSM$4K7wnP{O7dUR_#q=2ynF2*S~f;rq6G8^nsF){uu8{<5W$dw zD&i#Nz}hn`H$^B0&!2mNKzf>(s%D0m%(b@FUkgz8Dh{cqy5Y%@75=u_TF@ z-qtF!>&`^7LH%pSG0U`)2wsclMWuDL0{Ym1bAbf&ZB$sGh3?ra0;2=?m#dN@JSyUk zm&yqks_V7J?f^a&@nW8J&ZMpAqnH|qTEVQdAi&RWXL|Xcw>P>RrRnL9ixVkDbtKio z7zz6u(?XyKAo~k5AB71DM1jlRt^n(sjs!a*TQ{?nxZg8bG0Fqqg1rd#&Wehv*$mR> z#3-mq-PRs`+|iipA&H;ID<;noM6=D!MLRn5a7YN?8Jz5CZ)z}Tx7VfTCw#OywuYoA zyf(+7=gfapdp^l7VRfNRqDi-_+zkv2k*+I5J?jx7Ft|O>KY_zZ(UoT{Jxmx=||GpqH>RK0F z4>v>_lLVB3Jo9$Iw(MHBl(*vN@ZAY|=^_3gjCI0OF!JhTQ&GP+GX%1fKh}f(_|lC= z4Al~^1B`U0l+r<2LDhv_M`YiAU-|E6URaHGWGpe{smTwsd0nzIEpZPia_t63f_?+3P#rFdOI%(|K=d0Iy12-l1;Q|7$b_-uioF5kS1>c2v zK_DV|4P28s>5mP2**-X&#o}QQR){q48YSC)WTjzCa&hnfB~fy_OYuaoABeI$ll}!K z(&5Qv&*V%{U*ai(-j*Cith7CMzeEsSPf90*X6^?Tp$dVCNws%MhZ>S?FItd$&9Ab+ zenrkB>jt0mGzPZr#k(sAzHUQI5w0B=$&Kqh`obH28slGLtDB3Obak#mV2bFn&e*I} z%S|LHb@VR+d%PU=N)(+q9pEwh?1A;YY>QHpA-X<|8Ymux5gxC2qow{A-9P?+E=kj# a(BFMgWNG%dSVms<0x}W`pUTCI0{#y=7qJ=u literal 0 HcmV?d00001 From 25e8e12157703741ecb1ce84dcd685fbd8dd004f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 14 Jul 2025 20:07:17 +0200 Subject: [PATCH 37/43] enhance: highlight new comments when shown; nit-fixes and cleanups fixes: - sync local commentsViewedAt on comment injection, to avoid double outline on item re-visit - avoid double highlighting when client-side visiting an item and injecting a new comment cleanups: - move ShowNewComments functions to dedicated lib/comments.js - bust auto-show enhancement due to bad useEffect usage todos: - two recursive counts might be too much --- components/comment.js | 7 +++ components/show-new-comments.js | 80 +------------------------------ components/use-live-comments.js | 2 +- lib/comments.js | 84 +++++++++++++++++++++++++++++++++ styles/globals.scss | 8 ++++ 5 files changed, 102 insertions(+), 79 deletions(-) diff --git a/components/comment.js b/components/comment.js index acd23dc28..540e0ff20 100644 --- a/components/comment.js +++ b/components/comment.js @@ -143,9 +143,16 @@ export default function Comment ({ useEffect(() => { if (router.query.commentsViewedAt && me?.id !== item.user?.id && + !item.newComments && new Date(item.createdAt).getTime() > router.query.commentsViewedAt) { ref.current.classList.add('outline-new-comment') } + + // an injected new comment has the newComments field, a different class is needed + // to reliably outline every new comment + if (item.newComments && me?.id !== item.user?.id) { + ref.current.classList.add('outline-new-injected-comment') + } }, [item.id]) const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index d9e6e466b..be118f7c0 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,87 +1,11 @@ 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 { prepareComments, dedupeNewComments, collectAllNewComments, showAllNewCommentsRecursively } from '@/lib/comments' -function prepareComments (client, newComments) { - return (data) => { - // newComments is an array of comment ids that allows us - // to read the latest newComments from the cache, guaranteeing that we're not reading stale data - const freshNewComments = newComments.map(id => { - const fragment = client.cache.readFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW_RECURSIVE, - fragmentName: 'CommentWithNewRecursive' - }) - - if (!fragment) { - return null - } - - return fragment - }).filter(Boolean) - - // count the total number of new comments including its nested new comments - let totalNComments = freshNewComments.length - for (const comment of freshNewComments) { - totalNComments += (comment.ncomments || 0) - } - - // update all ancestors, but not the item itself - const ancestors = data.path.split('.').slice(0, -1) - updateAncestorsCommentCount(client.cache, ancestors, totalNComments) - - return { - ...data, - comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] }, - ncomments: data.ncomments + totalNComments, - newComments: [] - } - } -} - -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) - } - } - - // recursively handle new comments in child comments - if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { - for (const childComment of item.comments.comments) { - showAllNewCommentsRecursively(client, childComment, currentDepth + 1) - } - } -} - -function dedupeNewComments (newComments, comments) { - const existingIds = new Set(comments.map(c => c.id)) - return newComments.filter(id => !existingIds.has(id)) -} - -function collectAllNewComments (item, currentDepth = 1) { - const allNewComments = [...(item.newComments || [])] - if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { - for (const comment of item.comments.comments) { - console.log('comment', comment) - console.log('currentDepth', currentDepth) - allNewComments.push(...collectAllNewComments(comment, currentDepth + 1)) - } - } - return allNewComments -} - -export function ShowNewComments ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) { +export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) => { const client = useApolloClient() - // if item is provided, we're showing all new comments for a thread, // otherwise we're showing new comments for a comment const isThread = !topLevel && item?.path.split('.').length === 2 diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 63aac5093..903cb81e1 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -127,7 +127,7 @@ function mergeNewComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment.id] } } -function getLatestCommentCreatedAt (comments, latest) { +export 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 b41e5c754..4d0fc5b6a 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -1,3 +1,8 @@ +import { COMMENT_DEPTH_LIMIT } from './constants' +import { commentUpdateFragment, getLatestCommentCreatedAt } from '../components/use-live-comments' +import { commentsViewedAfterComment } from './new-comments' +import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' + export function updateAncestorsCommentCount (cache, ancestors, increment) { // update all ancestors ancestors.forEach(id => { @@ -12,3 +17,82 @@ export function updateAncestorsCommentCount (cache, ancestors, increment) { }) }) } + +// live comments - cache manipulations +export function dedupeNewComments (newComments, comments) { + const existingIds = new Set(comments.map(c => c.id)) + return newComments.filter(id => !existingIds.has(id)) +} + +export function collectAllNewComments (item, currentDepth = 1) { + const allNewComments = [...(item.newComments || [])] + if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { + for (const comment of item.comments.comments) { + allNewComments.push(...collectAllNewComments(comment, currentDepth + 1)) + } + } + return allNewComments +} + +export function prepareComments (client, newComments) { + return (data) => { + // newComments is an array of comment ids that allows us + // to read the latest newComments from the cache, guaranteeing that we're not reading stale data + const freshNewComments = newComments.map(id => { + const fragment = client.cache.readFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' + }) + + if (!fragment) { + return null + } + + return fragment + }).filter(Boolean) + + // count the total number of new comments including its nested new comments + let totalNComments = freshNewComments.length + for (const comment of freshNewComments) { + totalNComments += (comment.ncomments || 0) + } + + // update all ancestors, but not the item itself + const ancestors = data.path.split('.').slice(0, -1) + updateAncestorsCommentCount(client.cache, ancestors, totalNComments) + + // 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 { + ...data, + comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] }, + ncomments: data.ncomments + totalNComments, + newComments: [] + } + } +} + +export 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) + } + } + + // recursively handle new comments in child comments + if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { + for (const childComment of item.comments.comments) { + showAllNewCommentsRecursively(client, childComment, currentDepth + 1) + } + } +} diff --git a/styles/globals.scss b/styles/globals.scss index a0667da34..0d8659782 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -908,10 +908,18 @@ div[contenteditable]:focus, box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25); } +.outline-new-injected-comment { + box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25); +} + .outline-new-comment.outline-new-comment-unset { box-shadow: none; } +.outline-new-injected-comment.outline-new-comment-unset { + box-shadow: none; +} + .outline-new-comment .outline-new-comment { box-shadow: none; } From e065f17a5ce97e8937710bf02158c13f8d62b174 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 14 Jul 2025 21:44:39 +0200 Subject: [PATCH 38/43] cleanup: move cache manipulation functions, comments for comments.js - lib/comments.js explanations for its functions - itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt on comments.js - format too many imports from comments.js todo: - we're not deduping comments for isThread, which forces us at this state, to dedupe twice --- components/show-new-comments.js | 11 +++++- components/use-live-comments.js | 51 +------------------------- lib/comments.js | 65 ++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 53 deletions(-) diff --git a/components/show-new-comments.js b/components/show-new-comments.js index be118f7c0..b52849e7b 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,8 +1,14 @@ import { useCallback, useMemo } from 'react' import { useApolloClient } from '@apollo/client' import styles from './comment.module.css' -import { itemUpdateQuery, commentUpdateFragment } from './use-live-comments' -import { prepareComments, dedupeNewComments, collectAllNewComments, showAllNewCommentsRecursively } from '@/lib/comments' +import { + itemUpdateQuery, + commentUpdateFragment, + prepareComments, + dedupeNewComments, + collectAllNewComments, + showAllNewCommentsRecursively +} from '../lib/comments' export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) => { const client = useApolloClient() @@ -12,6 +18,7 @@ export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHas const allNewComments = useMemo(() => { if (isThread) { // TODO: well are we only collecting all new comments just for a fancy UI? + // TODO2: also, we're not deduping new comments here, so we're showing duplicates return collectAllNewComments(item, depth) } return dedupeNewComments(newComments, comments) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 903cb81e1..9d915b262 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -1,8 +1,8 @@ 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 @@ -47,46 +47,6 @@ export default function useLiveComments (rootId, after, sort, setHasNewComments) }, []) } -// 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 = [] @@ -126,10 +86,3 @@ function mergeNewComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment.id] } } - -export 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 4d0fc5b6a..23ba555fe 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -1,8 +1,9 @@ import { COMMENT_DEPTH_LIMIT } from './constants' -import { commentUpdateFragment, getLatestCommentCreatedAt } from '../components/use-live-comments' import { commentsViewedAfterComment } from './new-comments' -import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' +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 => { @@ -19,11 +20,58 @@ 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 +} + +// filters out new comments, by id, that already exist in the item's comments +// preventing duplicate comments from being injected export function dedupeNewComments (newComments, comments) { + console.log('dedupeNewComments', newComments, comments) const existingIds = new Set(comments.map(c => c.id)) return newComments.filter(id => !existingIds.has(id)) } +// 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) { const allNewComments = [...(item.newComments || [])] if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { @@ -34,6 +82,8 @@ export function collectAllNewComments (item, currentDepth = 1) { return allNewComments } +// 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 export function prepareComments (client, newComments) { return (data) => { // newComments is an array of comment ids that allows us @@ -69,6 +119,7 @@ export function prepareComments (client, newComments) { 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] }, @@ -78,6 +129,8 @@ export function prepareComments (client, newComments) { } } +// recursively processes and displays all new comments for a thread +// handles comment injection at each level, respecting depth limits export function showAllNewCommentsRecursively (client, item, currentDepth = 1) { // handle new comments at this item level if (item.newComments && item.newComments.length > 0) { @@ -96,3 +149,11 @@ export function showAllNewCommentsRecursively (client, item, currentDepth = 1) { } } } + +// 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 17006520b87a6ca794aaaf9c6ac3744a4843119f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 15 Jul 2025 12:13:38 +0200 Subject: [PATCH 39/43] enhance: highlight new comment with injected field, recursive injection in every case but top level; cleanups cleanups: - better separation of concerns for lib/comments.js - don't show new comment count, avoiding useless complexity - simpler topLevel/nested logic - add comments --- api/typeDefs/item.js | 1 + components/comment.js | 34 ++++++--- components/show-new-comments.js | 122 +++++++++++++++++++++++++------- components/use-live-comments.js | 80 ++++++++++----------- fragments/comments.js | 3 + lib/apollo.js | 5 ++ lib/comments.js | 91 ------------------------ 7 files changed, 171 insertions(+), 165 deletions(-) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 4d19c7440..4e970c8aa 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -150,6 +150,7 @@ export default gql` nDirectComments: Int! comments(sort: String, cursor: String): Comments! newComments(rootId: ID, after: Date): Comments! + injected: Boolean! path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 540e0ff20..2e6e8bc5b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -140,19 +140,35 @@ export default function Comment ({ } }, [item.id, cache, router.query.commentId]) + const unsetOutline = () => { + ref.current.classList.add('outline-new-comment-unset') + // if the comment is injected, we need to change injected to false + // so that the next time the comment is rendered, it won't be outlined + if (item.injected) { + cache.writeFragment({ + id: `Item:${item.id}`, + fragment: gql` + fragment CommentInjected on Item { + injected @client + }`, + data: { + injected: false + } + }) + } + } + useEffect(() => { + // an injected new comment needs a different class to reliably outline every new comment + if (item.injected && me?.id !== item.user?.id) { + ref.current.classList.add('outline-new-injected-comment') + } + if (router.query.commentsViewedAt && me?.id !== item.user?.id && - !item.newComments && new Date(item.createdAt).getTime() > router.query.commentsViewedAt) { ref.current.classList.add('outline-new-comment') } - - // an injected new comment has the newComments field, a different class is needed - // to reliably outline every new comment - if (item.newComments && me?.id !== item.user?.id) { - ref.current.classList.add('outline-new-injected-comment') - } }, [item.id]) const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0) @@ -167,8 +183,8 @@ export default function Comment ({ return (
ref.current.classList.add('outline-new-comment-unset')} - onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')} + onMouseEnter={unsetOutline} + onTouchStart={unsetOutline} >
{item.outlawed && !me?.privates?.wildWestMode diff --git a/components/show-new-comments.js b/components/show-new-comments.js index b52849e7b..12c0eb491 100644 --- a/components/show-new-comments.js +++ b/components/show-new-comments.js @@ -1,43 +1,117 @@ import { useCallback, useMemo } from 'react' import { useApolloClient } from '@apollo/client' import styles from './comment.module.css' +import { COMMENT_DEPTH_LIMIT } from '../lib/constants' +import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments' +import { commentsViewedAfterComment } from '../lib/new-comments' import { itemUpdateQuery, commentUpdateFragment, - prepareComments, - dedupeNewComments, - collectAllNewComments, - showAllNewCommentsRecursively + 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) { + console.log('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 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}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' + }) + + if (!fragment) { + return null + } + + // marking it as injected so that the new comment can be outlined + return { ...fragment, injected: true } + }).filter(Boolean) + + // count the total number of new comments including its nested new comments + let totalNComments = freshNewComments.length + for (const comment of freshNewComments) { + totalNComments += (comment.ncomments || 0) + } + + // update all ancestors, but not the item itself + const ancestors = data.path.split('.').slice(0, -1) + updateAncestorsCommentCount(client.cache, ancestors, totalNComments) + + // update commentsViewedAt with the most recent fresh new comment + // quirk: this is not the most recent comment, it's the most recent comment in the freshNewComments array + // as such, the next visit will not outline other new comments that have not been injected yet + const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt) + const rootId = data.path.split('.')[0] + commentsViewedAfterComment(rootId, latestCommentCreatedAt) + + // return the updated item with the new comments injected + return { + ...data, + comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] }, + ncomments: data.ncomments + totalNComments, + newComments: [] + } + } +} + +// recursively processes and displays all new comments for a thread +// handles comment injection at each level, respecting depth limits +function showAllNewCommentsRecursively (client, item, currentDepth = 1) { + // handle new comments at this item level + if (item.newComments && item.newComments.length > 0) { + const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments) + + if (dedupedNewComments.length > 0) { + const payload = prepareComments(client, dedupedNewComments) + commentUpdateFragment(client, item.id, payload) + } + } + + // read the updated item from the cache + // this is necessary because the item may have been updated by the time we get to the child comments + const updatedItem = client.cache.readFragment({ + id: `Item:${item.id}`, + fragment: COMMENT_WITH_NEW_RECURSIVE, + fragmentName: 'CommentWithNewRecursive' + }) + + // recursively handle new comments in child comments + if (updatedItem.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { + for (const childComment of updatedItem.comments.comments) { + showAllNewCommentsRecursively(client, childComment, currentDepth + 1) + } + } +} + export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) => { const client = useApolloClient() // if item is provided, we're showing all new comments for a thread, // otherwise we're showing new comments for a comment const isThread = !topLevel && item?.path.split('.').length === 2 - const allNewComments = useMemo(() => { - if (isThread) { - // TODO: well are we only collecting all new comments just for a fancy UI? - // TODO2: also, we're not deduping new comments here, so we're showing duplicates - return collectAllNewComments(item, depth) - } - return dedupeNewComments(newComments, comments) - }, [isThread, item, newComments, comments, depth]) + const allNewComments = useMemo(() => dedupeNewComments(newComments, comments), [newComments, comments]) const showNewComments = useCallback(() => { - if (isThread) { - showAllNewCommentsRecursively(client, item, depth) - } else { - // fetch the latest version of the comments from the cache by their ids + if (topLevel) { const payload = prepareComments(client, allNewComments) - if (topLevel) { - itemUpdateQuery(client, itemId, sort, payload) - } else { - commentUpdateFragment(client, itemId, payload) - } + itemUpdateQuery(client, itemId, sort, payload) + } else { + showAllNewCommentsRecursively(client, item, depth) } setHasNewComments(false) - }, [client, itemId, allNewComments, topLevel, sort]) + }, [client, itemId, allNewComments, topLevel, sort, item]) if (allNewComments.length === 0) { return null @@ -48,9 +122,7 @@ export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHas 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 - ? `${isThread ? 'show all ' : ''}${allNewComments.length} new comments` - : 'show new comment'} + show {isThread ? 'all' : ''} new comments
) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 9d915b262..7f074d797 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -6,6 +6,46 @@ 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 +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 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, setHasNewComments) { @@ -46,43 +86,3 @@ export default function useLiveComments (rootId, after, sort, setHasNewComments) } }, []) } - -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] } -} diff --git a/fragments/comments.js b/fragments/comments.js index 06b1605cb..d4330be7b 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -48,6 +48,7 @@ export const COMMENT_FIELDS = gql` ncomments nDirectComments newComments @client + injected @client imgproxyUrls rel apiKey @@ -130,6 +131,7 @@ export const COMMENT_WITH_NEW_RECURSIVE = gql` } } newComments @client + injected @client } ` @@ -144,6 +146,7 @@ export const COMMENT_WITH_NEW_LIMITED = gql` } } newComments @client + injected @client } ` diff --git a/lib/apollo.js b/lib/apollo.js index dc7b8127b..bc0303f96 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -318,6 +318,11 @@ function getClient (uri) { return newComments || [] } }, + injected: { + read (injected) { + return injected || false + } + }, meAnonSats: { read (existingAmount, { readField }) { if (SSR) return null diff --git a/lib/comments.js b/lib/comments.js index 23ba555fe..8f842d89d 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -1,5 +1,3 @@ -import { COMMENT_DEPTH_LIMIT } from './constants' -import { commentsViewedAfterComment } from './new-comments' import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' @@ -61,95 +59,6 @@ export function commentUpdateFragment (client, id, fn) { return result } -// filters out new comments, by id, that already exist in the item's comments -// preventing duplicate comments from being injected -export function dedupeNewComments (newComments, comments) { - console.log('dedupeNewComments', newComments, comments) - const existingIds = new Set(comments.map(c => c.id)) - return newComments.filter(id => !existingIds.has(id)) -} - -// 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) { - const allNewComments = [...(item.newComments || [])] - if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { - for (const comment of item.comments.comments) { - allNewComments.push(...collectAllNewComments(comment, currentDepth + 1)) - } - } - return allNewComments -} - -// 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 -export function prepareComments (client, newComments) { - return (data) => { - // newComments is an array of comment ids that allows us - // to read the latest newComments from the cache, guaranteeing that we're not reading stale data - const freshNewComments = newComments.map(id => { - const fragment = client.cache.readFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW_RECURSIVE, - fragmentName: 'CommentWithNewRecursive' - }) - - if (!fragment) { - return null - } - - return fragment - }).filter(Boolean) - - // count the total number of new comments including its nested new comments - let totalNComments = freshNewComments.length - for (const comment of freshNewComments) { - totalNComments += (comment.ncomments || 0) - } - - // update all ancestors, but not the item itself - const ancestors = data.path.split('.').slice(0, -1) - updateAncestorsCommentCount(client.cache, ancestors, totalNComments) - - // update commentsViewedAt with the most recent fresh new comment - // quirk: this is not the most recent comment, it's the most recent comment in the freshNewComments array - // as such, the next visit will not outline other new comments that have not been injected yet - const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt) - const rootId = data.path.split('.')[0] - commentsViewedAfterComment(rootId, latestCommentCreatedAt) - - // return the updated item with the new comments injected - return { - ...data, - comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] }, - ncomments: data.ncomments + totalNComments, - newComments: [] - } - } -} - -// recursively processes and displays all new comments for a thread -// handles comment injection at each level, respecting depth limits -export 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) - } - } - - // recursively handle new comments in child comments - if (item.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) { - for (const childComment of item.comments.comments) { - showAllNewCommentsRecursively(client, childComment, currentDepth + 1) - } - } -} - // finds the most recent createdAt timestamp from an array of comments export function getLatestCommentCreatedAt (comments, latest) { return comments.reduce( From f24ad002ca517a98ffd3ce6b488e0d4a7f95f44c Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 09:58:02 +0200 Subject: [PATCH 40/43] 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 3159026c6..b371e1dc3 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 1951b7814..a7f16f1bb 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 f5396444a..0d8b107af 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 b41e5c754..8f842d89d 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 41/43] 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 b371e1dc3..9f3f28a1a 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 a7f16f1bb..431cb8a99 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 a8dcbc09bddc1a2be23a4d73a0c755e30e334325 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 16 Jul 2025 11:21:40 +0200 Subject: [PATCH 42/43] hotfix: fix lint after merge --- components/comment.js | 6 +++--- components/comments.js | 18 +++++------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/components/comment.js b/components/comment.js index 2e6e8bc5b..88ba08bf2 100644 --- a/components/comment.js +++ b/components/comment.js @@ -98,7 +98,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) { export default function Comment ({ item, children, replyOpen, includeParent, topLevel, - rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry, setHasNewComments + rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry }) { const [edit, setEdit] = useState() const { me } = useMe() @@ -285,7 +285,7 @@ export default function Comment ({ {root.bounty && !bountyPaid && }
- +
} {children} @@ -294,7 +294,7 @@ export default function Comment ({ ? ( <> {item.comments.comments.map((item) => ( - + ))} {item.comments.comments.length < item.nDirectComments && } diff --git a/components/comments.js b/components/comments.js index 0c4b98eed..fa8ce9c7b 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,4 +1,4 @@ -import { Fragment, useMemo, useState } from 'react' +import { Fragment, useMemo } from 'react' import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' @@ -10,8 +10,6 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './use-live-comments' import { ShowNewComments } from './show-new-comments' -import Head from 'next/head' -import { useHasNewNotes } from './use-has-new-notes' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -71,19 +69,13 @@ export default function Comments ({ commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { const router = useRouter() - const [hasNewComments, setHasNewComments] = useState(false) // fetch new comments that arrived after the lastCommentAt, and update the item.newComments field in cache - useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort, setHasNewComments) - // xxx - const hasNewNotes = useHasNewNotes() + useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) return ( <> - - - {comments?.length > 0 ? : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( - + ))} {comments.filter(({ position }) => !position).map(item => ( - + ))} {ncomments > FULL_COMMENTS_THRESHOLD && Date: Wed, 16 Jul 2025 14:09:34 +0200 Subject: [PATCH 43/43] merge: missing memo deps, limited fragment for non-recursive comments; fix: don't highlight injected comments with classic outline; cleanup: comments --- components/comment.js | 2 ++ components/show-new-comments.js | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/components/comment.js b/components/comment.js index 88ba08bf2..9d111504d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -160,11 +160,13 @@ export default function Comment ({ useEffect(() => { // an injected new comment needs a different class to reliably outline every new comment + // regardless of commentsViewedAt, it's always new if (item.injected && me?.id !== item.user?.id) { ref.current.classList.add('outline-new-injected-comment') } if (router.query.commentsViewedAt && + !item.injected && me?.id !== item.user?.id && new Date(item.createdAt).getTime() > router.query.commentsViewedAt) { ref.current.classList.add('outline-new-comment') diff --git a/components/show-new-comments.js b/components/show-new-comments.js index 971607d28..2356d16d3 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, @@ -51,7 +51,7 @@ function prepareComments (client, 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 may 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) @@ -81,14 +81,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) } @@ -122,11 +127,11 @@ export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newCo // otherwise we're showing new comments for a comment const isThread = !topLevel && item?.path.split('.').length === 2 const allNewComments = useMemo(() => { - if (isThread) { + if (item) { return collectAllNewComments(item, depth) } return dedupeNewComments(newComments, comments) - }, [newComments, comments, item, depth]) + }, [newComments, comments, item, depth, topLevel]) const showNewComments = useCallback(() => { if (topLevel) { @@ -135,7 +140,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