}
diff --git a/components/reply.js b/components/reply.js
index ec12d0a5f..e89816c70 100644
--- a/components/reply.js
+++ b/components/reply.js
@@ -13,6 +13,7 @@ import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
+import { updateAncestorsCommentCount } from '@/lib/comments'
export default forwardRef(function Reply ({
item,
@@ -82,17 +83,7 @@ export default forwardRef(function Reply ({
const ancestors = item.path.split('.')
// update all ancestors
- ancestors.forEach(id => {
- cache.modify({
- id: `Item:${id}`,
- fields: {
- ncomments (existingNComments = 0) {
- return existingNComments + 1
- }
- },
- optimistic: true
- })
- })
+ updateAncestorsCommentCount(cache, ancestors, 1)
// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did
diff --git a/components/show-new-comments.js b/components/show-new-comments.js
new file mode 100644
index 000000000..2356d16d3
--- /dev/null
+++ b/components/show-new-comments.js
@@ -0,0 +1,158 @@
+import { useCallback, useMemo } from 'react'
+import { useApolloClient } from '@apollo/client'
+import styles from './comment.module.css'
+import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
+import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments'
+import { commentsViewedAfterComment } from '../lib/new-comments'
+import {
+ itemUpdateQuery,
+ commentUpdateFragment,
+ getLatestCommentCreatedAt,
+ updateAncestorsCommentCount
+} from '../lib/comments'
+
+// filters out new comments, by id, that already exist in the item's comments
+// preventing duplicate comments from being injected
+function dedupeNewComments (newComments, comments = []) {
+ const existingIds = new Set(comments.map(c => c.id))
+ return newComments.filter(id => !existingIds.has(id))
+}
+
+// prepares and creates a new comments fragment for injection into the cache
+// returns a function that can be used to update an item's comments field
+function prepareComments (client, newComments) {
+ return (data) => {
+ // newComments is an array of comment ids that allows us to read the latest newComments from the cache,
+ // guaranteeing that we're not reading stale data
+ const freshNewComments = newComments.map(id => {
+ const fragment = client.cache.readFragment({
+ id: `Item:${id}`,
+ fragment: COMMENT_WITH_NEW_RECURSIVE,
+ fragmentName: 'CommentWithNewRecursive'
+ })
+
+ if (!fragment) {
+ return null
+ }
+
+ // marking it as injected so that the new comment can be outlined
+ return { ...fragment, injected: true }
+ }).filter(Boolean)
+
+ // count the total number of new comments including its nested new comments
+ let totalNComments = freshNewComments.length
+ for (const comment of freshNewComments) {
+ totalNComments += (comment.ncomments || 0)
+ }
+
+ // update all ancestors, but not the item itself
+ const ancestors = data.path.split('.').slice(0, -1)
+ updateAncestorsCommentCount(client.cache, ancestors, totalNComments)
+
+ // update commentsViewedAt with the most recent fresh new comment
+ // quirk: this is not the most recent comment, it's the most recent comment in the freshNewComments array
+ // as such, the next visit may not outline other new comments that have not been injected yet
+ const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt)
+ const rootId = data.path.split('.')[0]
+ commentsViewedAfterComment(rootId, latestCommentCreatedAt)
+
+ // return the updated item with the new comments injected
+ return {
+ ...data,
+ comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] },
+ ncomments: data.ncomments + totalNComments,
+ newComments: []
+ }
+ }
+}
+
+// recursively processes and displays all new comments for a thread
+// handles comment injection at each level, respecting depth limits
+function showAllNewCommentsRecursively (client, item, currentDepth = 1) {
+ // handle new comments at this item level
+ if (item.newComments && item.newComments.length > 0) {
+ const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments)
+
+ if (dedupedNewComments.length > 0) {
+ const payload = prepareComments(client, dedupedNewComments)
+ commentUpdateFragment(client, item.id, payload)
+ }
+ }
+
+ // read the updated item from the cache
+ // this is necessary because the item may have been updated by the time we get to the child comments
+ // comments nearing the depth limit lack the recursive structure, so we also need to read the limited fragment
+ const updatedItem = client.cache.readFragment({
+ id: `Item:${item.id}`,
+ fragment: COMMENT_WITH_NEW_RECURSIVE,
+ fragmentName: 'CommentWithNewRecursive'
+ }) || client.cache.readFragment({
+ id: `Item:${item.id}`,
+ fragment: COMMENT_WITH_NEW_LIMITED,
+ fragmentName: 'CommentWithNewLimited'
+ })
+
+ // recursively handle new comments in child comments
+ if (updatedItem?.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) {
+ for (const childComment of updatedItem.comments.comments) {
+ showAllNewCommentsRecursively(client, childComment, currentDepth + 1)
+ }
+ }
+}
+
+// recursively collects all new comments from an item and its children
+// by respecting the depth limit, we avoid collecting new comments to inject in places
+// that are too deep in the tree
+export function collectAllNewComments (item, currentDepth = 1) {
+ let allNewComments = [...(item.newComments || [])]
+
+ // dedupe against the existing comments at this level
+ if (item.comments?.comments) {
+ allNewComments = dedupeNewComments(allNewComments, item.comments.comments)
+
+ if (currentDepth < (COMMENT_DEPTH_LIMIT - 1)) {
+ for (const comment of item.comments.comments) {
+ allNewComments.push(...collectAllNewComments(comment, currentDepth + 1))
+ }
+ }
+ }
+
+ return allNewComments
+}
+
+// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field
+export function ShowNewComments ({ topLevel, sort, comments, itemId, item, newComments = [], depth = 1 }) {
+ const client = useApolloClient()
+ // if item is provided, we're showing all new comments for a thread,
+ // otherwise we're showing new comments for a comment
+ const isThread = !topLevel && item?.path.split('.').length === 2
+ const allNewComments = useMemo(() => {
+ if (item) {
+ return collectAllNewComments(item, depth)
+ }
+ return dedupeNewComments(newComments, comments)
+ }, [newComments, comments, item, depth, topLevel])
+
+ const showNewComments = useCallback(() => {
+ if (topLevel) {
+ const payload = prepareComments(client, allNewComments)
+ itemUpdateQuery(client, itemId, sort, payload)
+ } else {
+ showAllNewCommentsRecursively(client, item, depth)
+ }
+ }, [client, itemId, allNewComments, topLevel, sort, item, depth])
+
+ if (allNewComments.length === 0) {
+ return null
+ }
+
+ return (
+
+ show {isThread ? 'all' : ''} new comments
+
+
+ )
+}
diff --git a/components/use-live-comments.js b/components/use-live-comments.js
new file mode 100644
index 000000000..0d8b107af
--- /dev/null
+++ b/components/use-live-comments.js
@@ -0,0 +1,85 @@
+import { useQuery, useApolloClient } from '@apollo/client'
+import { SSR } from '../lib/constants'
+import { GET_NEW_COMMENTS } from '../fragments/comments'
+import { useEffect, useRef, useState } from 'react'
+import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } from '../lib/comments'
+
+const POLL_INTERVAL = 1000 * 10 // 10 seconds
+
+// merge new comment into item's newComments
+// and prevent duplicates by checking if the comment is already in item's newComments or existing comments
+function mergeNewComment (item, newComment) {
+ const existingNewComments = item.newComments || []
+
+ // is the incoming new comment already in item's new comments or existing comments?
+ if (existingNewComments.includes(newComment.id)) {
+ return item
+ }
+
+ return { ...item, newComments: [...existingNewComments, newComment.id] }
+}
+
+function cacheNewComments (client, rootId, newComments, sort) {
+ const queuedComments = []
+
+ for (const newComment of newComments) {
+ const { parentId } = newComment
+ const topLevel = Number(parentId) === Number(rootId)
+
+ // if the comment is a top level comment, update the item
+ if (topLevel) {
+ // merge the new comment into the item's newComments field, checking for duplicates
+ itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment))
+ } else {
+ // if the comment is a reply, update the parent comment
+ // merge the new comment into the parent comment's newComments field, checking for duplicates
+ const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment))
+
+ if (!result) {
+ // parent not in cache, queue for retry
+ queuedComments.push(newComment)
+ }
+ }
+ }
+
+ return { queuedComments }
+}
+
+// useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt
+// and inserts them into the newComment client field of their parent comment/post.
+export default function useLiveComments (rootId, after, sort) {
+ const client = useApolloClient()
+ const [latest, setLatest] = useState(after)
+ const queue = useRef([])
+
+ const { data } = useQuery(GET_NEW_COMMENTS, SSR
+ ? {}
+ : {
+ pollInterval: POLL_INTERVAL,
+ // only get comments newer than the passed latest timestamp
+ variables: { rootId, after: latest },
+ nextFetchPolicy: 'cache-and-network'
+ })
+
+ useEffect(() => {
+ if (!data?.newComments?.comments?.length) return
+
+ // sometimes new comments can arrive as orphans because their parent might not be in the cache yet
+ // queue them up, retry until the parent shows up.
+ const newComments = [...data.newComments.comments, ...queue.current]
+ const { queuedComments } = cacheNewComments(client, rootId, newComments, sort)
+
+ // keep the queued comments for the next poll
+ queue.current = queuedComments
+
+ // update latest timestamp to the latest comment created at
+ setLatest(prevLatest => getLatestCommentCreatedAt(data.newComments.comments, prevLatest))
+ }, [data, client, rootId, sort])
+
+ // cleanup queue on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ queue.current = []
+ }
+ }, [])
+}
diff --git a/fragments/comments.js b/fragments/comments.js
index 2fd28d0f1..d4330be7b 100644
--- a/fragments/comments.js
+++ b/fragments/comments.js
@@ -47,6 +47,8 @@ export const COMMENT_FIELDS = gql`
otsHash
ncomments
nDirectComments
+ newComments @client
+ injected @client
imgproxyUrls
rel
apiKey
@@ -116,3 +118,57 @@ export const COMMENTS = gql`
}
}
}`
+
+export const COMMENT_WITH_NEW_RECURSIVE = gql`
+ ${COMMENT_FIELDS}
+ ${COMMENTS}
+
+ fragment CommentWithNewRecursive on Item {
+ ...CommentFields
+ comments {
+ comments {
+ ...CommentsRecursive
+ }
+ }
+ newComments @client
+ injected @client
+ }
+`
+
+export const COMMENT_WITH_NEW_LIMITED = gql`
+ ${COMMENT_FIELDS}
+
+ fragment CommentWithNewLimited on Item {
+ ...CommentFields
+ comments {
+ comments {
+ ...CommentFields
+ }
+ }
+ newComments @client
+ injected @client
+ }
+`
+
+// TODO: fragment for comments without item.comments field
+// TODO: remove if useless to pursue
+export const COMMENT_WITH_NEW = gql`
+ ${COMMENT_FIELDS}
+
+ fragment CommentWithNew on Item {
+ ...CommentFields
+ newComments @client
+ }
+`
+
+export const GET_NEW_COMMENTS = gql`
+ ${COMMENTS}
+
+ query GetNewComments($rootId: ID, $after: Date) {
+ newComments(rootId: $rootId, after: $after) {
+ comments {
+ ...CommentsRecursive
+ }
+ }
+ }
+`
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 4a60c2a56..14d06f5e5 100644
--- a/lib/apollo.js
+++ b/lib/apollo.js
@@ -323,6 +323,16 @@ function getClient (uri) {
}
}
},
+ newComments: {
+ read (newComments) {
+ return newComments || []
+ }
+ },
+ injected: {
+ read (injected) {
+ return injected || false
+ }
+ },
meAnonSats: {
read (existingAmount, { readField }) {
if (SSR) return null
diff --git a/lib/comments.js b/lib/comments.js
new file mode 100644
index 000000000..8f842d89d
--- /dev/null
+++ b/lib/comments.js
@@ -0,0 +1,68 @@
+import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED } from '../fragments/comments'
+import { ITEM_FULL } from '../fragments/items'
+
+// updates the ncomments field of all ancestors of an item/comment in the cache
+export function updateAncestorsCommentCount (cache, ancestors, increment) {
+ // update all ancestors
+ ancestors.forEach(id => {
+ cache.modify({
+ id: `Item:${id}`,
+ fields: {
+ ncomments (existingNComments = 0) {
+ return existingNComments + increment
+ }
+ },
+ optimistic: true
+ })
+ })
+}
+
+// live comments - cache manipulations
+// updates the item query in the cache
+// this is used by live comments to update a top level item's newComments field
+export function itemUpdateQuery (client, id, sort, fn) {
+ client.cache.updateQuery({
+ query: ITEM_FULL,
+ // updateQuery needs the correct variables to update the correct item
+ // the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists
+ variables: sort ? { id, sort } : { id }
+ }, (data) => {
+ if (!data) return data
+ return { item: fn(data.item) }
+ })
+}
+
+// updates a comment fragment in the cache, with a fallback for comments lacking CommentsRecursive
+export function commentUpdateFragment (client, id, fn) {
+ let result = client.cache.updateFragment({
+ id: `Item:${id}`,
+ fragment: COMMENT_WITH_NEW_RECURSIVE,
+ fragmentName: 'CommentWithNewRecursive'
+ }, (data) => {
+ if (!data) return data
+ return fn(data)
+ })
+
+ // sometimes comments can reach their depth limit, and lack adherence to the CommentsRecursive fragment
+ // for this reason, we update the fragment with a limited version that only includes the CommentFields fragment
+ if (!result) {
+ result = client.cache.updateFragment({
+ id: `Item:${id}`,
+ fragment: COMMENT_WITH_NEW_LIMITED,
+ fragmentName: 'CommentWithNewLimited'
+ }, (data) => {
+ if (!data) return data
+ return fn(data)
+ })
+ }
+
+ return result
+}
+
+// finds the most recent createdAt timestamp from an array of comments
+export function getLatestCommentCreatedAt (comments, latest) {
+ return comments.reduce(
+ (max, { createdAt }) => (createdAt > max ? createdAt : max),
+ latest
+ )
+}
diff --git a/public/favicon-new-comment.png b/public/favicon-new-comment.png
new file mode 100644
index 000000000..53f48c988
Binary files /dev/null and b/public/favicon-new-comment.png differ
diff --git a/styles/globals.scss b/styles/globals.scss
index a0667da34..0d8659782 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -908,10 +908,18 @@ div[contenteditable]:focus,
box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25);
}
+.outline-new-injected-comment {
+ box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25);
+}
+
.outline-new-comment.outline-new-comment-unset {
box-shadow: none;
}
+.outline-new-injected-comment.outline-new-comment-unset {
+ box-shadow: none;
+}
+
.outline-new-comment .outline-new-comment {
box-shadow: none;
}