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:
parent
fbeba23e27
commit
f0e3516cf0
@ -743,7 +743,7 @@ export default {
|
||||
subMaxBoost: subAgg?._max.boost || 0
|
||||
}
|
||||
},
|
||||
newComments: async (parent, { topLevelId, after }, { models, me }) => {
|
||||
newComments: async (parent, { itemId, after }, { models, me }) => {
|
||||
const comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
@ -757,7 +757,7 @@ export default {
|
||||
'"Item"."created_at" > $2'
|
||||
)}
|
||||
ORDER BY "Item"."created_at" ASC`
|
||||
}, Number(topLevelId), after)
|
||||
}, Number(itemId), after)
|
||||
|
||||
return { comments }
|
||||
}
|
||||
|
@ -12,7 +12,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(topLevelId: ID, after: Date): Comments!
|
||||
newComments(itemId: ID, after: Date): Comments!
|
||||
}
|
||||
|
||||
type BoostPositions {
|
||||
@ -151,7 +151,6 @@ export default gql`
|
||||
ncomments: Int!
|
||||
nDirectComments: Int!
|
||||
comments(sort: String, cursor: String): Comments!
|
||||
injected: Boolean!
|
||||
path: String
|
||||
position: Int
|
||||
prior: Int
|
||||
|
@ -120,11 +120,11 @@ export default function Comment ({
|
||||
|
||||
const classes = ref.current.classList
|
||||
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')
|
||||
|
||||
// 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')
|
||||
// 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 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 isNewInjectedComment = item.injected && itemCreatedAt > (meViewedAt || rootLast)
|
||||
const isNewLiveComment = item.live && itemCreatedAt > (meViewedAt || rootLast)
|
||||
|
||||
if (!isNewComment && !isNewInjectedComment) return
|
||||
if (!isNewComment && !isNewLiveComment) return
|
||||
|
||||
if (item.injected) {
|
||||
// newly injected comments (item.injected) have to use a different class to outline every new comment
|
||||
ref.current.classList.add('outline-new-injected-comment')
|
||||
if (item.live) {
|
||||
// live comments (item.live) have to use a different class to outline every new comment
|
||||
ref.current.classList.add('outline-new-live-comment')
|
||||
|
||||
// wait for the injection animation to end before removing its class
|
||||
ref.current.addEventListener('animationend', () => {
|
||||
ref.current.classList.remove(styles.injectedComment)
|
||||
ref.current.classList.remove(styles.liveComment)
|
||||
}, { once: true })
|
||||
// animate the live comment injection
|
||||
ref.current.classList.add(styles.injectedComment)
|
||||
ref.current.classList.add(styles.liveComment)
|
||||
} else {
|
||||
ref.current.classList.add('outline-new-comment')
|
||||
}
|
||||
|
@ -139,7 +139,7 @@
|
||||
padding-top: .5rem;
|
||||
}
|
||||
|
||||
.injectedComment {
|
||||
.liveComment {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
|
@ -71,7 +71,7 @@ export default function Comments ({
|
||||
const router = useRouter()
|
||||
|
||||
// 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
|
||||
const { navigator } = useCommentsNavigatorContext()
|
||||
|
@ -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 (scrollTop <= 0) {
|
||||
callback()
|
||||
return
|
||||
return callback()
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
callback()
|
||||
return callback()
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Form, MarkdownInput } from '@/components/form'
|
||||
import styles from './reply.module.css'
|
||||
import { COMMENTS } from '@/fragments/comments'
|
||||
import { useMe } from './me'
|
||||
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
|
||||
@ -10,9 +9,9 @@ import { useShowModal } from './modal'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useRoot } from './root'
|
||||
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||
import { injectComment } from '@/lib/comments'
|
||||
import useItemSubmit from './use-item-submit'
|
||||
import gql from 'graphql-tag'
|
||||
import { updateAncestorsCommentCount } from '@/lib/comments'
|
||||
import useCommentsView from './use-comments-view'
|
||||
|
||||
export default forwardRef(function Reply ({
|
||||
@ -52,23 +51,11 @@ export default forwardRef(function Reply ({
|
||||
update (cache, { data: { upsertComment: { result, invoice } } }) {
|
||||
if (!result) return
|
||||
|
||||
cache.modify({
|
||||
id: `Item:${parentId}`,
|
||||
fields: {
|
||||
comments (existingComments = {}) {
|
||||
const newCommentRef = cache.writeFragment({
|
||||
data: result,
|
||||
fragment: COMMENTS,
|
||||
fragmentName: 'CommentsRecursive'
|
||||
})
|
||||
return {
|
||||
cursor: existingComments.cursor,
|
||||
comments: [newCommentRef, ...(existingComments?.comments || [])]
|
||||
// inject the new comment into the cache
|
||||
const injected = injectComment(cache, result)
|
||||
if (injected) {
|
||||
markCommentViewedAt(result.createdAt, { ncomments: 1 })
|
||||
}
|
||||
}
|
||||
},
|
||||
optimistic: true
|
||||
})
|
||||
|
||||
// no lag for itemRepetition
|
||||
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 }) => {
|
||||
|
@ -20,7 +20,8 @@ export default function useCanEdit (item) {
|
||||
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
|
||||
setCanEdit(canEdit => canEdit || anonEdit)
|
||||
}, [])
|
||||
// update when the hmac gets set
|
||||
}, [item?.invoice?.hmac])
|
||||
|
||||
return [canEdit, setCanEdit, editThreshold]
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ export function useCommentsNavigator () {
|
||||
node.classList.remove(
|
||||
'outline-it',
|
||||
'outline-new-comment',
|
||||
'outline-new-injected-comment'
|
||||
'outline-new-live-comment'
|
||||
)
|
||||
node.classList.add('outline-new-comment-unset')
|
||||
}
|
||||
|
@ -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 { GET_NEW_COMMENTS } from '../fragments/comments'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
||||
import { useQuery, useApolloClient } from '@apollo/client'
|
||||
import { injectComment } from '../lib/comments'
|
||||
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
|
||||
// also handles side effects like updating comment counts and viewedAt timestamps
|
||||
function prepareComments (item, cache, newComment) {
|
||||
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
|
||||
const readStoredLatest = (key, latest) => {
|
||||
const stored = window.sessionStorage.getItem(key)
|
||||
return stored && stored > latest ? stored : latest
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
// cache new comments and return the most recent timestamp between current latest and new comment
|
||||
// regardless of whether the comments were injected or not
|
||||
function cacheNewComments (cache, latest, itemId, newComments, markCommentViewedAt) {
|
||||
let injected = 0
|
||||
|
||||
function cacheNewComments (cache, latest, topLevelId, newComments, sort) {
|
||||
let injectedLatest = latest
|
||||
for (const newComment of newComments) {
|
||||
const { parentId } = newComment
|
||||
const topLevel = Number(parentId) === Number(topLevelId)
|
||||
let injected = false
|
||||
const injectedLatest = newComments.reduce((latestTimestamp, newComment) => {
|
||||
const result = injectComment(cache, newComment, { live: true, rootId: itemId })
|
||||
// if any comment was injected, increment injected
|
||||
injected = result ? injected + 1 : injected
|
||||
return new Date(newComment.createdAt) > new Date(latestTimestamp)
|
||||
? newComment.createdAt
|
||||
: latestTimestamp
|
||||
}, latest)
|
||||
|
||||
// if the comment is a top level comment, update the item, else update the parent comment
|
||||
if (topLevel) {
|
||||
updateItemQuery(cache, topLevelId, sort, (item) => prepareComments(item, cache, newComment))
|
||||
injected = true
|
||||
} else {
|
||||
// if the comment is too deep, we can skip it
|
||||
const depth = calculateDepth(newComment.path, topLevelId, parentId)
|
||||
if (depth > COMMENT_DEPTH_LIMIT) continue
|
||||
// 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 && new Date(newComment.createdAt).getTime() > new Date(injectedLatest).getTime()) {
|
||||
injectedLatest = newComment.createdAt
|
||||
}
|
||||
if (injected > 0) {
|
||||
markCommentViewedAt(injectedLatest, { ncomments: injected })
|
||||
}
|
||||
|
||||
return injectedLatest
|
||||
}
|
||||
|
||||
// useLiveComments fetches new comments under an item (topLevelId),
|
||||
// that are newer than the latest comment createdAt (after), and injects them into the cache.
|
||||
export default function useLiveComments (topLevelId, after, sort) {
|
||||
const latestKey = `liveCommentsLatest:${topLevelId}`
|
||||
// fetches comments for an item that are newer than the latest comment createdAt (after),
|
||||
// injects them into cache, and keeps scroll position stable.
|
||||
export default function useLiveComments (itemId, after) {
|
||||
const latestKey = `liveCommentsLatest:${itemId}`
|
||||
const { cache } = useApolloClient()
|
||||
const { markCommentViewedAt } = useCommentsView(topLevelId)
|
||||
const { markCommentViewedAt } = useCommentsView(itemId)
|
||||
const [disableLiveComments] = useLiveCommentsToggle()
|
||||
|
||||
const [latest, setLatest] = useState(after)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const storedLatest = window.sessionStorage.getItem(latestKey)
|
||||
if (storedLatest && storedLatest > after) {
|
||||
setLatest(storedLatest)
|
||||
} else {
|
||||
setLatest(after)
|
||||
}
|
||||
|
||||
setLatest(readStoredLatest(latestKey, after))
|
||||
// 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
|
||||
setInitialized(true)
|
||||
}, [topLevelId, after])
|
||||
}, [itemId, after])
|
||||
|
||||
const { data } = useQuery(GET_NEW_COMMENTS, {
|
||||
pollInterval: POLL_INTERVAL,
|
||||
// only get comments newer than the passed latest timestamp
|
||||
variables: { topLevelId, after: latest },
|
||||
variables: { itemId, after: latest },
|
||||
nextFetchPolicy: 'cache-and-network',
|
||||
skip: SSR || !initialized || disableLiveComments
|
||||
})
|
||||
|
||||
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
|
||||
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
|
||||
let injectedLatest = latest
|
||||
preserveScroll(() => {
|
||||
injectedLatest = cacheNewComments(cache, injectedLatest, topLevelId, data.newComments.comments, sort)
|
||||
})
|
||||
const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt))
|
||||
|
||||
// sync view time if we successfully injected new comments
|
||||
if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) {
|
||||
markCommentViewedAt(injectedLatest)
|
||||
// if we didn't process any newer comments, bail
|
||||
if (new Date(injectedLatest).getTime() <= new Date(latest).getTime()) return
|
||||
|
||||
// update latest timestamp to the latest comment created at
|
||||
// save it to session storage, to persist between client-side navigations
|
||||
setLatest(injectedLatest)
|
||||
if (typeof window !== 'undefined') {
|
||||
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 () {
|
||||
const [disableLiveComments, setDisableLiveComments] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// preference: local storage
|
||||
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_KEY) === 'true')
|
||||
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true')
|
||||
read()
|
||||
|
||||
// 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
|
||||
const onToggle = () => read()
|
||||
|
||||
@ -163,12 +104,11 @@ export function useLiveCommentsToggle () {
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const current = window.localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, !current)
|
||||
const current = window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true'
|
||||
window.localStorage.setItem(STORAGE_DISABLE_KEY, !current)
|
||||
// trigger local event to update this tab
|
||||
window.dispatchEvent(new Event(TOGGLE_EVENT))
|
||||
}, [disableLiveComments])
|
||||
}, [])
|
||||
|
||||
return [disableLiveComments, toggle]
|
||||
}
|
||||
|
@ -207,7 +207,8 @@ export const paidActionCacheMods = {
|
||||
fields: {
|
||||
actionState: () => 'PAID',
|
||||
confirmedAt: () => new Date().toISOString(),
|
||||
satsReceived: () => invoice.satsRequested
|
||||
satsReceived: () => invoice.satsRequested,
|
||||
hmac: () => invoice.hmac
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export const COMMENT_FIELDS = gql`
|
||||
otsHash
|
||||
ncomments
|
||||
nDirectComments
|
||||
injected @client
|
||||
live @client
|
||||
imgproxyUrls
|
||||
rel
|
||||
apiKey
|
||||
@ -55,6 +55,7 @@ export const COMMENT_FIELDS = gql`
|
||||
id
|
||||
actionState
|
||||
confirmedAt
|
||||
hmac
|
||||
}
|
||||
cost
|
||||
}
|
||||
@ -94,6 +95,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
|
||||
commentCredits
|
||||
mine
|
||||
otsHash
|
||||
live @client
|
||||
imgproxyUrls
|
||||
rel
|
||||
apiKey
|
||||
@ -101,6 +103,7 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
|
||||
id
|
||||
actionState
|
||||
confirmedAt
|
||||
hmac
|
||||
}
|
||||
cost
|
||||
}
|
||||
@ -174,48 +177,19 @@ export const COMMENTS = gql`
|
||||
}
|
||||
}`
|
||||
|
||||
export const COMMENT_WITH_NEW_RECURSIVE = gql`
|
||||
${COMMENT_FIELDS}
|
||||
${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 HAS_COMMENTS = gql`
|
||||
fragment HasComments on Item {
|
||||
comments
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_NEW_COMMENTS = gql`
|
||||
${COMMENTS}
|
||||
${COMMENT_FIELDS_NO_CHILD_COMMENTS}
|
||||
|
||||
query GetNewComments($topLevelId: ID, $after: Date) {
|
||||
newComments(topLevelId: $topLevelId, after: $after) {
|
||||
query GetNewComments($itemId: ID, $after: Date) {
|
||||
newComments(itemId: $itemId, after: $after) {
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
...CommentFieldsNoChildComments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -324,9 +324,9 @@ function getClient (uri) {
|
||||
}
|
||||
}
|
||||
},
|
||||
injected: {
|
||||
read (injected) {
|
||||
return injected || false
|
||||
live: {
|
||||
read (live) {
|
||||
return live || false
|
||||
}
|
||||
},
|
||||
meAnonSats: {
|
||||
|
161
lib/comments.js
161
lib/comments.js
@ -1,98 +1,87 @@
|
||||
import { COMMENT_WITH_NEW_RECURSIVE, COMMENT_WITH_NEW_LIMITED, COMMENT_WITH_NEW_MINIMAL } from '../fragments/comments'
|
||||
import { ITEM_FULL } from '../fragments/items'
|
||||
import { COMMENTS, HAS_COMMENTS } from '../fragments/comments'
|
||||
|
||||
// updates the ncomments field of all ancestors of an item/comment in the cache
|
||||
export function updateAncestorsCommentCount (cache, ancestors, increment, parentId) {
|
||||
// update all ancestors
|
||||
// adds a comment to the cache, under its parent item
|
||||
function cacheComment (cache, newComment, { live = false }) {
|
||||
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 => {
|
||||
cache.modify({
|
||||
id: `Item:${id}`,
|
||||
fields: {
|
||||
ncomments (existingNComments = 0) {
|
||||
return existingNComments + increment
|
||||
},
|
||||
nDirectComments (existingNDirectComments = 0) {
|
||||
// only increment nDirectComments for the immediate parent
|
||||
if (parentId && Number(id) === Number(parentId)) {
|
||||
return existingNDirectComments + 1
|
||||
}
|
||||
return existingNDirectComments
|
||||
return existingNComments + ncomments
|
||||
}
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
|
@ -903,7 +903,7 @@ div[contenteditable]:focus,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -911,7 +911,7 @@ div[contenteditable]:focus,
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.outline-new-injected-comment.outline-new-comment-unset {
|
||||
.outline-new-live-comment.outline-new-comment-unset {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user