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

169 lines
5.3 KiB
JavaScript

import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentSchema } from '@/lib/validate'
import { ItemButtonBar } from './post'
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 useCommentsView from './use-comments-view'
export default forwardRef(function Reply ({
item,
replyOpen,
children,
onQuoteReply,
onCancelQuote,
quote
}, ref) {
const [reply, setReply] = useState(replyOpen || quote)
const { me } = useMe()
const parentId = item.id
const replyInput = useRef(null)
const showModal = useShowModal()
const root = useRoot()
const sub = item?.sub || root?.sub
const { markCommentViewedAt } = useCommentsView(root.id)
useEffect(() => {
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
setReply(true)
}
}, [replyOpen, quote, parentId])
const placeholder = useMemo(() => {
return [
'comment for currency',
'fractions of a penny for your thoughts?',
'put your money where your mouth is'
][parentId % 3]
}, [parentId])
const onSubmit = useItemSubmit(CREATE_COMMENT, {
extraValues: { parentId },
paidMutationOptions: {
update (cache, { data: { upsertComment: { result, invoice } } }) {
if (!result) return
// inject the new comment into the cache
const injected = injectComment(cache, result)
if (injected) {
markCommentViewedAt(result.createdAt, { ncomments: 1 })
}
// no lag for itemRepetition
if (!item.mine && me) {
cache.updateQuery({
query: gql`{ itemRepetition(parentId: "${parentId}") }`
}, data => {
return {
itemRepetition: (data?.itemRepetition || 0) + 1
}
})
}
}
},
onSuccessfulSubmit: (data, { resetForm }) => {
resetForm({ values: { text: '' } })
setReply(replyOpen || false)
},
navigateOnSubmit: false
})
useEffect(() => {
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
}, [reply])
const onCancel = useCallback(() => {
window.localStorage.removeItem('reply-' + parentId + '-' + 'text')
setReply(false)
onCancelQuote?.()
}, [setReply, parentId, onCancelQuote])
return (
<div>
{replyOpen
? <div className='p-3' />
: (
<div className={styles.replyButtons}>
<div
className='pe-3'
onClick={e => {
if (reply) {
const text = window.localStorage.getItem('reply-' + parentId + '-' + 'text')
if (text?.trim()) {
showModal(onClose => (
<>
<p className='fw-bolder'>Are you sure? You will lose your work</p>
<div className='d-flex justify-content-end'>
<Button
variant='info' onClick={() => {
onCancel()
onClose()
}}
>yep
</Button>
</div>
</>
))
} else {
onCancel()
}
} else {
e.preventDefault()
onQuoteReply?.({ selectionOnly: true })
setReply(true)
}
}}
>
{reply ? 'cancel' : 'reply'}
</div>
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)}
{reply &&
<div className={styles.reply}>
<FeeButtonProvider
baseLineItems={postCommentBaseLineItems({ baseCost: sub?.replyCost ?? 1, comment: true, me: !!me })}
useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
>
<Form
initial={{
text: ''
}}
schema={commentSchema}
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus={!replyOpen}
required
appendValue={quote}
placeholder={placeholder}
hint={sub?.moderated && 'this territory is moderated'}
/>
<ItemButtonBar createText='reply' hasCancel={false} />
</Form>
</FeeButtonProvider>
</div>}
</div>
)
})
export function ReplySkeleton () {
return (
<div className={`${styles.reply} ${styles.skeleton}`}>
<div className={`${styles.input} clouds`} />
<div className={`${styles.button} clouds`} />
</div>
)
}