From f115148594479c305448b699328684138aae86f3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 05:34:20 -0500 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 + }) + }) +}