fix quote reply by removing imperative logic

This commit is contained in:
keyan 2023-11-20 21:37:57 -06:00
parent 2bcf3acda6
commit d211fe93ea
6 changed files with 65 additions and 77 deletions

View File

@ -21,6 +21,7 @@ import ItemInfo from './item-info'
import Badge from 'react-bootstrap/Badge' import Badge from 'react-bootstrap/Badge'
import { RootProvider, useRoot } from './root' import { RootProvider, useRoot } from './root'
import { useMe } from './me' import { useMe } from './me'
import { useQuoteReply } from './use-quote-reply'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -108,6 +109,7 @@ export default function Comment ({
const router = useRouter() const router = useRouter()
const root = useRoot() const root = useRoot()
const [pendingSats, setPendingSats] = useState(0) const [pendingSats, setPendingSats] = useState(0)
const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text })
useEffect(() => { useEffect(() => {
setCollapse(window.localStorage.getItem(`commentCollapse:${item.id}`) || collapse) setCollapse(window.localStorage.getItem(`commentCollapse:${item.id}`) || collapse)
@ -136,8 +138,6 @@ export default function Comment ({
? 'fwd' ? 'fwd'
: null : null
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
const replyRef = useRef()
const contentContainerRef = useRef()
return ( return (
<div <div
@ -167,7 +167,7 @@ export default function Comment ({
commentTextSingular='reply' commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`} className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>} embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={replyRef?.current?.quoteReply} onQuoteReply={quoteReply}
extraInfo={ extraInfo={
<> <>
{includeParent && <Parent item={item} rootText={rootText} />} {includeParent && <Parent item={item} rootText={rootText} />}
@ -210,7 +210,7 @@ export default function Comment ({
/> />
) )
: ( : (
<div className={styles.text} ref={contentContainerRef}> <div className={styles.text} ref={textRef}>
{item.searchText {item.searchText
? <SearchText text={item.searchText} /> ? <SearchText text={item.searchText} />
: ( : (
@ -227,7 +227,7 @@ export default function Comment ({
: ( : (
<div className={styles.children}> <div className={styles.children}>
{!noReply && {!noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} ref={replyRef} contentContainerRef={contentContainerRef}> <Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />} {root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>} </Reply>}
{children} {children}

View File

@ -388,7 +388,7 @@ function FormGroup ({ className, label, children }) {
} }
function InputInner ({ function InputInner ({
prepend, append, hint, showValid, onChange, onBlur, overrideValue, prepend, append, hint, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
...props ...props
}) { }) {
@ -441,6 +441,17 @@ function InputInner ({
} }
}, [overrideValue]) }, [overrideValue])
useEffect(() => {
if (appendValue) {
const updatedValue = meta.value ? `${meta.value}\n${appendValue}` : appendValue
helpers.setValue(updatedValue)
if (storageKey) {
window.localStorage.setItem(storageKey, updatedValue)
}
innerRef?.current?.focus()
}
}, [appendValue])
const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error
useEffect(debounce(() => { useEffect(debounce(() => {

View File

@ -13,7 +13,7 @@ import Button from 'react-bootstrap/Button'
import { TwitterTweetEmbed } from 'react-twitter-embed' import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube' import YouTube from 'react-youtube'
import useDarkMode from './dark-mode' import useDarkMode from './dark-mode'
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import Poll from './poll' import Poll from './poll'
import { commentsViewed } from '../lib/new-comments' import { commentsViewed } from '../lib/new-comments'
import Related from './related' import Related from './related'
@ -25,6 +25,7 @@ import Link from 'next/link'
import { RootProvider } from './root' import { RootProvider } from './root'
import { IMGPROXY_URL_REGEXP } from '../lib/url' import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { useQuoteReply } from './use-quote-reply'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const me = useMe() const me = useMe()
@ -122,14 +123,13 @@ function FwdUsers ({ forwards }) {
function TopLevelItem ({ item, noReply, ...props }) { function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.isJob ? ItemJob : Item const ItemComponent = item.isJob ? ItemJob : Item
const replyRef = useRef() const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text })
const contentContainerRef = useRef()
return ( return (
<ItemComponent <ItemComponent
item={item} item={item}
replyRef={replyRef}
full full
onQuoteReply={quoteReply}
right={ right={
!noReply && !noReply &&
<> <>
@ -140,7 +140,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
belowTitle={item.forwards && item.forwards.length > 0 && <FwdUsers forwards={item.forwards} />} belowTitle={item.forwards && item.forwards.length > 0 && <FwdUsers forwards={item.forwards} />}
{...props} {...props}
> >
<div className={styles.fullItemContainer} ref={contentContainerRef}> <div className={styles.fullItemContainer} ref={textRef}>
{item.text && <ItemText item={item} />} {item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />} {item.url && <ItemEmbed item={item} />}
{item.poll && <Poll item={item} />} {item.poll && <Poll item={item} />}
@ -160,7 +160,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
</div> </div>
{!noReply && {!noReply &&
<> <>
<Reply item={item} replyOpen placeholder={item.ncomments ? undefined : 'start the conversation ...'} ref={replyRef} contentContainerRef={contentContainerRef} /> <Reply item={item} replyOpen placeholder={item.ncomments ? undefined : 'start the conversation ...'} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote} />
{!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && <Related title={item.title} itemId={item.id} />} {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && <Related title={item.title} itemId={item.id} />}
{item.bounty > 0 && <PastBounties item={item} />} {item.bounty > 0 && <PastBounties item={item} />}
</>} </>}

View File

@ -24,7 +24,7 @@ export function SearchTitle ({ title }) {
}) })
} }
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, replyRef }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
const [pendingSats, setPendingSats] = useState(0) const [pendingSats, setPendingSats] = useState(0)
@ -94,7 +94,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
</div> </div>
<ItemInfo <ItemInfo
full={full} item={item} pendingSats={pendingSats} full={full} item={item} pendingSats={pendingSats}
onQuoteReply={replyRef?.current?.quoteReply} onQuoteReply={onQuoteReply}
nofollow={nofollow} nofollow={nofollow}
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />

View File

@ -3,12 +3,11 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments' import { COMMENTS } from '../fragments/comments'
import { useMe } from './me' import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useImperativeHandle } from 'react' import { forwardRef, useCallback, useEffect, useState, useRef } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button' import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments' import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate' import { commentSchema } from '../lib/validate'
import { quote } from '../lib/md'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useToast } from './toast' import { useToast } from './toast'
import { toastDeleteScheduled } from '../lib/form' import { toastDeleteScheduled } from '../lib/form'
@ -30,68 +29,18 @@ export function ReplyOnAnotherPage ({ item }) {
) )
} }
export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, contentContainerRef }, ref) { export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, onQuoteReply, onCancelQuote, quote }, ref) {
const [reply, setReply] = useState(replyOpen) const [reply, setReply] = useState(replyOpen || quote)
const me = useMe() const me = useMe()
const parentId = item.id const parentId = item.id
const replyInput = useRef(null) const replyInput = useRef(null)
const formInnerRef = useRef()
const toaster = useToast() const toaster = useToast()
// Start block to handle iOS Safari's weird selection clearing behavior
const savedRange = useRef()
const savedRangeNode = useRef()
const onTouchEnd = useCallback(() => {
const selection = document.getSelection()
if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).length === 0) {
return
}
const range = selection.getRangeAt(0)
savedRangeNode.current = range.commonAncestorContainer
savedRange.current = range.cloneContents()
}, [])
useEffect(() => { useEffect(() => {
document.addEventListener('touchend', onTouchEnd) if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
return () => document.removeEventListener('touchend', onTouchEnd) setReply(true)
}, [])
// End block to handle iOS Safari's weird selection clearing behavior
useImperativeHandle(ref, () => ({
quoteReply: ({ selectionOnly }) => {
if (!reply) {
setReply(true)
}
const selection = window.getSelection()
let selectedText = selection.isCollapsed ? undefined : selection.toString()
let isSelectedTextInTarget = contentContainerRef?.current?.contains(selection.anchorNode)
// Start block to handle iOS Safari's weird selection clearing behavior
if (!selectedText && savedRange.current && savedRangeNode.current) {
selectedText = savedRange.current.textContent
isSelectedTextInTarget = contentContainerRef?.current?.contains(savedRangeNode.current)
}
// End block to handle iOS Safari's weird selection clearing behavior
if ((selection.isCollapsed || !isSelectedTextInTarget || !selectedText) && selectionOnly) return
const textToQuote = isSelectedTextInTarget ? selectedText : item.text
let updatedValue
if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) {
updatedValue = quote(textToQuote)
} else if (formInnerRef.current?.values?.text) {
// append quote reply text if the input already has content
updatedValue = `${replyInput.current.value}\n${quote(textToQuote)}`
}
if (updatedValue) {
replyInput.current.value = updatedValue
formInnerRef.current.setValues({ text: updatedValue })
window.localStorage.setItem(`reply-${parentId}-text`, updatedValue)
}
} }
}), [reply, item]) }, [replyOpen, quote, parentId])
useEffect(() => {
setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text'))
}, [])
const [upsertComment] = useMutation( const [upsertComment] = useMutation(
gql` gql`
@ -160,12 +109,17 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
: ( : (
<div className={styles.replyButtons}> <div className={styles.replyButtons}>
<div <div
className='pe-3' onPointerDown={e => { className='pe-3'
if (!reply) { onPointerDown={e => {
if (reply) {
window.localStorage.removeItem('reply-' + parentId + '-' + 'text')
setReply(false)
onCancelQuote?.()
} else {
e.preventDefault() e.preventDefault()
ref?.current?.quoteReply({ selectionOnly: true }) onQuoteReply({ selectionOnly: true })
setReply(true)
} }
setReply(!reply)
}} }}
> >
{reply ? 'cancel' : 'reply'} {reply ? 'cancel' : 'reply'}
@ -187,15 +141,14 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
invoiceable invoiceable
onSubmit={onSubmit} onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`} storageKeyPrefix={`reply-${parentId}`}
innerRef={formInnerRef}
> >
<MarkdownInput <MarkdownInput
name='text' name='text'
minRows={6} minRows={6}
autoFocus={!replyOpen} autoFocus={!replyOpen}
required required
appendValue={quote}
placeholder={placeholder} placeholder={placeholder}
innerRef={replyInput}
/> />
<ItemButtonBar createText='reply' hasCancel={false} /> <ItemButtonBar createText='reply' hasCancel={false} />
</Form> </Form>

View File

@ -0,0 +1,24 @@
import { useCallback, useRef, useState } from 'react'
import { quote as quoteMd } from '../lib/md'
export function useQuoteReply ({ text }) {
const ref = useRef(null)
const [quote, setQuote] = useState(null)
const quoteReply = useCallback(({ selectionOnly }) => {
const selection = window.getSelection()
const selectedText = selection.isCollapsed ? undefined : selection.toString()
const isSelectedTextInTarget = ref?.current?.contains(selection.anchorNode)
if ((selection.isCollapsed || !isSelectedTextInTarget || !selectedText) && selectionOnly) return
const textToQuote = isSelectedTextInTarget ? selectedText : text
setQuote(quoteMd(textToQuote))
}, [ref?.current, text])
const cancelQuote = useCallback(() => {
setQuote(null)
}, [])
return { ref, quote, quoteReply, cancelQuote }
}