Skip to content

Commit 1700652

Browse files
committed
enhance: highlight new comment with injected field, recursive injection in every case but top level; cleanups
cleanups: - better separation of concerns for lib/comments.js - don't show new comment count, avoiding useless complexity - simpler topLevel/nested logic - add comments
1 parent e065f17 commit 1700652

File tree

7 files changed

+171
-165
lines changed

7 files changed

+171
-165
lines changed

api/typeDefs/item.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export default gql`
150150
nDirectComments: Int!
151151
comments(sort: String, cursor: String): Comments!
152152
newComments(rootId: ID, after: Date): Comments!
153+
injected: Boolean!
153154
path: String
154155
position: Int
155156
prior: Int

components/comment.js

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,35 @@ export default function Comment ({
140140
}
141141
}, [item.id, cache, router.query.commentId])
142142

143+
const unsetOutline = () => {
144+
ref.current.classList.add('outline-new-comment-unset')
145+
// if the comment is injected, we need to change injected to false
146+
// so that the next time the comment is rendered, it won't be outlined
147+
if (item.injected) {
148+
cache.writeFragment({
149+
id: `Item:${item.id}`,
150+
fragment: gql`
151+
fragment CommentInjected on Item {
152+
injected @client
153+
}`,
154+
data: {
155+
injected: false
156+
}
157+
})
158+
}
159+
}
160+
143161
useEffect(() => {
162+
// an injected new comment needs a different class to reliably outline every new comment
163+
if (item.injected && me?.id !== item.user?.id) {
164+
ref.current.classList.add('outline-new-injected-comment')
165+
}
166+
144167
if (router.query.commentsViewedAt &&
145168
me?.id !== item.user?.id &&
146-
!item.newComments &&
147169
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
148170
ref.current.classList.add('outline-new-comment')
149171
}
150-
151-
// an injected new comment has the newComments field, a different class is needed
152-
// to reliably outline every new comment
153-
if (item.newComments && me?.id !== item.user?.id) {
154-
ref.current.classList.add('outline-new-injected-comment')
155-
}
156172
}, [item.id])
157173

158174
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
@@ -167,8 +183,8 @@ export default function Comment ({
167183
return (
168184
<div
169185
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
170-
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
171-
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
186+
onMouseEnter={unsetOutline}
187+
onTouchStart={unsetOutline}
172188
>
173189
<div className={`${itemStyles.item} ${styles.item}`}>
174190
{item.outlawed && !me?.privates?.wildWestMode

components/show-new-comments.js

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,117 @@
11
import { useCallback, useMemo } from 'react'
22
import { useApolloClient } from '@apollo/client'
33
import styles from './comment.module.css'
4+
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
5+
import { COMMENT_WITH_NEW_RECURSIVE } from '../fragments/comments'
6+
import { commentsViewedAfterComment } from '../lib/new-comments'
47
import {
58
itemUpdateQuery,
69
commentUpdateFragment,
7-
prepareComments,
8-
dedupeNewComments,
9-
collectAllNewComments,
10-
showAllNewCommentsRecursively
10+
getLatestCommentCreatedAt,
11+
updateAncestorsCommentCount
1112
} from '../lib/comments'
1213

14+
// filters out new comments, by id, that already exist in the item's comments
15+
// preventing duplicate comments from being injected
16+
function dedupeNewComments (newComments, comments) {
17+
console.log('dedupeNewComments', newComments, comments)
18+
const existingIds = new Set(comments.map(c => c.id))
19+
return newComments.filter(id => !existingIds.has(id))
20+
}
21+
22+
// prepares and creates a new comments fragment for injection into the cache
23+
// returns a function that can be used to update an item's comments field
24+
function prepareComments (client, newComments) {
25+
return (data) => {
26+
// newComments is an array of comment ids that allows usto read the latest newComments from the cache,
27+
// guaranteeing that we're not reading stale data
28+
const freshNewComments = newComments.map(id => {
29+
const fragment = client.cache.readFragment({
30+
id: `Item:${id}`,
31+
fragment: COMMENT_WITH_NEW_RECURSIVE,
32+
fragmentName: 'CommentWithNewRecursive'
33+
})
34+
35+
if (!fragment) {
36+
return null
37+
}
38+
39+
// marking it as injected so that the new comment can be outlined
40+
return { ...fragment, injected: true }
41+
}).filter(Boolean)
42+
43+
// count the total number of new comments including its nested new comments
44+
let totalNComments = freshNewComments.length
45+
for (const comment of freshNewComments) {
46+
totalNComments += (comment.ncomments || 0)
47+
}
48+
49+
// update all ancestors, but not the item itself
50+
const ancestors = data.path.split('.').slice(0, -1)
51+
updateAncestorsCommentCount(client.cache, ancestors, totalNComments)
52+
53+
// update commentsViewedAt with the most recent fresh new comment
54+
// quirk: this is not the most recent comment, it's the most recent comment in the freshNewComments array
55+
// as such, the next visit will not outline other new comments that have not been injected yet
56+
const latestCommentCreatedAt = getLatestCommentCreatedAt(freshNewComments, data.createdAt)
57+
const rootId = data.path.split('.')[0]
58+
commentsViewedAfterComment(rootId, latestCommentCreatedAt)
59+
60+
// return the updated item with the new comments injected
61+
return {
62+
...data,
63+
comments: { ...data.comments, comments: [...freshNewComments, ...data.comments.comments] },
64+
ncomments: data.ncomments + totalNComments,
65+
newComments: []
66+
}
67+
}
68+
}
69+
70+
// recursively processes and displays all new comments for a thread
71+
// handles comment injection at each level, respecting depth limits
72+
function showAllNewCommentsRecursively (client, item, currentDepth = 1) {
73+
// handle new comments at this item level
74+
if (item.newComments && item.newComments.length > 0) {
75+
const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments)
76+
77+
if (dedupedNewComments.length > 0) {
78+
const payload = prepareComments(client, dedupedNewComments)
79+
commentUpdateFragment(client, item.id, payload)
80+
}
81+
}
82+
83+
// read the updated item from the cache
84+
// this is necessary because the item may have been updated by the time we get to the child comments
85+
const updatedItem = client.cache.readFragment({
86+
id: `Item:${item.id}`,
87+
fragment: COMMENT_WITH_NEW_RECURSIVE,
88+
fragmentName: 'CommentWithNewRecursive'
89+
})
90+
91+
// recursively handle new comments in child comments
92+
if (updatedItem.comments?.comments && currentDepth < (COMMENT_DEPTH_LIMIT - 1)) {
93+
for (const childComment of updatedItem.comments.comments) {
94+
showAllNewCommentsRecursively(client, childComment, currentDepth + 1)
95+
}
96+
}
97+
}
98+
1399
export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHasNewComments, newComments = [], depth = 1 }) => {
14100
const client = useApolloClient()
15101
// if item is provided, we're showing all new comments for a thread,
16102
// otherwise we're showing new comments for a comment
17103
const isThread = !topLevel && item?.path.split('.').length === 2
18-
const allNewComments = useMemo(() => {
19-
if (isThread) {
20-
// TODO: well are we only collecting all new comments just for a fancy UI?
21-
// TODO2: also, we're not deduping new comments here, so we're showing duplicates
22-
return collectAllNewComments(item, depth)
23-
}
24-
return dedupeNewComments(newComments, comments)
25-
}, [isThread, item, newComments, comments, depth])
104+
const allNewComments = useMemo(() => dedupeNewComments(newComments, comments), [newComments, comments])
26105

27106
const showNewComments = useCallback(() => {
28-
if (isThread) {
29-
showAllNewCommentsRecursively(client, item, depth)
30-
} else {
31-
// fetch the latest version of the comments from the cache by their ids
107+
if (topLevel) {
32108
const payload = prepareComments(client, allNewComments)
33-
if (topLevel) {
34-
itemUpdateQuery(client, itemId, sort, payload)
35-
} else {
36-
commentUpdateFragment(client, itemId, payload)
37-
}
109+
itemUpdateQuery(client, itemId, sort, payload)
110+
} else {
111+
showAllNewCommentsRecursively(client, item, depth)
38112
}
39113
setHasNewComments(false)
40-
}, [client, itemId, allNewComments, topLevel, sort])
114+
}, [client, itemId, allNewComments, topLevel, sort, item])
41115

42116
if (allNewComments.length === 0) {
43117
return null
@@ -48,9 +122,7 @@ export const ShowNewComments = ({ topLevel, sort, comments, itemId, item, setHas
48122
onClick={showNewComments}
49123
className={`${topLevel && `d-block fw-bold ${styles.comment} pb-2`} d-flex align-items-center gap-2 px-3 pointer`}
50124
>
51-
{allNewComments.length > 1
52-
? `${isThread ? 'show all ' : ''}${allNewComments.length} new comments`
53-
: 'show new comment'}
125+
show {isThread ? 'all' : ''} new comments
54126
<div className={styles.newCommentDot} />
55127
</div>
56128
)

components/use-live-comments.js

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,46 @@ import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } fro
66

77
const POLL_INTERVAL = 1000 * 10 // 10 seconds
88

9+
// merge new comment into item's newComments
10+
// and prevent duplicates by checking if the comment is already in item's newComments or existing comments
11+
function mergeNewComment (item, newComment) {
12+
const existingNewComments = item.newComments || []
13+
const existingComments = item.comments?.comments || []
14+
15+
// is the incoming new comment already in item's new comments or existing comments?
16+
if (existingNewComments.includes(newComment.id) || existingComments.some(c => c.id === newComment.id)) {
17+
return item
18+
}
19+
20+
return { ...item, newComments: [...existingNewComments, newComment.id] }
21+
}
22+
23+
function cacheNewComments (client, rootId, newComments, sort) {
24+
const queuedComments = []
25+
26+
for (const newComment of newComments) {
27+
const { parentId } = newComment
28+
const topLevel = Number(parentId) === Number(rootId)
29+
30+
// if the comment is a top level comment, update the item
31+
if (topLevel) {
32+
// merge the new comment into the item's newComments field, checking for duplicates
33+
itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment))
34+
} else {
35+
// if the comment is a reply, update the parent comment
36+
// merge the new comment into the parent comment's newComments field, checking for duplicates
37+
const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment))
38+
39+
if (!result) {
40+
// parent not in cache, queue for retry
41+
queuedComments.push(newComment)
42+
}
43+
}
44+
}
45+
46+
return { queuedComments }
47+
}
48+
949
// useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt
1050
// and inserts them into the newComment client field of their parent comment/post.
1151
export default function useLiveComments (rootId, after, sort, setHasNewComments) {
@@ -46,43 +86,3 @@ export default function useLiveComments (rootId, after, sort, setHasNewComments)
4686
}
4787
}, [])
4888
}
49-
50-
function cacheNewComments (client, rootId, newComments, sort) {
51-
const queuedComments = []
52-
53-
for (const newComment of newComments) {
54-
const { parentId } = newComment
55-
const topLevel = Number(parentId) === Number(rootId)
56-
57-
// if the comment is a top level comment, update the item
58-
if (topLevel) {
59-
// merge the new comment into the item's newComments field, checking for duplicates
60-
itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment))
61-
} else {
62-
// if the comment is a reply, update the parent comment
63-
// merge the new comment into the parent comment's newComments field, checking for duplicates
64-
const result = commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment))
65-
66-
if (!result) {
67-
// parent not in cache, queue for retry
68-
queuedComments.push(newComment)
69-
}
70-
}
71-
}
72-
73-
return { queuedComments }
74-
}
75-
76-
// merge new comment into item's newComments
77-
// and prevent duplicates by checking if the comment is already in item's newComments or existing comments
78-
function mergeNewComment (item, newComment) {
79-
const existingNewComments = item.newComments || []
80-
const existingComments = item.comments?.comments || []
81-
82-
// is the incoming new comment already in item's new comments or existing comments?
83-
if (existingNewComments.includes(newComment.id) || existingComments.some(c => c.id === newComment.id)) {
84-
return item
85-
}
86-
87-
return { ...item, newComments: [...existingNewComments, newComment.id] }
88-
}

fragments/comments.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const COMMENT_FIELDS = gql`
4848
ncomments
4949
nDirectComments
5050
newComments @client
51+
injected @client
5152
imgproxyUrls
5253
rel
5354
apiKey
@@ -130,6 +131,7 @@ export const COMMENT_WITH_NEW_RECURSIVE = gql`
130131
}
131132
}
132133
newComments @client
134+
injected @client
133135
}
134136
`
135137

@@ -144,6 +146,7 @@ export const COMMENT_WITH_NEW_LIMITED = gql`
144146
}
145147
}
146148
newComments @client
149+
injected @client
147150
}
148151
`
149152

lib/apollo.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,11 @@ function getClient (uri) {
318318
return newComments || []
319319
}
320320
},
321+
injected: {
322+
read (injected) {
323+
return injected || false
324+
}
325+
},
321326
meAnonSats: {
322327
read (existingAmount, { readField }) {
323328
if (SSR) return null

0 commit comments

Comments
 (0)