Refactor live comments and comment injection (#2462)

* Fix duplicate comment on pessimistic creation

- comment creation checks for comment's ID existence in cache
- invoice.confirmedAt included in useCanEdit deps for anons live comments

* switch to some as sets are not worth it

* only check for duplicates if a pessimistic payment method has been used

* default to empty array

* add comment about side-effects

* record ownership of an item to avoid injecting it via live comments

* trigger check only if the incoming comment is ours, cleanup

* correct conditions, correct comments, light cleanup

* fix: add defensive condition to ownership recorder, better name

* refactor: unified comment injection logic with deduplication, useCommentsView hook; revert sessionStorage-based fix

* adjust live comments naming around the codebase

* listen for hmac presence for anon edits

* always return the injected comment createdAt to bump live comments

* refactor: improve live comments hook readability

- latest comment createdAt persistence helper
- preserveScroll returns the returning value of the callback
- compact conditional logic
- refresh code comments
- refresh naming
- group constants
- reorder imports

* flat comment injection, fetch flat comments instead of the entire subtree that would've been deduplicated anyway, cleanup

* always align new comment fragment to the comments query structure

* generic useCommentsView hook

* update comment counts if live injecting into fragments without comments field

* fix: pass parentId, if a comment has a top level parent it always has the comments field

* fix: update CommentsViewAt only if we actually injected a comment into cache

* correct injectComment result usage

* pass markViewedAt to further centralize side effects, remove live from Item server typedefs

* fix: don't update counts for ancestors that are already up to date, update commentsViewedAt per batch not per comment

* port: fix coalesce, useCommentsView hook and outline changes

* update hmac field in cache on paid invoice, hmac as useCanEdit effect dependency

* comments and light cleanup, update useCommentsView

* efficient hasComments logic for live comments, establish a gql fragment

* fix: typo on topLevel evaluation

* limit extra evaluations to live comments scenarios

* update comments

* support live comments ncomments increments for anon view tracking
This commit is contained in:
soxa 2025-09-07 23:04:34 +02:00 committed by GitHub
parent fbeba23e27
commit f0e3516cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 169 additions and 288 deletions

View File

@ -743,7 +743,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0 subMaxBoost: subAgg?._max.boost || 0
} }
}, },
newComments: async (parent, { topLevelId, after }, { models, me }) => { newComments: async (parent, { itemId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({ const comments = await itemQueryWithMeta({
me, me,
models, models,
@ -757,7 +757,7 @@ export default {
'"Item"."created_at" > $2' '"Item"."created_at" > $2'
)} )}
ORDER BY "Item"."created_at" ASC` ORDER BY "Item"."created_at" ASC`
}, Number(topLevelId), after) }, Number(itemId), after)
return { comments } return { comments }
} }

View File

