stacker.news/components/use-live-comments.js
soxa b5af28c48b
Server-side tracking of comments view time (#2432)
* server-side comments view tracking, model structure, mutation

* full commentsViewedAt refactor, adjust comment creation and injection, adjust item navigation

* update server-side tracking only if there's a change, light cleanup

* coalesce meCommentsViewedAt to the item's createdAt, wip PoC comment outlining

* don't update cache on item visit, use useRoot hook for outlining

* add meCommentsViewedAt to root, better naming, light cleanup

* better timestamp logic and comparisons, add lastCommentAt to root item object, added TODOs

* fix: track commentsViewedAt only for root item, use topLevelId to fetch live comments only for the current item

* only track commentsViewedAt for root item, light cleanup

* light cleanup, correct live comments timestamp deps

* worker: on midnight, untrack items that were never viewed and had no comments in the last 21 days
2025-09-02 13:13:44 -05:00

193 lines
7.0 KiB
JavaScript

import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments'
import { UPDATE_ITEM_USER_VIEW } from '../fragments/items'
import { useEffect, useState, useCallback } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient, useMutation } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
import { useMe } from './me'
import { useRoot } from './root'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
// 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
}
return payload
}
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
// 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
}
}
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}`
const { cache } = useApolloClient()
const { me } = useMe()
const root = useRoot()
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
update (cache, { data: { updateCommentsViewAt } }) {
cache.modify({
id: `Item:${root.id}`,
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
})
}
})
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)
}
// 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])
const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { topLevelId, after: latest },
nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments
})
useEffect(() => {
if (!data?.newComments?.comments?.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)
})
// sync view time if we successfully injected new comments
if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) {
if (me?.id) {
// server-tracked view
updateCommentsViewAt({ variables: { id: root.id, meCommentsViewedAt: injectedLatest } })
} else {
// anon fallback
commentsViewedAfterComment(root.id, injectedLatest)
}
// 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, topLevelId, root.id, sort, latest, me?.id])
}
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')
read()
// update across tabs
const onStorage = e => { if (e.key === STORAGE_KEY) read() }
// update this tab
const onToggle = () => read()
window.addEventListener('storage', onStorage)
window.addEventListener(TOGGLE_EVENT, onToggle)
return () => {
window.removeEventListener('storage', onStorage)
window.removeEventListener(TOGGLE_EVENT, onToggle)
}
}, [])
const toggle = useCallback(() => {
const current = window.localStorage.getItem(STORAGE_KEY) === 'true'
window.localStorage.setItem(STORAGE_KEY, !current)
// trigger local event to update this tab
window.dispatchEvent(new Event(TOGGLE_EVENT))
}, [disableLiveComments])
return [disableLiveComments, toggle]
}