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
}
},
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 }
}

View File

@ -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

View File

@ -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')
}

View File

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

View File

@ -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()

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 (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()
}

View File

@ -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 }) => {

View File

@ -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]
}

View File

@ -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')
}

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 { 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]
}

View File

@ -207,7 +207,8 @@ export const paidActionCacheMods = {
fields: {
actionState: () => 'PAID',
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
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
}
}
}

View File

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

View File

@ -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
)
}

View File

@ -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;
}