stacker.news/components/use-live-comments.js
soxa f0e3516cf0
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
2025-09-07 16:04:34 -05:00

115 lines
4.4 KiB
JavaScript

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 { injectComment } from '../lib/comments'
import useCommentsView from './use-comments-view'
// live comments polling interval
const POLL_INTERVAL = 1000 * 5
// live comments toggle keys
const STORAGE_DISABLE_KEY = 'disableLiveComments'
const TOGGLE_EVENT = 'liveComments:toggle'
const readStoredLatest = (key, latest) => {
const stored = window.sessionStorage.getItem(key)
return stored && stored > latest ? stored : latest
}
// 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
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 (injected > 0) {
markCommentViewedAt(injectedLatest, { ncomments: injected })
}
return injectedLatest
}
// 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(itemId)
const [disableLiveComments] = useLiveCommentsToggle()
const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
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)
}, [itemId, after])
const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { itemId, after: latest },
nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments
})
useEffect(() => {
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
const injectedLatest = preserveScroll(() => cacheNewComments(cache, latest, itemId, newComments, markCommentViewedAt))
// 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)
window.sessionStorage.setItem(latestKey, injectedLatest)
}, [data, cache, itemId, latest, markCommentViewedAt])
}
export function useLiveCommentsToggle () {
const [disableLiveComments, setDisableLiveComments] = useState(false)
useEffect(() => {
// preference: local storage
const read = () => setDisableLiveComments(window.localStorage.getItem(STORAGE_DISABLE_KEY) === 'true')
read()
// update across tabs
const onStorage = e => { if (e.key === STORAGE_DISABLE_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_DISABLE_KEY) === 'true'
window.localStorage.setItem(STORAGE_DISABLE_KEY, !current)
// trigger local event to update this tab
window.dispatchEvent(new Event(TOGGLE_EVENT))
}, [])
return [disableLiveComments, toggle]
}