From f115148594479c305448b699328684138aae86f3 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 05:34:20 -0500 Subject: [PATCH 01/22] check new comments every 10 seconds --- api/resolvers/item.js | 26 ++++++++++ api/typeDefs/item.js | 6 +++ components/comment.js | 65 ++++++++++++++++++++++- components/comment.module.css | 23 +++++++++ components/comments.js | 10 +++- components/item-full.js | 23 +++++---- components/use-live-comments.js | 91 +++++++++++++++++++++++++++++++++ fragments/comments.js | 28 ++++++++++ fragments/items.js | 1 + lib/apollo.js | 6 +++ 10 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 components/use-live-comments.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 781409e09e..ee7d898687 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -727,6 +727,32 @@ export default { homeMaxBoost: homeAgg._max.boost || 0, subMaxBoost: subAgg?._max.boost || 0 } + }, + newComments: async (parent, { rootId, after }, { models, me }) => { + console.log('rootId', rootId) + console.log('after', after) + const item = await models.item.findUnique({ where: { id: Number(rootId) } }) + if (!item) { + throw new GqlInputError('item not found') + } + + const comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + -- comments can be nested, so we need to get all comments that are descendants of the root + WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) + AND "Item"."created_at" > $2 + ORDER BY "Item"."created_at" ASC` + }, Number(rootId), after) + + console.log('comments', comments) + + return { + comments + } } }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a40a99ae1b..95bbaf7888 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,7 @@ export default gql` auctionPosition(sub: String, id: ID, boost: Int): Int! boostPosition(sub: String, id: ID, boost: Int): BoostPositions! itemRepetition(parentId: ID): Int! + newComments(rootId: ID, after: Date): NewComments } type BoostPositions { @@ -96,6 +97,10 @@ export default gql` comments: [Item!]! } + type NewComments { + comments: [Item] + } + enum InvoiceActionState { PENDING PENDING_HELD @@ -148,6 +153,7 @@ export default gql` ncomments: Int! nDirectComments: Int! comments(sort: String, cursor: String): Comments! + newComments(rootId: ID, after: Date): NewComments path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 73e64d0c33..d0e04a4666 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,6 +28,8 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' +import { ITEM_FULL } from '@/fragments/items' +import { COMMENT_WITH_NEW } from '@/fragments/comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -111,9 +113,12 @@ export default function Comment ({ const router = useRouter() const root = useRoot() const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) - const { cache } = useApolloClient() + useEffect(() => { + console.log('item', item) + }, [item]) + useEffect(() => { const comment = cache.readFragment({ id: `Item:${router.query.commentId}`, @@ -275,6 +280,9 @@ export default function Comment ({ : null} {/* TODO: add link to more comments if they're limited */} + {item.newComments?.length > 0 && ( + + )} ) )} @@ -338,3 +346,58 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } + +export function ShowNewComments ({ newComments = [], itemId, updateQuery = false }) { + const client = useApolloClient() + + const showNewComments = () => { + if (updateQuery) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: itemId } + }, (data) => { + if (!data) return data + const { item } = data + return { + item: { + ...item, + comments: dedupeComments(item, newComments), + newComments: [] + } + } + }) + } else { + client.cache.updateFragment({ + id: `Item:${itemId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + return { + ...data, + comments: dedupeComments(data, newComments), + newComments: [] + } + }) + } + } + + const dedupeComments = (item) => { + const existingComments = item?.comments?.comments || [] + const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) + const updatedComments = [...filtered, ...existingComments] + return updatedComments + } + + return ( + +
+
+ {newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'} +
+
+
+ + ) +} diff --git a/components/comment.module.css b/components/comment.module.css index 6f24ab6157..215993d64f 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -135,4 +135,27 @@ .comment:has(.comment) + .comment{ padding-top: .5rem; +} + +.newCommentDot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--bs-primary); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + background-color: #FADA5E; + opacity: 0.7; + } + 50% { + background-color: #F6911D; + opacity: 1; + } + 100% { + background-color: #FADA5E; + opacity: 0.7; + } } \ No newline at end of file diff --git a/components/comments.js b/components/comments.js index cb5d864167..d0af8abfae 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,5 +1,5 @@ import { Fragment, useMemo } from 'react' -import Comment, { CommentSkeleton } from './comment' +import Comment, { CommentSkeleton, ShowNewComments } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' import Navbar from 'react-bootstrap/Navbar' @@ -8,6 +8,7 @@ import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' +import { useLiveComments } from './use-live-comments' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -64,9 +65,11 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm export default function Comments ({ parentId, pinned, bio, parentCreatedAt, - commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props + commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { const router = useRouter() + // update item.newComments in cache + useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -102,6 +105,9 @@ export default function Comments ({ count={comments?.length} Skeleton={CommentsSkeleton} />} + {newComments?.length > 0 && ( + + )} ) } diff --git a/components/item-full.js b/components/item-full.js index 72c60b9c63..02621981b8 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -182,17 +182,18 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props ? : }
)} - {item.comments && -
- -
} +
+ +
diff --git a/components/use-live-comments.js b/components/use-live-comments.js new file mode 100644 index 0000000000..9d6fa94ed1 --- /dev/null +++ b/components/use-live-comments.js @@ -0,0 +1,91 @@ +import { useQuery, useApolloClient } from '@apollo/client' +import { SSR } from '../lib/constants' +import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' +import { ITEM_FULL } from '../fragments/items' +import { useState } from 'react' + +export function useLiveComments (rootId, after) { + const client = useApolloClient() + const [lastChecked, setLastChecked] = useState(after) + const { data, error } = useQuery(GET_NEW_COMMENTS, SSR + ? {} + : { + pollInterval: 10000, + variables: { rootId, after: lastChecked } + }) + + console.log('error', error) + + if (data && data.newComments) { + saveNewComments(client, rootId, data.newComments.comments) + const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + if (latestCommentCreatedAt) { + setLastChecked(latestCommentCreatedAt) + } + } + + return null +} + +export function saveNewComments (client, rootId, newComments) { + console.log('newComments', newComments) + for (const comment of newComments) { + console.log('comment', comment) + const parentId = comment.parentId + if (Number(parentId) === Number(rootId)) { + console.log('parentId', parentId) + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: rootId } + }, (data) => { + console.log('data', data) + if (!data) return data + console.log('dataTopLevel', data) + + const { item } = data + + return { item: dedupeComment(item, comment) } + }) + } else { + console.log('not top level', parentId) + client.cache.updateFragment({ + id: `Item:${parentId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + console.log('data', data) + + return dedupeComment(data, comment) + }) + console.log('fragment', client.cache.readFragment({ + id: `Item:${parentId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + })) + } + } +} + +function dedupeComment (item, newComment) { + const existingNewComments = item.newComments || [] + const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) + const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] + console.log(item) + const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) + const final = { ...item, newComments: filteredComments } + console.log('final', final) + return final +} + +function getLastCommentCreatedAt (comments) { + if (comments.length === 0) return null + let latest = comments[0].createdAt + for (const comment of comments) { + if (comment.createdAt > latest) { + latest = comment.createdAt + } + } + return latest +} diff --git a/fragments/comments.js b/fragments/comments.js index 2fd28d0f18..8dab8904d9 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -47,6 +47,7 @@ export const COMMENT_FIELDS = gql` otsHash ncomments nDirectComments + newComments @client imgproxyUrls rel apiKey @@ -116,3 +117,30 @@ export const COMMENTS = gql` } } }` + +export const COMMENT_WITH_NEW = gql` + ${COMMENT_FIELDS} + ${COMMENTS} + + fragment CommentWithNew on Item { + ...CommentFields + comments { + comments { + ...CommentsRecursive + } + } + newComments @client + } +` + +export const GET_NEW_COMMENTS = gql` + ${COMMENT_FIELDS} + + query GetNewComments($rootId: ID, $after: Date) { + newComments(rootId: $rootId, after: $after) { + comments { + ...CommentFields + } + } + } +` diff --git a/fragments/items.js b/fragments/items.js index 151587a202..95eed0421a 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -59,6 +59,7 @@ export const ITEM_FIELDS = gql` bio ncomments nDirectComments + newComments @client commentSats commentCredits lastCommentAt diff --git a/lib/apollo.js b/lib/apollo.js index 3739ba3fd8..a7dcdb01b7 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -313,6 +313,12 @@ function getClient (uri) { } } }, + newComments: { + read (newComments) { + console.log('newComments', newComments) + return newComments || [] + } + }, meAnonSats: { read (existingAmount, { readField }) { if (SSR) return null From c813e59f9d88aea99e89f670ff923f311ffc1773 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 09:27:57 -0500 Subject: [PATCH 02/22] enhance: clear newComments on child comments when we show a topLevel new comment; cleanup: resolvers, logs --- api/resolvers/item.js | 13 +-------- components/comment.js | 48 ++++++++++++++++++++++++--------- components/comments.js | 6 ++--- components/use-live-comments.js | 10 ++----- lib/apollo.js | 1 - 5 files changed, 41 insertions(+), 37 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ee7d898687..35875993c5 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -729,13 +729,6 @@ export default { } }, newComments: async (parent, { rootId, after }, { models, me }) => { - console.log('rootId', rootId) - console.log('after', after) - const item = await models.item.findUnique({ where: { id: Number(rootId) } }) - if (!item) { - throw new GqlInputError('item not found') - } - const comments = await itemQueryWithMeta({ me, models, @@ -748,11 +741,7 @@ export default { ORDER BY "Item"."created_at" ASC` }, Number(rootId), after) - console.log('comments', comments) - - return { - comments - } + return { comments } } }, diff --git a/components/comment.js b/components/comment.js index d0e04a4666..427feadb58 100644 --- a/components/comment.js +++ b/components/comment.js @@ -115,10 +115,6 @@ export default function Comment ({ const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) const { cache } = useApolloClient() - useEffect(() => { - console.log('item', item) - }, [item]) - useEffect(() => { const comment = cache.readFragment({ id: `Item:${router.query.commentId}`, @@ -347,24 +343,45 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } -export function ShowNewComments ({ newComments = [], itemId, updateQuery = false }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { const client = useApolloClient() + const [loading, setLoading] = useState(false) const showNewComments = () => { - if (updateQuery) { + setLoading(true) + if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, variables: { id: itemId } }, (data) => { if (!data) return data const { item } = data - return { - item: { - ...item, - comments: dedupeComments(item, newComments), - newComments: [] + + const updatedComments = { + ...item.comments, + comments: dedupeComments(item, newComments) + } + // first merge in new comments, then clear newComments for the item + const mergedItem = { + ...item, + comments: updatedComments, + newComments: [] + } + // then recursively clear newComments for all nested comments + const clearAllNew = (comment) => { + return { + ...comment, + newComments: [], + comments: comment.comments + ? { + ...comment.comments, + comments: comment.comments.comments.map(child => clearAllNew(child)) + } + : comment.comments } } + const finalItem = clearAllNew(mergedItem) + return { item: finalItem } }) } else { client.cache.updateFragment({ @@ -381,18 +398,23 @@ export function ShowNewComments ({ newComments = [], itemId, updateQuery = false } }) } + setLoading(false) } - const dedupeComments = (item) => { + const dedupeComments = (item, newComments) => { const existingComments = item?.comments?.comments || [] const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) const updatedComments = [...filtered, ...existingComments] return updatedComments } + if (loading && Skeleton) { + return + } + return ( -
+
{newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'}
diff --git a/components/comments.js b/components/comments.js index d0af8abfae..f77581d1e2 100644 --- a/components/comments.js +++ b/components/comments.js @@ -91,6 +91,9 @@ export default function Comments ({ }} /> : null} + {newComments?.length > 0 && ( + + )} {pins.map(item => ( @@ -105,9 +108,6 @@ export default function Comments ({ count={comments?.length} Skeleton={CommentsSkeleton} />} - {newComments?.length > 0 && ( - - )} ) } diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 9d6fa94ed1..44c46877bc 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,15 +7,13 @@ import { useState } from 'react' export function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) - const { data, error } = useQuery(GET_NEW_COMMENTS, SSR + const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { pollInterval: 10000, variables: { rootId, after: lastChecked } }) - console.log('error', error) - if (data && data.newComments) { saveNewComments(client, rootId, data.newComments.comments) const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) @@ -28,7 +26,6 @@ export function useLiveComments (rootId, after) { } export function saveNewComments (client, rootId, newComments) { - console.log('newComments', newComments) for (const comment of newComments) { console.log('comment', comment) const parentId = comment.parentId @@ -72,11 +69,8 @@ function dedupeComment (item, newComment) { const existingNewComments = item.newComments || [] const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] - console.log(item) const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) - const final = { ...item, newComments: filteredComments } - console.log('final', final) - return final + return { ...item, newComments: filteredComments } } function getLastCommentCreatedAt (comments) { diff --git a/lib/apollo.js b/lib/apollo.js index a7dcdb01b7..dc7b8127bd 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -315,7 +315,6 @@ function getClient (uri) { }, newComments: { read (newComments) { - console.log('newComments', newComments) return newComments || [] } }, From c41a4689cefb1873af7f6217599aebb52e0399d6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 18 Apr 2025 14:04:34 -0500 Subject: [PATCH 03/22] handle comments of comments, new structure to clear newComments on childs --- api/typeDefs/item.js | 8 +--- components/comment.js | 47 ++++++++++--------- ...{use-live-comments.js => comments-live.js} | 0 components/comments.js | 2 +- fragments/comments.js | 5 ++ 5 files changed, 33 insertions(+), 29 deletions(-) rename components/{use-live-comments.js => comments-live.js} (100%) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 95bbaf7888..4d19c74401 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,7 +11,7 @@ export default gql` auctionPosition(sub: String, id: ID, boost: Int): Int! boostPosition(sub: String, id: ID, boost: Int): BoostPositions! itemRepetition(parentId: ID): Int! - newComments(rootId: ID, after: Date): NewComments + newComments(rootId: ID, after: Date): Comments! } type BoostPositions { @@ -97,10 +97,6 @@ export default gql` comments: [Item!]! } - type NewComments { - comments: [Item] - } - enum InvoiceActionState { PENDING PENDING_HELD @@ -153,7 +149,7 @@ export default gql` ncomments: Int! nDirectComments: Int! comments(sort: String, cursor: String): Comments! - newComments(rootId: ID, after: Date): NewComments + newComments(rootId: ID, after: Date): Comments! path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 427feadb58..ffc386a22b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -359,7 +359,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S const updatedComments = { ...item.comments, - comments: dedupeComments(item, newComments) + comments: dedupeComments(item.comments, newComments) } // first merge in new comments, then clear newComments for the item const mergedItem = { @@ -368,44 +368,47 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S newComments: [] } // then recursively clear newComments for all nested comments - const clearAllNew = (comment) => { - return { - ...comment, - newComments: [], - comments: comment.comments - ? { - ...comment.comments, - comments: comment.comments.comments.map(child => clearAllNew(child)) - } - : comment.comments - } - } - const finalItem = clearAllNew(mergedItem) + const finalItem = clearNewComments(mergedItem) return { item: finalItem } }) } else { - client.cache.updateFragment({ + const updatedData = client.cache.updateFragment({ id: `Item:${itemId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data + console.log('previous data', data) + return { ...data, - comments: dedupeComments(data, newComments), + comments: dedupeComments(data.comments, newComments), newComments: [] } }) + console.log('new data', updatedData) } setLoading(false) } - const dedupeComments = (item, newComments) => { - const existingComments = item?.comments?.comments || [] - const filtered = newComments.filter(newComment => !existingComments.some(existingComment => existingComment.id === newComment.id)) - const updatedComments = [...filtered, ...existingComments] - return updatedComments + const dedupeComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments?.map(c => c.id)) + const filteredNew = newComments.filter(c => !existingIds.has(c.id)) + return [...filteredNew, ...existingComments.comments] + } + + const clearNewComments = comment => { + return { + ...comment, + newComments: [], + comments: comment?.comments?.comments + ? { + ...comment.comments, + comments: comment.comments.comments.map(clearNewComments) + } + : comment.comments + } } if (loading && Skeleton) { @@ -414,7 +417,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S return ( -
+
{newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'}
diff --git a/components/use-live-comments.js b/components/comments-live.js similarity index 100% rename from components/use-live-comments.js rename to components/comments-live.js diff --git a/components/comments.js b/components/comments.js index f77581d1e2..024b7c5eac 100644 --- a/components/comments.js +++ b/components/comments.js @@ -8,7 +8,7 @@ import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' -import { useLiveComments } from './use-live-comments' +import { useLiveComments } from './comments-live' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() diff --git a/fragments/comments.js b/fragments/comments.js index 8dab8904d9..cbaddc5a75 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -140,6 +140,11 @@ export const GET_NEW_COMMENTS = gql` newComments(rootId: $rootId, after: $after) { comments { ...CommentFields + comments { + comments { + ...CommentFields + } + } } } } From 2a1e9e99abf508c1c30cad3e2b216e0799c3b07a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 04:01:01 -0500 Subject: [PATCH 04/22] use original recursive comments data structure --- components/comment.js | 39 +++++++-------------------------------- fragments/comments.js | 11 +++-------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/components/comment.js b/components/comment.js index ffc386a22b..24a0ea6bca 100644 --- a/components/comment.js +++ b/components/comment.js @@ -345,10 +345,8 @@ export function CommentSkeleton ({ skeletonChildren }) { export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { const client = useApolloClient() - const [loading, setLoading] = useState(false) const showNewComments = () => { - setLoading(true) if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, @@ -357,19 +355,14 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S if (!data) return data const { item } = data - const updatedComments = { - ...item.comments, - comments: dedupeComments(item.comments, newComments) - } - // first merge in new comments, then clear newComments for the item - const mergedItem = { - ...item, - comments: updatedComments, - newComments: [] + console.log('item', item) + return { + item: { + ...item, + comments: dedupeComments(item.comments, newComments), + newComments: [] + } } - // then recursively clear newComments for all nested comments - const finalItem = clearNewComments(mergedItem) - return { item: finalItem } }) } else { const updatedData = client.cache.updateFragment({ @@ -389,7 +382,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S }) console.log('new data', updatedData) } - setLoading(false) } const dedupeComments = (existingComments = [], newComments = []) => { @@ -398,23 +390,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S return [...filteredNew, ...existingComments.comments] } - const clearNewComments = comment => { - return { - ...comment, - newComments: [], - comments: comment?.comments?.comments - ? { - ...comment.comments, - comments: comment.comments.comments.map(clearNewComments) - } - : comment.comments - } - } - - if (loading && Skeleton) { - return - } - return (
diff --git a/fragments/comments.js b/fragments/comments.js index cbaddc5a75..f7e325c4cd 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -134,17 +134,12 @@ export const COMMENT_WITH_NEW = gql` ` export const GET_NEW_COMMENTS = gql` - ${COMMENT_FIELDS} - + ${COMMENTS} + query GetNewComments($rootId: ID, $after: Date) { newComments(rootId: $rootId, after: $after) { comments { - ...CommentFields - comments { - comments { - ...CommentFields - } - } + ...CommentsRecursive } } } From b064c63e3b349485f98db4a5f2f3b01fe48d5af4 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 04:45:11 -0500 Subject: [PATCH 05/22] correct comment structure after deduplication --- components/comment.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/comment.js b/components/comment.js index 24a0ea6bca..500cd7d6ec 100644 --- a/components/comment.js +++ b/components/comment.js @@ -113,6 +113,7 @@ export default function Comment ({ const router = useRouter() const root = useRoot() const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) + const { cache } = useApolloClient() useEffect(() => { @@ -387,7 +388,10 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S const dedupeComments = (existingComments = [], newComments = []) => { const existingIds = new Set(existingComments.comments?.map(c => c.id)) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return [...filteredNew, ...existingComments.comments] + return { + ...existingComments, + comments: [...filteredNew, ...(existingComments.comments || [])] + } } return ( From e0542ce529ce618507f218a5fce2ef2d5ab82e1d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 12:34:06 -0500 Subject: [PATCH 06/22] faster newComments query deduplication, don't need to know how many comments are there --- components/comment.js | 2 +- components/comments-live.js | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/components/comment.js b/components/comment.js index 500cd7d6ec..41bb94fb9d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -398,7 +398,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S
- {newComments.length} new {newComments.length === 1 ? 'reply' : 'replies'} + load new comments
diff --git a/components/comments-live.js b/components/comments-live.js index 44c46877bc..8c1fdadd8f 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -66,11 +66,24 @@ export function saveNewComments (client, rootId, newComments) { } function dedupeComment (item, newComment) { + const existingCommentIds = new Set( + (item.comments?.comments || []).map(c => c.id) + ) const existingNewComments = item.newComments || [] - const alreadyInNewComments = existingNewComments.some(c => c.id === newComment.id) - const updatedNewComments = alreadyInNewComments ? existingNewComments : [...existingNewComments, newComment] - const filteredComments = updatedNewComments.filter((comment) => !item.comments?.comments?.some(c => c.id === comment.id)) - return { ...item, newComments: filteredComments } + + // is the incoming new comment already in item's new comments? + if (existingNewComments.some(c => c.id === newComment.id)) { + return item + } + + // if the incoming new comment is not in item's new comments, add it + // sanity check: and if somehow the incoming new comment is in + // item's new comments, remove it + const updatedNewComments = !existingCommentIds.has(newComment.id) + ? [...existingNewComments, newComment] + : existingNewComments.filter(c => c.id !== newComment.id) + + return { ...item, newComments: updatedNewComments } } function getLastCommentCreatedAt (comments) { From e3076743683f3ad1c0034782734d2b45e8cf4f93 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 19 Apr 2025 12:41:31 -0500 Subject: [PATCH 07/22] cleanup: comments on newComments fetches and dedupes --- components/comment.js | 8 ++------ components/comments-live.js | 29 ++++++++++------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/components/comment.js b/components/comment.js index 41bb94fb9d..8f17e06d6a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -344,7 +344,7 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } -export function ShowNewComments ({ newComments = [], itemId, topLevel = false, Skeleton }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { const client = useApolloClient() const showNewComments = () => { @@ -356,7 +356,6 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S if (!data) return data const { item } = data - console.log('item', item) return { item: { ...item, @@ -366,22 +365,19 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, S } }) } else { - const updatedData = client.cache.updateFragment({ + client.cache.updateFragment({ id: `Item:${itemId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data - console.log('previous data', data) - return { ...data, comments: dedupeComments(data.comments, newComments), newComments: [] } }) - console.log('new data', updatedData) } } diff --git a/components/comments-live.js b/components/comments-live.js index 8c1fdadd8f..5f3311dac0 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -27,45 +27,36 @@ export function useLiveComments (rootId, after) { export function saveNewComments (client, rootId, newComments) { for (const comment of newComments) { - console.log('comment', comment) - const parentId = comment.parentId - if (Number(parentId) === Number(rootId)) { - console.log('parentId', parentId) + const { parentId } = comment + const topLevel = Number(parentId) === Number(rootId) + + // if the comment is a top level comment, update the item + if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, variables: { id: rootId } }, (data) => { - console.log('data', data) if (!data) return data - console.log('dataTopLevel', data) - - const { item } = data - - return { item: dedupeComment(item, comment) } + // we return the entire item, not just the newComments + return { item: dedupeComment(data?.item, comment) } }) } else { - console.log('not top level', parentId) + // if the comment is a reply, update the parent comment client.cache.updateFragment({ id: `Item:${parentId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' }, (data) => { if (!data) return data - - console.log('data', data) - + // here we return the parent comment with the new comment added return dedupeComment(data, comment) }) - console.log('fragment', client.cache.readFragment({ - id: `Item:${parentId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - })) } } } function dedupeComment (item, newComment) { + // get the existing comment ids for faster lookup const existingCommentIds = new Set( (item.comments?.comments || []).map(c => c.id) ) From beb10af5aa8e581b1460b00be2225b0f00f2d960 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 22 Apr 2025 07:35:51 -0500 Subject: [PATCH 08/22] cleanup, use correct function declarations --- components/comments-live.js | 4 ++-- components/comments.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index 5f3311dac0..a7321059dd 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -4,7 +4,7 @@ import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useState } from 'react' -export function useLiveComments (rootId, after) { +export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const { data } = useQuery(GET_NEW_COMMENTS, SSR @@ -25,7 +25,7 @@ export function useLiveComments (rootId, after) { return null } -export function saveNewComments (client, rootId, newComments) { +function saveNewComments (client, rootId, newComments) { for (const comment of newComments) { const { parentId } = comment const topLevel = Number(parentId) === Number(rootId) diff --git a/components/comments.js b/components/comments.js index 024b7c5eac..23f7ce8d87 100644 --- a/components/comments.js +++ b/components/comments.js @@ -8,7 +8,7 @@ import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' -import { useLiveComments } from './comments-live' +import useLiveComments from './comments-live' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() @@ -69,6 +69,7 @@ export default function Comments ({ }) { const router = useRouter() // update item.newComments in cache + // TODO use UserActivation to poll only when the user is actively on page useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) From 4add86e1c8a5bc4916009df299b7b4bc76181d4d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 05:01:57 -0500 Subject: [PATCH 09/22] stop polling after 30 minutes, pause polling if user is not on the page --- components/comments-live.js | 29 ++++++++++++++++++++++++++--- components/comments.js | 11 ++++++++--- components/header.module.css | 24 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index a7321059dd..aac7fdfd89 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -2,15 +2,38 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useState } from 'react' +import { useEffect, useState } from 'react' export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) + const [polling, setPolling] = useState(true) + const engagedAt = new Date() + + useEffect(() => { + if (engagedAt) { + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 60 * 30) { + document.addEventListener('visibilitychange', () => { + const isActive = document.visibilityState === 'visible' + setPolling(isActive) + }) + + return () => { + document.removeEventListener('visibilitychange', () => {}) + } + } else { + setPolling(false) + } + } + }, []) + const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: 10000, + pollInterval: polling ? 10000 : null, variables: { rootId, after: lastChecked } }) @@ -22,7 +45,7 @@ export default function useLiveComments (rootId, after) { } } - return null + return { polling } } function saveNewComments (client, rootId, newComments) { diff --git a/components/comments.js b/components/comments.js index 23f7ce8d87..902763c910 100644 --- a/components/comments.js +++ b/components/comments.js @@ -10,7 +10,7 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -29,6 +29,11 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} + {livePolling && ( + +
+ + )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -79,7 +84,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} livePolling={livePolling} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ diff --git a/components/header.module.css b/components/header.module.css index 1134e8480f..cc3027342b 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -109,4 +109,28 @@ padding-top: 1px; background-color: var(--bs-body-bg); z-index: 1000; +} + + +.newCommentDot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--bs-primary); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + background-color: #FADA5E; + opacity: 0.7; + } + 50% { + background-color: #F6911D; + opacity: 1; + } + 100% { + background-color: #FADA5E; + opacity: 0.7; + } } \ No newline at end of file From 871684901968b62a07cb4b5a1b1f51db052879fb Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 05:12:45 -0500 Subject: [PATCH 10/22] ActionTooltip indicating that the user is in a live comment section --- components/comments.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/comments.js b/components/comments.js index 902763c910..e922bd1684 100644 --- a/components/comments.js +++ b/components/comments.js @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' +import ActionTooltip from './action-tooltip' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() @@ -31,7 +32,9 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {livePolling && ( -
+ +
+ )}
From 8f19b72d5660728283a66ba8c90507d795b40dd7 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 14:33:51 +0200 Subject: [PATCH 11/22] handleVisibilityChange to control polling by visibility --- components/comments-live.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index aac7fdfd89..f8d429f8d3 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -8,27 +8,28 @@ export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const [polling, setPolling] = useState(true) - const engagedAt = new Date() + const [engagedAt] = useState(new Date()) useEffect(() => { - if (engagedAt) { - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { - document.addEventListener('visibilitychange', () => { - const isActive = document.visibilityState === 'visible' - setPolling(isActive) - }) + const handleVisibilityChange = () => { + const isActive = document.visibilityState === 'visible' + setPolling(isActive) + } + + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() - return () => { - document.removeEventListener('visibilitychange', () => {}) - } - } else { - setPolling(false) + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 60 * 30) { + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) } + } else { + setPolling(false) } - }, []) + }, [engagedAt]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} From 7e06381f69876dc5a8b8c038fbd75606c5cd9eaf Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 15:35:33 +0200 Subject: [PATCH 12/22] paused polling styling, check activity on 1 minute intervals and visibility change, light cleanup --- components/comments-live.js | 29 +++++++++++++++++------------ components/comments.js | 26 +++++++++++++++++--------- components/header.module.css | 8 ++++++-- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index f8d429f8d3..82a5fe0242 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -11,23 +11,28 @@ export default function useLiveComments (rootId, after) { const [engagedAt] = useState(new Date()) useEffect(() => { - const handleVisibilityChange = () => { + const checkActivity = () => { + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() const isActive = document.visibilityState === 'visible' - setPolling(isActive) + + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 30 * 60) { + setPolling(isActive) + } else { + setPolling(false) + } } - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() + // check activity every minute + const interval = setInterval(checkActivity, 1000 * 60) - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { - document.addEventListener('visibilitychange', handleVisibilityChange) + // check activity also on visibility change + document.addEventListener('visibilitychange', checkActivity) - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - } else { - setPolling(false) + return () => { + document.removeEventListener('visibilitychange', checkActivity) + clearInterval(interval) } }, [engagedAt]) diff --git a/components/comments.js b/components/comments.js index e922bd1684..cdf44b4fce 100644 --- a/components/comments.js +++ b/components/comments.js @@ -10,6 +10,7 @@ import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments from './comments-live' import ActionTooltip from './action-tooltip' +import classNames from 'classnames' export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { const router = useRouter() @@ -30,13 +31,21 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {livePolling && ( - - -
- - - )} + {livePolling + ? ( + + +
+ + + ) + : ( + + +
+ + + )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -101,7 +109,7 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + )} {pins.map(item => ( diff --git a/components/header.module.css b/components/header.module.css index cc3027342b..33cf610325 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -111,7 +111,6 @@ z-index: 1000; } - .newCommentDot { width: 10px; height: 10px; @@ -120,6 +119,11 @@ animation: pulse 2s infinite; } +.newCommentDot.paused { + background-color: var(--bs-grey-darkmode); + animation: none; +} + @keyframes pulse { 0% { background-color: #FADA5E; @@ -133,4 +137,4 @@ background-color: #FADA5E; opacity: 0.7; } -} \ No newline at end of file +} From 553592e07102595b1b3b030efd181a4ce9e53e87 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 28 Apr 2025 22:47:56 +0200 Subject: [PATCH 13/22] user can resume polling without refreshing the page --- components/comments-live.js | 14 ++++++++++---- components/comments.js | 14 +++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/components/comments-live.js b/components/comments-live.js index 82a5fe0242..020e4011c1 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -8,7 +8,14 @@ export default function useLiveComments (rootId, after) { const client = useApolloClient() const [lastChecked, setLastChecked] = useState(after) const [polling, setPolling] = useState(true) - const [engagedAt] = useState(new Date()) + const [engagedAt, setEngagedAt] = useState(new Date()) + + // reset engagedAt when polling is toggled + useEffect(() => { + if (polling) { + setEngagedAt(new Date()) + } + }, [polling]) useEffect(() => { const checkActivity = () => { @@ -17,7 +24,7 @@ export default function useLiveComments (rootId, after) { const isActive = document.visibilityState === 'visible' // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 30 * 60) { + if (timeSinceEngaged < 1000 * 60 * 30) { setPolling(isActive) } else { setPolling(false) @@ -26,7 +33,6 @@ export default function useLiveComments (rootId, after) { // check activity every minute const interval = setInterval(checkActivity, 1000 * 60) - // check activity also on visibility change document.addEventListener('visibilitychange', checkActivity) @@ -51,7 +57,7 @@ export default function useLiveComments (rootId, after) { } } - return { polling } + return { polling, setPolling } } function saveNewComments (client, rootId, newComments) { diff --git a/components/comments.js b/components/comments.js index cdf44b4fce..a5696c3ec1 100644 --- a/components/comments.js +++ b/components/comments.js @@ -12,7 +12,7 @@ import useLiveComments from './comments-live' import ActionTooltip from './action-tooltip' import classNames from 'classnames' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling, setLivePolling }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -41,8 +41,12 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm ) : ( - -
+ +
setLivePolling(true)} + style={{ cursor: 'pointer' }} + /> )} @@ -86,7 +90,7 @@ export default function Comments ({ }) { const router = useRouter() // update item.newComments in cache - const { polling: livePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) + const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -95,7 +99,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} livePolling={livePolling} setLivePolling={setLivePolling} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ From e9b7b15a2b9d3a93516d70d6f0fc55ce31bf1b64 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 24 Jun 2025 12:25:52 +0200 Subject: [PATCH 14/22] better naming, straightforward dedupeComment on newComment arrival --- components/comment.js | 2 +- components/comments-live.js | 41 +++++++++++++++---------------------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/components/comment.js b/components/comment.js index 8f17e06d6a..d638088a5a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -386,7 +386,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] + comments: [...(existingComments.comments || []), ...filteredNew] } } diff --git a/components/comments-live.js b/components/comments-live.js index 020e4011c1..e665750793 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -49,20 +49,22 @@ export default function useLiveComments (rootId, after) { variables: { rootId, after: lastChecked } }) - if (data && data.newComments) { - saveNewComments(client, rootId, data.newComments.comments) - const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) - if (latestCommentCreatedAt) { - setLastChecked(latestCommentCreatedAt) + useEffect(() => { + if (data && data.newComments) { + saveNewComments(client, rootId, data.newComments.comments) + const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + if (latestCommentCreatedAt) { + setLastChecked(latestCommentCreatedAt) + } } - } + }, [data, client, rootId]) return { polling, setPolling } } function saveNewComments (client, rootId, newComments) { - for (const comment of newComments) { - const { parentId } = comment + for (const newComment of newComments) { + const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item @@ -73,7 +75,7 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // we return the entire item, not just the newComments - return { item: dedupeComment(data?.item, comment) } + return { item: dedupeComment(data?.item, newComment) } }) } else { // if the comment is a reply, update the parent comment @@ -84,32 +86,21 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // here we return the parent comment with the new comment added - return dedupeComment(data, comment) + return dedupeComment(data, newComment) }) } } } function dedupeComment (item, newComment) { - // get the existing comment ids for faster lookup - const existingCommentIds = new Set( - (item.comments?.comments || []).map(c => c.id) - ) const existingNewComments = item.newComments || [] + const existingComments = item.comments?.comments || [] - // is the incoming new comment already in item's new comments? - if (existingNewComments.some(c => c.id === newComment.id)) { + // is the incoming new comment already in item's new comments or existing comments? + if (existingNewComments.some(c => c.id === newComment.id) || existingComments.some(c => c.id === newComment.id)) { return item } - - // if the incoming new comment is not in item's new comments, add it - // sanity check: and if somehow the incoming new comment is in - // item's new comments, remove it - const updatedNewComments = !existingCommentIds.has(newComment.id) - ? [...existingNewComments, newComment] - : existingNewComments.filter(c => c.id !== newComment.id) - - return { ...item, newComments: updatedNewComments } + return { ...item, newComments: [...existingNewComments, newComment] } } function getLastCommentCreatedAt (comments) { From 65b61abee8cfef07b8b22807d2229f01eb2a9270 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 13:00:48 +0200 Subject: [PATCH 15/22] cleanup: better naming, get latest comment creation, correct order of comment injection --- components/comment.js | 2 +- components/comments-live.js | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/components/comment.js b/components/comment.js index d638088a5a..8f17e06d6a 100644 --- a/components/comment.js +++ b/components/comment.js @@ -386,7 +386,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, - comments: [...(existingComments.comments || []), ...filteredNew] + comments: [...filteredNew, ...(existingComments.comments || [])] } } diff --git a/components/comments-live.js b/components/comments-live.js index e665750793..0532f2080f 100644 --- a/components/comments-live.js +++ b/components/comments-live.js @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react' export default function useLiveComments (rootId, after) { const client = useApolloClient() - const [lastChecked, setLastChecked] = useState(after) + const [latest, setLatest] = useState(after) const [polling, setPolling] = useState(true) const [engagedAt, setEngagedAt] = useState(new Date()) @@ -46,18 +46,19 @@ export default function useLiveComments (rootId, after) { ? {} : { pollInterval: polling ? 10000 : null, - variables: { rootId, after: lastChecked } + variables: { rootId, after: latest } }) useEffect(() => { if (data && data.newComments) { saveNewComments(client, rootId, data.newComments.comments) - const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + // check new comments created after the latest new comment + const latestCommentCreatedAt = getLatestCommentCreatedAt(data.newComments.comments, latest) if (latestCommentCreatedAt) { - setLastChecked(latestCommentCreatedAt) + setLatest(latestCommentCreatedAt) } } - }, [data, client, rootId]) + }, [data, client, rootId, latest]) return { polling, setPolling } } @@ -103,13 +104,14 @@ function dedupeComment (item, newComment) { return { ...item, newComments: [...existingNewComments, newComment] } } -function getLastCommentCreatedAt (comments) { +function getLatestCommentCreatedAt (comments, latest) { if (comments.length === 0) return null - let latest = comments[0].createdAt + for (const comment of comments) { if (comment.createdAt > latest) { latest = comment.createdAt } } + return latest } From 8126858e37e36f0fba4dacabd42961925f198af6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 13:39:02 +0200 Subject: [PATCH 16/22] cleanup: refactor live comments related functions to use-live-comments.js --- components/comment.js | 61 +------------------ components/comments.js | 4 +- ...{comments-live.js => use-live-comments.js} | 59 ++++++++++++++++++ 3 files changed, 62 insertions(+), 62 deletions(-) rename components/{comments-live.js => use-live-comments.js} (69%) diff --git a/components/comment.js b/components/comment.js index 8f17e06d6a..a7667ee69d 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,8 +28,7 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' -import { ITEM_FULL } from '@/fragments/items' -import { COMMENT_WITH_NEW } from '@/fragments/comments' +import { ShowNewComments } from './use-live-comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -343,61 +342,3 @@ export function CommentSkeleton ({ skeletonChildren }) {
) } - -export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { - const client = useApolloClient() - - const showNewComments = () => { - if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: itemId } - }, (data) => { - if (!data) return data - const { item } = data - - return { - item: { - ...item, - comments: dedupeComments(item.comments, newComments), - newComments: [] - } - } - }) - } else { - client.cache.updateFragment({ - id: `Item:${itemId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - - return { - ...data, - comments: dedupeComments(data.comments, newComments), - newComments: [] - } - }) - } - } - - const dedupeComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments?.map(c => c.id)) - const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return { - ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] - } - } - - return ( - -
-
- load new comments -
-
-
- - ) -} diff --git a/components/comments.js b/components/comments.js index a5696c3ec1..54eace4ca8 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,5 +1,5 @@ import { Fragment, useMemo } from 'react' -import Comment, { CommentSkeleton, ShowNewComments } from './comment' +import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' import Navbar from 'react-bootstrap/Navbar' @@ -8,7 +8,7 @@ import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' -import useLiveComments from './comments-live' +import useLiveComments, { ShowNewComments } from './use-live-comments' import ActionTooltip from './action-tooltip' import classNames from 'classnames' diff --git a/components/comments-live.js b/components/use-live-comments.js similarity index 69% rename from components/comments-live.js rename to components/use-live-comments.js index 0532f2080f..7300d64e37 100644 --- a/components/comments-live.js +++ b/components/use-live-comments.js @@ -3,6 +3,7 @@ import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useEffect, useState } from 'react' +import styles from './comments.module.css' export default function useLiveComments (rootId, after) { const client = useApolloClient() @@ -115,3 +116,61 @@ function getLatestCommentCreatedAt (comments, latest) { return latest } + +export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { + const client = useApolloClient() + + const showNewComments = () => { + if (topLevel) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: itemId } + }, (data) => { + if (!data) return data + const { item } = data + + return { + item: { + ...item, + comments: dedupeComments(item.comments, newComments), + newComments: [] + } + } + }) + } else { + client.cache.updateFragment({ + id: `Item:${itemId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + return { + ...data, + comments: dedupeComments(data.comments, newComments), + newComments: [] + } + }) + } + } + + const dedupeComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments?.map(c => c.id)) + const filteredNew = newComments.filter(c => !existingIds.has(c.id)) + return { + ...existingComments, + comments: [...filteredNew, ...(existingComments.comments || [])] + } + } + + return ( + +
+
+ load new comments +
+
+
+ + ) +} From 08bbba4cc1c9c26371ff603ac54a042e4a0b4bf5 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 25 Jun 2025 14:15:16 +0200 Subject: [PATCH 17/22] refactor: clearer naming, optimized polling and date retrieval logic, use of constants, general cleanup --- components/use-live-comments.js | 54 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 7300d64e37..44c8fe3baa 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -3,7 +3,11 @@ import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' import { useEffect, useState } from 'react' -import styles from './comments.module.css' +import styles from './comment.module.css' + +const POLL_INTERVAL = 1000 * 10 // 10 seconds +const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes +const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute export default function useLiveComments (rootId, after) { const client = useApolloClient() @@ -25,7 +29,7 @@ export default function useLiveComments (rootId, after) { const isActive = document.visibilityState === 'visible' // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < 1000 * 60 * 30) { + if (timeSinceEngaged < ACTIVITY_TIMEOUT) { setPolling(isActive) } else { setPolling(false) @@ -33,11 +37,12 @@ export default function useLiveComments (rootId, after) { } // check activity every minute - const interval = setInterval(checkActivity, 1000 * 60) + const interval = setInterval(checkActivity, ACTIVITY_CHECK_INTERVAL) // check activity also on visibility change document.addEventListener('visibilitychange', checkActivity) return () => { + // cleanup document.removeEventListener('visibilitychange', checkActivity) clearInterval(interval) } @@ -46,25 +51,22 @@ export default function useLiveComments (rootId, after) { const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: polling ? 10000 : null, + pollInterval: polling ? POLL_INTERVAL : null, variables: { rootId, after: latest } }) useEffect(() => { - if (data && data.newComments) { - saveNewComments(client, rootId, data.newComments.comments) - // check new comments created after the latest new comment - const latestCommentCreatedAt = getLatestCommentCreatedAt(data.newComments.comments, latest) - if (latestCommentCreatedAt) { - setLatest(latestCommentCreatedAt) - } - } - }, [data, client, rootId, latest]) + if (!data?.newComments) return + + cacheNewComments(client, rootId, data.newComments.comments) + // check new comments created after the latest new comment + setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + }, [data, client, rootId]) return { polling, setPolling } } -function saveNewComments (client, rootId, newComments) { +function cacheNewComments (client, rootId, newComments) { for (const newComment of newComments) { const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) @@ -77,7 +79,7 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // we return the entire item, not just the newComments - return { item: dedupeComment(data?.item, newComment) } + return { item: mergeNewComments(data?.item, newComment) } }) } else { // if the comment is a reply, update the parent comment @@ -88,13 +90,13 @@ function saveNewComments (client, rootId, newComments) { }, (data) => { if (!data) return data // here we return the parent comment with the new comment added - return dedupeComment(data, newComment) + return mergeNewComments(data, newComment) }) } } } -function dedupeComment (item, newComment) { +function mergeNewComments (item, newComment) { const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -106,15 +108,15 @@ function dedupeComment (item, newComment) { } function getLatestCommentCreatedAt (comments, latest) { - if (comments.length === 0) return null - - for (const comment of comments) { - if (comment.createdAt > latest) { - latest = comment.createdAt - } - } - - return latest + if (comments.length === 0) return latest + + // timestamp comparison via Math.max on bare timestamps + // convert all createdAt to timestamps + const timestamps = comments.map(c => new Date(c.createdAt).getTime()) + // find the latest timestamp + const maxTimestamp = Math.max(...timestamps, new Date(latest).getTime()) + // convert back to ISO string + return new Date(maxTimestamp).toISOString() } export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { From 6f417cc422d782823962be71fc74ac514e7e712d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 27 Jun 2025 12:26:46 +0200 Subject: [PATCH 18/22] ui: place ShowNewComments in the bottom-right corner of nested comments --- components/comment.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/comment.js b/components/comment.js index a7667ee69d..e053d215fc 100644 --- a/components/comment.js +++ b/components/comment.js @@ -261,6 +261,11 @@ export default function Comment ({ : !noReply && {root.bounty && !bountyPaid && } +
+ {item.newComments?.length > 0 && ( + + )} +
} {children}
@@ -276,9 +281,6 @@ export default function Comment ({ : null} {/* TODO: add link to more comments if they're limited */}
- {item.newComments?.length > 0 && ( - - )}
) )} From 274927d2632a4936ea7f8a476419251784251736 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 27 Jun 2025 12:28:22 +0200 Subject: [PATCH 19/22] fix: make updateQuery sort-aware to correctly inject the comment in the correct Item query --- components/comments.js | 7 +++++-- components/use-live-comments.js | 27 +++++++++++++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/components/comments.js b/components/comments.js index 54eace4ca8..34da7c6680 100644 --- a/components/comments.js +++ b/components/comments.js @@ -89,8 +89,10 @@ export default function Comments ({ commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props }) { const router = useRouter() + // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP + const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) // update item.newComments in cache - const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt) + const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt, sort) const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -113,7 +115,8 @@ export default function Comments ({ /> : null} {newComments?.length > 0 && ( - + // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP + )} {pins.map(item => ( diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 44c8fe3baa..505a82972e 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -9,7 +9,7 @@ const POLL_INTERVAL = 1000 * 10 // 10 seconds const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute -export default function useLiveComments (rootId, after) { +export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) const [polling, setPolling] = useState(true) @@ -58,7 +58,7 @@ export default function useLiveComments (rootId, after) { useEffect(() => { if (!data?.newComments) return - cacheNewComments(client, rootId, data.newComments.comments) + cacheNewComments(client, rootId, data.newComments.comments, sort) // check new comments created after the latest new comment setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) }, [data, client, rootId]) @@ -66,7 +66,7 @@ export default function useLiveComments (rootId, after) { return { polling, setPolling } } -function cacheNewComments (client, rootId, newComments) { +function cacheNewComments (client, rootId, newComments, sort) { for (const newComment of newComments) { const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) @@ -75,7 +75,7 @@ function cacheNewComments (client, rootId, newComments) { if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, - variables: { id: rootId } + variables: { id: rootId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP }, (data) => { if (!data) return data // we return the entire item, not just the newComments @@ -119,14 +119,14 @@ function getLatestCommentCreatedAt (comments, latest) { return new Date(maxTimestamp).toISOString() } -export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { +export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { const client = useApolloClient() const showNewComments = () => { if (topLevel) { client.cache.updateQuery({ query: ITEM_FULL, - variables: { id: itemId } + variables: { id: itemId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP }, (data) => { if (!data) return data const { item } = data @@ -166,13 +166,12 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) } return ( - -
-
- load new comments -
-
-
- +
+ load new comments +
+
) } From 907c71d4ea5d855972fd6f8d8b91d136497c850f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 28 Jun 2025 01:54:58 +0200 Subject: [PATCH 20/22] cleanup: better naming; fix: usecallback on live comments component; fix leak on useEffect because of missing sort atomic apollo cache manipulations; manage top sort not being present in item query cache queue nested comments without a parent, retry on the next poll fix commit messages --- components/comments.js | 27 +------ components/use-live-comments.js | 139 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 96 deletions(-) diff --git a/components/comments.js b/components/comments.js index 34da7c6680..c4fb8a6c66 100644 --- a/components/comments.js +++ b/components/comments.js @@ -9,10 +9,8 @@ import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' import useLiveComments, { ShowNewComments } from './use-live-comments' -import ActionTooltip from './action-tooltip' -import classNames from 'classnames' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling, setLivePolling }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -31,25 +29,6 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} - {livePolling - ? ( - - -
- - - ) - : ( - - -
setLivePolling(true)} - style={{ cursor: 'pointer' }} - /> - - - )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -101,7 +80,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 505a82972e..37edef4025 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,101 +2,97 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState, useRef } from 'react' import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds -const ACTIVITY_TIMEOUT = 1000 * 60 * 30 // 30 minutes -const ACTIVITY_CHECK_INTERVAL = 1000 * 60 // 1 minute + +function itemUpdateQuery (client, id, sort, fn) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: sort === 'top' ? { id } : { id, sort } + }, (data) => fn(data)) +} + +function commentUpdateFragment (client, id, fn) { + client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => fn(data)) +} export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) - const [polling, setPolling] = useState(true) - const [engagedAt, setEngagedAt] = useState(new Date()) - - // reset engagedAt when polling is toggled - useEffect(() => { - if (polling) { - setEngagedAt(new Date()) - } - }, [polling]) - - useEffect(() => { - const checkActivity = () => { - const now = new Date() - const timeSinceEngaged = now.getTime() - engagedAt.getTime() - const isActive = document.visibilityState === 'visible' - - // poll only if the user is active and has been active in the last 30 minutes - if (timeSinceEngaged < ACTIVITY_TIMEOUT) { - setPolling(isActive) - } else { - setPolling(false) - } - } - - // check activity every minute - const interval = setInterval(checkActivity, ACTIVITY_CHECK_INTERVAL) - // check activity also on visibility change - document.addEventListener('visibilitychange', checkActivity) - - return () => { - // cleanup - document.removeEventListener('visibilitychange', checkActivity) - clearInterval(interval) - } - }, [engagedAt]) + const queuedCommentsRef = useRef([]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} : { - pollInterval: polling ? POLL_INTERVAL : null, + pollInterval: POLL_INTERVAL, variables: { rootId, after: latest } }) useEffect(() => { if (!data?.newComments) return - cacheNewComments(client, rootId, data.newComments.comments, sort) - // check new comments created after the latest new comment - setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) - }, [data, client, rootId]) + // live comments can be orphans if the parent comment is not in the cache + // queue them up and retry later, when the parent decides they want the children. + const allComments = [...queuedCommentsRef.current, ...data.newComments.comments] + const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) - return { polling, setPolling } + // keep the queued comments in the ref for the next poll + queuedCommentsRef.current = queuedComments + + // update latest timestamp to the latest comment created at + setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) + }, [data, client, rootId, sort]) } function cacheNewComments (client, rootId, newComments, sort) { + const queuedComments = [] + for (const newComment of newComments) { + console.log('newComment', newComment) const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: rootId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - }, (data) => { + console.log('topLevel', topLevel) + itemUpdateQuery(client, rootId, sort, (data) => { if (!data) return data - // we return the entire item, not just the newComments - return { item: mergeNewComments(data?.item, newComment) } + return { item: mergeNewComment(data?.item, newComment) } }) } else { - // if the comment is a reply, update the parent comment - client.cache.updateFragment({ + // check if parent exists in cache before attempting update + const parentExists = client.cache.readFragment({ id: `Item:${parentId}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - // here we return the parent comment with the new comment added - return mergeNewComments(data, newComment) }) + + if (parentExists) { + // if the comment is a reply, update the parent comment + console.log('reply', parentId) + commentUpdateFragment(client, parentId, (data) => { + if (!data) return data + return mergeNewComment(data, newComment) + }) + } else { + // parent not in cache, queue for retry + queuedComments.push(newComment) + } } } + + return { queuedComments } } -function mergeNewComments (item, newComment) { +// merge new comment into item's newComments +// if the new comment is already in item's newComments or existing comments, do nothing +function mergeNewComment (item, newComment) { const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -122,42 +118,41 @@ function getLatestCommentCreatedAt (comments, latest) { export function ShowNewComments ({ newComments = [], itemId, topLevel = false, sort }) { const client = useApolloClient() - const showNewComments = () => { + const showNewComments = useCallback(() => { if (topLevel) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: { id: itemId, sort } // TODO-LIVE: ok now item updates thanks to sort awareness, but please CLEAN THIS UP - }, (data) => { + console.log('topLevel', topLevel) + itemUpdateQuery(client, itemId, sort, (data) => { + console.log('data', data) if (!data) return data const { item } = data return { item: { ...item, - comments: dedupeComments(item.comments, newComments), + comments: injectComments(item.comments, newComments), newComments: [] } } }) } else { - client.cache.updateFragment({ - id: `Item:${itemId}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { + console.log('reply', itemId) + commentUpdateFragment(client, itemId, (data) => { + console.log('data', data) if (!data) return data return { ...data, - comments: dedupeComments(data.comments, newComments), + comments: injectComments(data.comments, newComments), newComments: [] } }) } - } + }, [client, itemId, newComments, topLevel, sort]) - const dedupeComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments?.map(c => c.id)) + // inject new comments into existing comments + // if the new comment is already in existing comments, do nothing + const injectComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments.map(c => c.id)) const filteredNew = newComments.filter(c => !existingIds.has(c.id)) return { ...existingComments, From e797011a3f21550d152249942e26322bcd8e2e10 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 30 Jun 2025 00:11:19 +0200 Subject: [PATCH 21/22] fix: don't show unpaid comments; cleanup: compact cache merge/dedupe, queue comments via state --- api/resolvers/item.js | 7 ++- components/use-live-comments.js | 82 +++++++++++++-------------------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 4485f6ca97..02a4ace5e4 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -742,8 +742,11 @@ export default { ${SELECT} FROM "Item" -- comments can be nested, so we need to get all comments that are descendants of the root - WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) - AND "Item"."created_at" > $2 + ${whereClause( + '"Item".path <@ (SELECT path FROM "Item" WHERE id = $1)', + activeOrMine(me), + '"Item"."created_at" > $2' + )} ORDER BY "Item"."created_at" ASC` }, Number(rootId), after) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 37edef4025..66b637311e 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -2,16 +2,20 @@ import { useQuery, useApolloClient } from '@apollo/client' import { SSR } from '../lib/constants' import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' import { ITEM_FULL } from '../fragments/items' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useState } from 'react' import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds +// the item query is used to update the item's newComments field function itemUpdateQuery (client, id, sort, fn) { client.cache.updateQuery({ query: ITEM_FULL, variables: sort === 'top' ? { id } : { id, sort } - }, (data) => fn(data)) + }, (data) => { + if (!data) return data + return { item: fn(data.item) } + }) } function commentUpdateFragment (client, id, fn) { @@ -19,13 +23,21 @@ function commentUpdateFragment (client, id, fn) { id: `Item:${id}`, fragment: COMMENT_WITH_NEW, fragmentName: 'CommentWithNew' - }, (data) => fn(data)) + }, (data) => { + if (!data) return data + return { ...data, ...fn(data) } + }) +} + +function dedupeComments (existing = [], incoming = []) { + const existingIds = new Set(existing.map(c => c.id)) + return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] } export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) - const queuedCommentsRef = useRef([]) + const [queue, setQueue] = useState([]) const { data } = useQuery(GET_NEW_COMMENTS, SSR ? {} @@ -39,11 +51,11 @@ export default function useLiveComments (rootId, after, sort) { // live comments can be orphans if the parent comment is not in the cache // queue them up and retry later, when the parent decides they want the children. - const allComments = [...queuedCommentsRef.current, ...data.newComments.comments] + const allComments = [...queue, ...data.newComments.comments] const { queuedComments } = cacheNewComments(client, rootId, allComments, sort) - // keep the queued comments in the ref for the next poll - queuedCommentsRef.current = queuedComments + // keep the queued comments for the next poll + setQueue(queuedComments) // update latest timestamp to the latest comment created at setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest)) @@ -61,10 +73,7 @@ function cacheNewComments (client, rootId, newComments, sort) { // if the comment is a top level comment, update the item if (topLevel) { console.log('topLevel', topLevel) - itemUpdateQuery(client, rootId, sort, (data) => { - if (!data) return data - return { item: mergeNewComment(data?.item, newComment) } - }) + itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { // check if parent exists in cache before attempting update const parentExists = client.cache.readFragment({ @@ -76,10 +85,7 @@ function cacheNewComments (client, rootId, newComments, sort) { if (parentExists) { // if the comment is a reply, update the parent comment console.log('reply', parentId) - commentUpdateFragment(client, parentId, (data) => { - if (!data) return data - return mergeNewComment(data, newComment) - }) + commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } else { // parent not in cache, queue for retry queuedComments.push(newComment) @@ -93,6 +99,7 @@ function cacheNewComments (client, rootId, newComments, sort) { // merge new comment into item's newComments // if the new comment is already in item's newComments or existing comments, do nothing function mergeNewComment (item, newComment) { + console.log('mergeNewComment', item, newComment) const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -119,47 +126,24 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s const client = useApolloClient() const showNewComments = useCallback(() => { + const payload = (data) => { + if (!data) return data + return { + ...data, + comments: { ...data.comments, comments: dedupeComments(data.comments.comments, newComments) }, + newComments: [] + } + } + if (topLevel) { console.log('topLevel', topLevel) - itemUpdateQuery(client, itemId, sort, (data) => { - console.log('data', data) - if (!data) return data - const { item } = data - - return { - item: { - ...item, - comments: injectComments(item.comments, newComments), - newComments: [] - } - } - }) + itemUpdateQuery(client, itemId, sort, payload) } else { console.log('reply', itemId) - commentUpdateFragment(client, itemId, (data) => { - console.log('data', data) - if (!data) return data - - return { - ...data, - comments: injectComments(data.comments, newComments), - newComments: [] - } - }) + commentUpdateFragment(client, itemId, payload) } }, [client, itemId, newComments, topLevel, sort]) - // inject new comments into existing comments - // if the new comment is already in existing comments, do nothing - const injectComments = (existingComments = [], newComments = []) => { - const existingIds = new Set(existingComments.comments.map(c => c.id)) - const filteredNew = newComments.filter(c => !existingIds.has(c.id)) - return { - ...existingComments, - comments: [...filteredNew, ...(existingComments.comments || [])] - } - } - return (
Date: Mon, 30 Jun 2025 11:51:57 +0200 Subject: [PATCH 22/22] fix: read new comments fragments to inject fresh new comments, fixing dropped comments; ui: show amount of new comments refactor: correct function positioning; cleanup: useless logs --- components/use-live-comments.js | 73 +++++++++++++++++---------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/components/use-live-comments.js b/components/use-live-comments.js index 66b637311e..56399e8811 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -7,33 +7,6 @@ import styles from './comment.module.css' const POLL_INTERVAL = 1000 * 10 // 10 seconds -// the item query is used to update the item's newComments field -function itemUpdateQuery (client, id, sort, fn) { - client.cache.updateQuery({ - query: ITEM_FULL, - variables: sort === 'top' ? { id } : { id, sort } - }, (data) => { - if (!data) return data - return { item: fn(data.item) } - }) -} - -function commentUpdateFragment (client, id, fn) { - client.cache.updateFragment({ - id: `Item:${id}`, - fragment: COMMENT_WITH_NEW, - fragmentName: 'CommentWithNew' - }, (data) => { - if (!data) return data - return { ...data, ...fn(data) } - }) -} - -function dedupeComments (existing = [], incoming = []) { - const existingIds = new Set(existing.map(c => c.id)) - return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] -} - export default function useLiveComments (rootId, after, sort) { const client = useApolloClient() const [latest, setLatest] = useState(after) @@ -62,17 +35,37 @@ export default function useLiveComments (rootId, after, sort) { }, [data, client, rootId, sort]) } +// the item query is used to update the item's newComments field +function itemUpdateQuery (client, id, sort, fn) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: sort === 'top' ? { id } : { id, sort } + }, (data) => { + if (!data) return data + return { item: fn(data.item) } + }) +} + +function commentUpdateFragment (client, id, fn) { + client.cache.updateFragment({ + id: `Item:${id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + return fn(data) + }) +} + function cacheNewComments (client, rootId, newComments, sort) { const queuedComments = [] for (const newComment of newComments) { - console.log('newComment', newComment) const { parentId } = newComment const topLevel = Number(parentId) === Number(rootId) // if the comment is a top level comment, update the item if (topLevel) { - console.log('topLevel', topLevel) itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment)) } else { // check if parent exists in cache before attempting update @@ -84,7 +77,6 @@ function cacheNewComments (client, rootId, newComments, sort) { if (parentExists) { // if the comment is a reply, update the parent comment - console.log('reply', parentId) commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment)) } else { // parent not in cache, queue for retry @@ -99,7 +91,6 @@ function cacheNewComments (client, rootId, newComments, sort) { // merge new comment into item's newComments // if the new comment is already in item's newComments or existing comments, do nothing function mergeNewComment (item, newComment) { - console.log('mergeNewComment', item, newComment) const existingNewComments = item.newComments || [] const existingComments = item.comments?.comments || [] @@ -107,9 +98,16 @@ function mergeNewComment (item, newComment) { if (existingNewComments.some(c => c.id === newComment.id) || existingComments.some(c => c.id === newComment.id)) { return item } + return { ...item, newComments: [...existingNewComments, newComment] } } +// dedupe comments by id +function dedupeComments (existing = [], incoming = []) { + const existingIds = new Set(existing.map(c => c.id)) + return [...incoming.filter(c => !existingIds.has(c.id)), ...existing] +} + function getLatestCommentCreatedAt (comments, latest) { if (comments.length === 0) return latest @@ -127,19 +125,22 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s const showNewComments = useCallback(() => { const payload = (data) => { - if (!data) return data + // fresh newComments + const freshNewComments = newComments.map(c => client.cache.readFragment({ + id: `Item:${c.id}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + })) return { ...data, - comments: { ...data.comments, comments: dedupeComments(data.comments.comments, newComments) }, + comments: { ...data.comments, comments: dedupeComments(data.comments.comments, freshNewComments) }, newComments: [] } } if (topLevel) { - console.log('topLevel', topLevel) itemUpdateQuery(client, itemId, sort, payload) } else { - console.log('reply', itemId) commentUpdateFragment(client, itemId, payload) } }, [client, itemId, newComments, topLevel, sort]) @@ -149,7 +150,7 @@ export function ShowNewComments ({ newComments = [], itemId, topLevel = false, s onClick={showNewComments} className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`} > - load new comments + show ({newComments.length}) new comments
)