@ -12,7 +12,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int! auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions! boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
newComments(topLevelId: ID, after: Date): Comments! newComments(itemId: ID, after: Date): Comments!
} }
type BoostPositions { type BoostPositions {
@ -151,7 +151,6 @@ export default gql`
ncomments: Int! ncomments: Int!
nDirectComments: Int! nDirectComments: Int!
comments(sort: String, cursor: String): Comments! comments(sort: String, cursor: String): Comments!
injected: Boolean!
path: String path: String
position: Int position: Int
prior: Int prior: Int

View File

@ -120,11 +120,11 @@ export default function Comment ({
const classes = ref.current.classList const classes = ref.current.classList
const hasOutline = classes.contains('outline-new-comment') const hasOutline = classes.contains('outline-new-comment')
const hasInjectedOutline = classes.contains('outline-new-injected-comment') const hasLiveOutline = classes.contains('outline-new-live-comment')
const hasOutlineUnset = classes.contains('outline-new-comment-unset') const hasOutlineUnset = classes.contains('outline-new-comment-unset')
// don't try to untrack and unset the outline if the comment is not outlined or we already unset the outline // don't try to untrack and unset the outline if the comment is not outlined or we already unset the outline
if (!(hasInjectedOutline || hasOutline) || hasOutlineUnset) return if (!(hasLiveOutline || hasOutline) || hasOutlineUnset) return
classes.add('outline-new-comment-unset') classes.add('outline-new-comment-unset')
// untrack new comment and its descendants if it's not a live comment // untrack new comment and its descendants if it's not a live comment
@ -166,22 +166,22 @@ export default function Comment ({
const viewedAt = me?.id ? meViewedAt : router.query.commentsViewedAt const viewedAt = me?.id ? meViewedAt : router.query.commentsViewedAt
const isNewComment = viewedAt && itemCreatedAt > viewedAt const isNewComment = viewedAt && itemCreatedAt > viewedAt
// injected comments are new regardless of me or anon view time // live comments are new regardless of me or anon view time
const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime() const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime()
const isNewInjectedComment = item.injected && itemCreatedAt > (meViewedAt || rootLast) const isNewLiveComment = item.live && itemCreatedAt > (meViewedAt || rootLast)
if (!isNewComment && !isNewInjectedComment) return if (!isNewComment && !isNewLiveComment) return
if (item.injected) { if (item.live) {
// newly injected comments (item.injected) have to use a different class to outline every new comment // live comments (item.live) have to use a different class to outline every new comment
ref.current.classList.add('outline-new-injected-comment') ref.current.classList.add('outline-new-live-comment')
// wait for the injection animation to end before removing its class // wait for the injection animation to end before removing its class
ref.current.addEventListener('animationend', () => { ref.current.addEventListener('animationend', () => {
ref.current.classList.remove(styles.injectedComment) ref.current.classList.remove(styles.liveComment)
}, { once: true }) }, { once: true })
// animate the live comment injection // animate the live comment injection
ref.current.classList.add(styles.injectedComment) ref.current.classList.add(styles.liveComment)
} else { } else {
ref.current.classList.add('outline-new-comment') ref.current.classList.add('outline-new-comment')
} }

View File

@ -139,7 +139,7 @@
padding-top: .5rem; padding-top: .5rem;
} }
.injectedComment { .liveComment {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }

View File

@ -71,7 +71,7 @@ export default function Comments ({
const router = useRouter() const router = useRouter()
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache // fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort) useLiveComments(parentId, lastCommentAt || parentCreatedAt)
// new comments navigator, tracks new comments and provides navigation controls // new comments navigator, tracks new comments and provides navigation controls
const { navigator } = useCommentsNavigatorContext() const { navigator } = useCommentsNavigatorContext()

View File

@ -4,8 +4,7 @@ export default function preserveScroll (callback) {
// if the scroll position is at the top, we don't need to preserve it, just call the callback // if the scroll position is at the top, we don't need to preserve it, just call the callback
if (scrollTop <= 0) { if (scrollTop <= 0) {
callback() return callback()
return
} }
// get a reference element at the center of the viewport to track if content is added above it // get a reference element at the center of the viewport to track if content is added above it
@ -49,5 +48,5 @@ export default function preserveScroll (callback) {
observer.observe(document.body, { childList: true, subtree: true }) observer.observe(document.body, { childList: true, subtree: true })
callback() return callback()
} }

View File

@ -1,6 +1,5 @@
import { Form, MarkdownInput } from '@/components/form' import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css' import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments'
import { useMe } from './me' import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react' import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button' import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
@ -10,9 +9,9 @@ import { useShowModal } from './modal'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { useRoot } from './root' import { useRoot } from './root'
import { CREATE_COMMENT } from '@/fragments/paidAction' import { CREATE_COMMENT } from '@/fragments/paidAction'
import { injectComment } from '@/lib/comments'
import useItemSubmit from './use-item-submit' import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
import useCommentsView from './use-comments-view' import useCommentsView from './use-comments-view'
export default forwardRef(function Reply ({ export default forwardRef(function Reply ({
@ -52,23 +51,11 @@ export default forwardRef(function Reply ({
update (cache, { data: { upsertComment: { result, invoice } } }) { update (cache, { data: { upsertComment: { result, invoice } } }) {
if (!result) return if (!result) return
cache.modify({ // inject the new comment into the cache
id: `Item:${parentId}`, const injected = injectComment(cache, result)
fields: { if (injected) {
comments (existingComments = {}) { markCommentViewedAt(result.createdAt, { ncomments: 1 })
const newCommentRef = cache.writeFragment({ }
data: result,
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
// no lag for itemRepetition // no lag for itemRepetition
if (!item.mine && me) { if (!item.mine && me) {
@ -80,15 +67,6 @@ export default forwardRef(function Reply ({
} }
}) })
} }
const ancestors = item.path.split('.')
// update all ancestors
updateAncestorsCommentCount(cache, ancestors, 1, parentId)
// 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
markCommentViewedAt(result.createdAt, { ncomments: 1 })
} }
}, },
onSuccessfulSubmit: (data, { resetForm }) => { onSuccessfulSubmit: (data, { resetForm }) => {

View File

@ -20,7 +20,8 @@ export default function useCanEdit (item) {
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed // anonEdit should not override canEdit, but only allow edits if they aren't already allowed
setCanEdit(canEdit => canEdit || anonEdit) setCanEdit(canEdit => canEdit || anonEdit)
}, []) // update when the hmac gets set
}, [item?.invoice?.hmac])
return [canEdit, setCanEdit, editThreshold] return [canEdit, setCanEdit, editThreshold]
} }

View File

@ -109,7 +109,7 @@ export function useCommentsNavigator () {
node.classList.remove( node.classList.remove(
'outline-it', 'outline-it',
'outline-new-comment', 'outline-new-comment',
'outline-new-injected-comment' 'outline-new-live-comment'
) )
node.classList.add('outline-new-comment-unset') node.classList.add('outline-new-comment-unset')
} }

View File

@ -1,156 +1,97 @@
import { useEffect, useState, useCallback } from 'react'
import { useQuery, useApolloClient } from '@apollo/client'
import { SSR } from '../lib/constants'
import preserveScroll from './preserve-scroll' import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments' import { GET_NEW_COMMENTS } from '../fragments/comments'
import { useEffect, useState, useCallback } from 'react' import { injectComment } from '../lib/comments'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import useCommentsView from './use-comments-view' import useCommentsView from './use-comments-view'
import {
updateItemQuery,
updateCommentFragment,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
const POLL_INTERVAL = 1000 * 5 // 5 seconds // live comments polling interval
const POLL_INTERVAL = 1000 * 5
// live comments toggle keys
const STORAGE_DISABLE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
// prepares and creates a fragment for injection into the cache const readStoredLatest = (key, latest) => {
// also handles side effects like updating comment counts and viewedAt timestamps const stored = window.sessionStorage.getItem(key)
function prepareComments (item, cache, newComment) { return stored && stored > latest ? stored : latest
const existingComments = item.comments?.comments || []
// is the incoming new comment already in item's existing comments?
// if so, we don't need to update the cache
if (existingComments.some(comment => comment.id === newComment.id)) return item
// count the new comment (+1) and its children (+ncomments)
const totalNComments = newComment.ncomments + 1
const itemHierarchy = item.path.split('.')
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
// an item can either have a comments.comments field, or not
const payload = item.comments
? {
...item,
ncomments: item.ncomments + totalNComments,
nDirectComments: item.nDirectComments + 1,
comments: {
...item.comments,
comments: [injectedComment, ...item.comments.comments]
}
}
// when the fragment doesn't have a comments field, we just update stats fields
: {
...item,
ncomments: item.ncomments + totalNComments,
nDirectComments: item.nDirectComments + 1
}
return payload
} }
function cacheNewComments (cache, latest, topLevelId, newComments, sort) { // cache new comments and return the most recent timestamp between current latest and new comment
let injectedLatest = latest // regardless of whether the comments were injected or not
for (const newComment of newComments) { function cacheNewComments (cache, latest, itemId, newComments, markCommentViewedAt) {
const { parentId } = newComment let injected = 0
const topLevel = Number(parentId) === Number(topLevelId)
let injected = false
// if the comment is a top level comment, update the item, else update the parent comment const injectedLatest = newComments.reduce((latestTimestamp, newComment) => {
if (topLevel) { const result = injectComment(cache, newComment, { live: true, rootId: itemId })
updateItemQuery(cache, topLevelId, sort, (item) => prepareComments(item, cache, newComment)) // if any comment was injected, increment injected
injected = true injected = result ? injected + 1 : injected
} else { return new Date(newComment.createdAt) > new Date(latestTimestamp)
// if the comment is too deep, we can skip it ? newComment.createdAt
const depth = calculateDepth(newComment.path, topLevelId, parentId) : latestTimestamp
if (depth > COMMENT_DEPTH_LIMIT) continue }, latest)
// inject the new comment into the parent comment's comments field
const updated = updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
injected = !!updated
}
// update latest timestamp to the latest comment created at if (injected > 0) {
if (injected && new Date(newComment.createdAt).getTime() > new Date(injectedLatest).getTime()) { markCommentViewedAt(injectedLatest, { ncomments: injected })
injectedLatest = newComment.createdAt
}
} }
return injectedLatest return injectedLatest
} }
// useLiveComments fetches new comments under an item (topLevelId), // fetches comments for an item that are newer than the latest comment createdAt (after),
// that are newer than the latest comment createdAt (after), and injects them into the cache. // injects them into cache, and keeps scroll position stable.
export default function useLiveComments (topLevelId, after, sort) { export default function useLiveComments (itemId, after) {
const latestKey = `liveCommentsLatest:${topLevelId}` const latestKey = `liveCommentsLatest:${itemId}`
const { cache } = useApolloClient() const { cache } = useApolloClient()
const { markCommentViewedAt } = useCommentsView(topLevelId) const { markCommentViewedAt } = useCommentsView(itemId)
const [disableLiveComments] = useLiveCommentsToggle() const [disableLiveComments] = useLiveCommentsToggle()
const [latest, setLatest] = useState(after) const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
useEffect(() => { useEffect(() => {
const storedLatest = window.sessionStorage.getItem(latestKey) setLatest(readStoredLatest(latestKey, after))
if (storedLatest && storedLatest > after) {
setLatest(storedLatest)
} else {
setLatest(after)
}
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data // Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered // this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true) setInitialized(true)
}, [topLevelId, after]) }, [itemId, after])
const { data } = useQuery(GET_NEW_COMMENTS, { const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL, pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp // only get comments newer than the passed latest timestamp
variables: { topLevelId, after: latest }, variables: { itemId, after: latest },
nextFetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments skip: SSR || !initialized || disableLiveComments
}) })
useEffect(() => { useEffect(() => {
if (!data?.newComments?.comments?.length) return const newComments = data?.newComments?.comments
if (!newComments?.length) return
// directly inject new comments into the cache, preserving scroll position // directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe // quirk: scroll is preserved even if we are not injecting new comments due to dedupe
let injectedLatest = latest const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt))
preserveScroll(() => {
injectedLatest = cacheNewComments(cache, injectedLatest, topLevelId, data.newComments.comments, sort)
})
// sync view time if we successfully injected new comments // if we didn't process any newer comments, bail
if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) { if (new Date(injectedLatest).getTime() <= new Date(latest).getTime()) return
markCommentViewedAt(injectedLatest)
// update latest timestamp to the latest comment created at // update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations // save it to session storage, to persist between client-side navigations
setLatest(injectedLatest) setLatest(injectedLatest)
if (typeof window !== 'undefined') { window.sessionStorage.setItem(latestKey, injectedLatest)
window.sessionStorage.setItem(latestKey, injectedLatest) }, [data, cache, itemId, latest, markCommentViewedAt])
}
}
}, [data, cache, topLevelId, sort, latest, markCommentViewedAt])
} }
const STORAGE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
export function useLiveCommentsToggle () { export function useLiveCommentsToggle () {
const [disableLiveComments, setDisableLiveComments] = useState(false) const [disableLiveComments, setDisableLiveComments] = useState(false)
useEffect(() => { useEffect(() => {
// preference: local storage // preference: local storage
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_KEY) === 'true') const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true')
read() read()
// update across tabs // update across tabs
const onStorage = e => { if (e.key === STORAGE_KEY) read() } const onStorage = e => { if (e.key === STORAGE_DISABLE_KEY) read() }
// update this tab // update this tab
const onToggle = () => read() const onToggle = () => read()
@ -163,12 +104,11 @@ export function useLiveCommentsToggle () {
}, []) }, [])
const toggle = useCallback(() => { const toggle = useCallback(() => {
const current = window.localStorage.getItem(STORAGE_KEY) === 'true' const current = window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true'
window.localStorage.setItem(STORAGE_DISABLE_KEY, !current)
window.localStorage.setItem(STORAGE_KEY, !current)
// trigger local event to update this tab // trigger local event to update this tab
window.dispatchEvent(new Event(TOGGLE_EVENT)) window.dispatchEvent(new Event(TOGGLE_EVENT))
}, [disableLiveComments]) }, [])
return [disableLiveComments, toggle] return [disableLiveComments, toggle]
} }

View File

@ -207,7 +207,8 @@ export const paidActionCacheMods = {
fields: { fields: {
actionState: () => 'PAID', actionState: () => 'PAID',
confirmedAt: () => new Date().toISOString(), confirmedAt: () => new Date().toISOString(),
satsReceived: () => invoice.satsRequested satsReceived: () => invoice.satsRequested,
hmac: () => invoice.hmac
} }
}) })
} }

View File

@ -47,7 +47,7 @@ export const COMMENT_FIELDS = gql`
otsHash otsHash
ncomments ncomments
nDirectComments nDirectComments
injected @client live @client
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
@ -55,6 +55,7 @@ export const COMMENT_FIELDS = gql`
id id
actionState actionState
confirmedAt confirmedAt
hmac
} }
cost cost
} }
@ -94,6 +95,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
commentCredits commentCredits
mine mine
otsHash otsHash
live @client
imgproxyUrls imgproxyUrls
rel rel
apiKey apiKey
@ -101,6 +103,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
id id
actionState actionState
confirmedAt confirmedAt
hmac
} }
cost cost
} }
@ -174,48 +177,19 @@ export const COMMENTS = gql`
} }
}` }`
export const COMMENT_WITH_NEW_RECURSIVE = gql` export const HAS_COMMENTS = gql`
${COMMENT_FIELDS} fragment HasComments on Item {
${COMMENTS} comments
fragment CommentWithNewRecursive on Item {
...CommentFields
comments {
comments {
...CommentsRecursive
}
}
}
`
export const COMMENT_WITH_NEW_LIMITED = gql`
${COMMENT_FIELDS}
fragment CommentWithNewLimited on Item {
...CommentFields
comments {
comments {
...CommentFields
}
}
}
`
export const COMMENT_WITH_NEW_MINIMAL = gql`
${COMMENT_FIELDS}
fragment CommentWithNewMinimal on Item {
...CommentFields
} }
` `
export const GET_NEW_COMMENTS = gql` export const GET_NEW_COMMENTS = gql`
${COMMENTS} ${COMMENT_FIELDS_NO_CHILD_COMMENTS}
query GetNewComments($topLevelId: ID, $after: Date) { query GetNewComments($itemId: ID, $after: Date) {
newComments(topLevelId: $topLevelId, after: $after) { newComments(itemId: $itemId, after: $after) {
comments { comments {
...CommentsRecursive ...CommentFieldsNoChildComments
} }
} }
} }

