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