View File

@ -324,9 +324,9 @@ function getClient (uri) {
} }
} }
}, },
injected: { live: {
read (injected) { read (live) {
return injected || false return live || false
} }
}, },
meAnonSats: { meAnonSats: {

View File

@ -1,98 +1,87 @@
import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_MINIMAL } from '../fragments/comments' import { COMMENTS, HAS_COMMENTS } from '../fragments/comments'
import { ITEM_FULL } from '../fragments/items'
// updates the ncomments field of all ancestors of an item/comment in the cache // adds a comment to the cache, under its parent item
export function updateAncestorsCommentCount (cache, ancestors, increment, parentId) { function cacheComment (cache, newComment, { live = false }) {
// update all ancestors return cache.modify({
id: `Item:${newComment.parentId}`,
fields: {
comments: (existingComments = {}, { readField }) => {
// if the comment already exists, return
if (existingComments?.comments?.some(c => readField('id', c) === newComment.id)) return existingComments
// we need to make sure we're writing a fragment that matches the comments query (comments and count fields)
const newCommentRef = cache.writeFragment({
data: {
comments: {
comments: []
},
ncomments: 0,
nDirectComments: 0,
...newComment,
live
},
fragment: COMMENTS,
fragmentName: 'CommentsRecursive'
})
return {
cursor: existingComments.cursor,
comments: [newCommentRef, ...(existingComments?.comments || [])]
}
}
},
optimistic: true
})
}
// handles cache injection and side-effects for both live and non-live comments
export function injectComment (cache, newComment, { live = false, rootId } = {}) {
// if live and a reply (not top level), check if the parent has comments
const hasComments = live && !(Number(rootId) === Number(newComment.parentId))
? !!(cache.readFragment({
id: `Item:${newComment.parentId}`,
fragment: HAS_COMMENTS
}))
// if not live, we can assume the parent has the comments field since user replied to it
: true
const updated = hasComments && cacheComment(cache, newComment, { live })
// run side effects if injection succeeded or if injecting live comment into SSR item without comments field
if (updated || (live && !hasComments)) {
// update all ancestors comment count, excluding the comment itself
const ancestors = newComment.path.split('.').slice(0, -1)
updateAncestorsCommentCount(cache, ancestors)
return true
}
return false
}
// updates the ncomments and nDirectComments fields of all ancestors of an item/comment in the cache
function updateAncestorsCommentCount (cache, ancestors, { ncomments = 1, nDirectComments = 1 } = {}) {
// update nDirectComments of immediate parent
cache.modify({
id: `Item:${ancestors[ancestors.length - 1]}`,
fields: {
nDirectComments (existingNDirectComments = 0) {
return existingNDirectComments + nDirectComments
}
},
optimistic: true
})
// update ncomments of all ancestors
ancestors.forEach(id => { ancestors.forEach(id => {
cache.modify({ cache.modify({
id: `Item:${id}`, id: `Item:${id}`,
fields: { fields: {
ncomments (existingNComments = 0) { ncomments (existingNComments = 0) {
return existingNComments + increment return existingNComments + ncomments
},
nDirectComments (existingNDirectComments = 0) {
// only increment nDirectComments for the immediate parent
if (parentId && Number(id) === Number(parentId)) {
return existingNDirectComments + 1
}
return existingNDirectComments
} }
}, },
optimistic: true optimistic: true
}) })
}) })
} }
// updates the item query in the cache
// this is used by live comments to update a top level item's comments field
export function updateItemQuery (cache, id, sort, fn) {
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 fallbacks for comments lacking CommentsRecursive or Comments altogether
export function updateCommentFragment (cache, id, fn) {
let result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_RECURSIVE,
fragmentName: 'CommentWithNewRecursive'
}, (data) => {
if (!data) return data
return fn(data)
})
// sometimes comments can start to 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 = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_LIMITED,
fragmentName: 'CommentWithNewLimited'
}, (data) => {
if (!data) return data
return fn(data)
})
}
// at the deepest level, the comment can't have any children, here we update only the newComments field.
if (!result) {
result = cache.updateFragment({
id: `Item:${id}`,
fragment: COMMENT_WITH_NEW_MINIMAL,
fragmentName: 'CommentWithNewMinimal'
}, (data) => {
if (!data) return data
return fn(data)
})
}
return result
}
export function calculateDepth (path, rootId, parentId) {
// calculate depth by counting path segments from root to parent
const pathSegments = path.split('.')
const rootIndex = pathSegments.indexOf(rootId.toString())
const parentIndex = pathSegments.indexOf(parentId.toString())
// depth is the distance from root to parent in the path
const depth = parentIndex - rootIndex
return depth
}
// 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
)
}

View File

@ -903,7 +903,7 @@ div[contenteditable]:focus,
box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25); box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25);
} }
.outline-new-injected-comment { .outline-new-live-comment {
box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25); box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25);
} }
@ -911,7 +911,7 @@ div[contenteditable]:focus,
box-shadow: none; box-shadow: none;
} }
.outline-new-injected-comment.outline-new-comment-unset { .outline-new-live-comment.outline-new-comment-unset {
box-shadow: none; box-shadow: none;
} }