Quote reply support on text-based posts and comments (#526)

* Quote reply support on text-based posts and comments

* Clean up the `onQuoteReply` prop usage

* Refactor to use `useImperativeHandle` for Reply

* quote selected text if any, otherwise quote whole item

* Only quote selected text if it's from the item we're replying to, not just any selected text

* add trailing newline to copied text

* onPointerDown for mobile, quote+reply quotes text

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-10-03 21:12:12 -04:00 committed by GitHub
parent 362f95add9
commit f6141a6965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 144 additions and 89 deletions

View File

@ -8,7 +8,7 @@ export default function ActionDropdown ({ children }) {
} }
return ( return (
<Dropdown className={`pointer ${styles.dropdown}`} as='span'> <Dropdown className={`pointer ${styles.dropdown}`} as='span'>
<Dropdown.Toggle variant='success' as='a'> <Dropdown.Toggle variant='success' as='a' onPointerDown={e => e.preventDefault()}>
<MoreIcon className='fill-grey ms-1' height={16} width={16} /> <MoreIcon className='fill-grey ms-1' height={16} width={16} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>

View File

@ -136,6 +136,8 @@ 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
@ -165,6 +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}
extraInfo={ extraInfo={
<> <>
{includeParent && <Parent item={item} rootText={rootText} />} {includeParent && <Parent item={item} rootText={rootText} />}
@ -207,7 +210,7 @@ export default function Comment ({
/> />
) )
: ( : (
<div className={styles.text}> <div className={styles.text} ref={contentContainerRef}>
{item.searchText {item.searchText
? <SearchText text={item.searchText} /> ? <SearchText text={item.searchText} />
: ( : (
@ -224,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}> <Reply depth={depth + 1} item={item} replyOpen={replyOpen} ref={replyRef} contentContainerRef={contentContainerRef}>
{root.bounty && !bountyPaid && <PayBounty item={item} />} {root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>} </Reply>}
{children} {children}

View File

@ -92,6 +92,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
const [, meta, helpers] = useField(props) const [, meta, helpers] = useField(props)
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 }) const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
innerRef = innerRef || useRef(null) innerRef = innerRef || useRef(null)
const previousTab = useRef(tab)
props.as ||= TextareaAutosize props.as ||= TextareaAutosize
props.rows ||= props.minRows || 6 props.rows ||= props.minRows || 6
@ -100,6 +101,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
!meta.value && setTab('write') !meta.value && setTab('write')
}, [meta.value]) }, [meta.value])
useEffect(() => {
// focus on input when switching to write tab from preview tab
if (innerRef?.current && tab === 'write' && previousTab?.current !== 'write') {
innerRef.current.focus()
}
previousTab.current = tab
}, [tab])
useEffect(() => { useEffect(() => {
if (selectionRange.start <= selectionRange.end && innerRef?.current) { if (selectionRange.start <= selectionRange.end && innerRef?.current) {
const { start, end } = selectionRange const { start, end } = selectionRange
@ -125,53 +134,49 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<Markdown width={18} height={18} /> <Markdown width={18} height={18} />
</a> </a>
</Nav> </Nav>
{tab === 'write' <div className={tab === 'write' ? '' : 'd-none'}>
? ( <InputInner
<div> {...props} onChange={(formik, e) => {
<InputInner if (onChange) onChange(formik, e)
{...props} onChange={(formik, e) => { if (setHasImgLink) {
if (onChange) onChange(formik, e) setHasImgLink(mdHas(e.target.value, ['link', 'image']))
if (setHasImgLink) { }
setHasImgLink(mdHas(e.target.value, ['link', 'image'])) }}
} innerRef={innerRef}
}} onKeyDown={(e) => {
innerRef={innerRef} const metaOrCtrl = e.metaKey || e.ctrlKey
onKeyDown={(e) => { if (metaOrCtrl) {
const metaOrCtrl = e.metaKey || e.ctrlKey if (e.key === 'k') {
if (metaOrCtrl) { // some browsers use CTRL+K to focus search bar so we have to prevent that behavior
if (e.key === 'k') { e.preventDefault()
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
e.preventDefault() }
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange) if (e.key === 'b') {
} // some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
if (e.key === 'b') { e.preventDefault()
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
e.preventDefault() }
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange) if (e.key === 'i') {
} // some browsers might use CTRL+I to do something else so prevent that behavior too
if (e.key === 'i') { e.preventDefault()
// some browsers might use CTRL+I to do something else so prevent that behavior too insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
e.preventDefault() }
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) if (e.key === 'Tab' && e.altKey) {
} e.preventDefault()
if (e.key === 'Tab' && e.altKey) { insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
e.preventDefault() }
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) }
}
}
if (onKeyDown) onKeyDown(e) if (onKeyDown) onKeyDown(e)
}} }}
/> />
</div>) </div>
: ( {tab !== 'write' &&
<div className='form-group'> <div className='form-group'>
<div className={`${styles.text} form-control`}> <div className={`${styles.text} form-control`}>
<Text topLevel={topLevel} noFragments tab={tab}>{meta.value}</Text> <Text topLevel={topLevel} noFragments tab={tab}>{meta.value}</Text>
</div>
</div> </div>
)} </div>}
</div> </div>
</FormGroup> </FormGroup>
) )
@ -225,7 +230,8 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ function InputInner ({
prepend, append, hint, showValid, onChange, onBlur, overrideValue, prepend, append, hint, showValid, onChange, onBlur, overrideValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength, ...props innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength,
...props
}) { }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext() const formik = noForm ? null : useFormikContext()
@ -483,7 +489,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
const StorageKeyPrefixContext = createContext() const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
@ -538,6 +544,7 @@ export function Form ({
toaster.danger(err.message || err.toString?.()) toaster.danger(err.message || err.toString?.())
} }
}} }}
innerRef={innerRef}
> >
<FormikForm {...props} noValidate> <FormikForm {...props} noValidate>
<StorageKeyPrefixContext.Provider value={storageKeyPrefix}> <StorageKeyPrefixContext.Provider value={storageKeyPrefix}>

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, useState } from 'react' import { useEffect, useRef, 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'
@ -122,10 +122,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 contentContainerRef = useRef()
return ( return (
<ItemComponent <ItemComponent
item={item} item={item}
replyRef={replyRef}
full full
right={ right={
!noReply && !noReply &&
@ -137,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}> <div className={styles.fullItemContainer} ref={contentContainerRef}>
{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} />}
@ -157,7 +160,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
</div> </div>
{!noReply && {!noReply &&
<> <>
<Reply item={item} replyOpen placeholder={item.ncomments ? undefined : 'start the conversation ...'} /> <Reply item={item} replyOpen placeholder={item.ncomments ? undefined : 'start the conversation ...'} ref={replyRef} contentContainerRef={contentContainerRef} />
{!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

@ -2,6 +2,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Badge from 'react-bootstrap/Badge' import Badge from 'react-bootstrap/Badge'
import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown' import Countdown from './countdown'
import { abbrNum, numWithUnits } from '../lib/format' import { abbrNum, numWithUnits } from '../lib/format'
import { newComments, commentsViewedAt } from '../lib/new-comments' import { newComments, commentsViewedAt } from '../lib/new-comments'
@ -20,7 +21,8 @@ import MuteDropdownItem from './mute'
export default function ItemInfo ({ export default function ItemInfo ({
item, pendingSats, full, commentsText = 'comments', item, pendingSats, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
onQuoteReply
}) { }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const me = useMe() const me = useMe()
@ -131,6 +133,8 @@ export default function ItemInfo ({
</>} </>}
<ActionDropdown> <ActionDropdown>
<CopyLinkDropdownItem item={item} /> <CopyLinkDropdownItem item={item} />
{(item.parentId || item.text) && onQuoteReply &&
<Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>}
{me && <BookmarkDropdownItem item={item} />} {me && <BookmarkDropdownItem item={item} />}
{me && !item.mine && <SubscribeDropdownItem item={item} />} {me && !item.mine && <SubscribeDropdownItem item={item} />}
{item.otsHash && {item.otsHash &&

View File

@ -24,7 +24,7 @@ export function SearchTitle ({ title }) {
}) })
} }
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, replyRef }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
const [pendingSats, setPendingSats] = useState(0) const [pendingSats, setPendingSats] = useState(0)
@ -85,6 +85,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}
embellishUser={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} embellishUser={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
{belowTitle} {belowTitle}

View File

@ -3,12 +3,13 @@ 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 { useEffect, useState, useRef, useCallback } from 'react' import { forwardRef, useCallback, useEffect, useState, useRef, useImperativeHandle } from 'react'
import Link from 'next/link' import Link from 'next/link'
import FeeButton from './fee-button' import FeeButton 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 Info from './info' import Info from './info'
import { quote } from '../lib/md'
export function ReplyOnAnotherPage ({ parentId }) { export function ReplyOnAnotherPage ({ parentId }) {
return ( return (
@ -33,10 +34,36 @@ function FreebieDialog () {
) )
} }
export default function Reply ({ item, onSuccess, replyOpen, children, placeholder }) { export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, contentContainerRef }, ref) {
const [reply, setReply] = useState(replyOpen) const [reply, setReply] = useState(replyOpen)
const me = useMe() const me = useMe()
const parentId = item.id const parentId = item.id
const replyInput = useRef(null)
const formInnerRef = useRef()
useImperativeHandle(ref, () => ({
quoteReply: ({ selectionOnly }) => {
if (!reply) {
setReply(true)
}
const selection = window.getSelection()
const selectedText = selection.isCollapsed ? undefined : selection.toString()
const isSelectedTextInTarget = contentContainerRef?.current?.contains(selection.anchorNode)
if ((selection.isCollapsed || !isSelectedTextInTarget) && 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])
useEffect(() => { useEffect(() => {
setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text'))
@ -96,7 +123,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
setReply(replyOpen || false) setReply(replyOpen || false)
}, [upsertComment, setReply, parentId]) }, [upsertComment, setReply, parentId])
const replyInput = useRef(null)
useEffect(() => { useEffect(() => {
if (replyInput.current && reply && !replyOpen) replyInput.current.focus() if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
}, [reply]) }, [reply])
@ -108,45 +134,51 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
: ( : (
<div className={styles.replyButtons}> <div className={styles.replyButtons}>
<div <div
onClick={() => setReply(!reply)} onPointerDown={e => {
if (!reply) {
e.preventDefault()
ref?.current?.quoteReply({ selectionOnly: true })
}
setReply(!reply)
}}
> >
{reply ? 'cancel' : 'reply'} {reply ? 'cancel' : 'reply'}
</div> </div>
{/* HACK if we need more items, we should probably do a comment toolbar */} {/* HACK if we need more items, we should probably do a comment toolbar */}
{children} {children}
</div>)} </div>)}
{reply && <div className={styles.reply} style={{ display: reply ? 'block' : 'none' }}>
<div className={styles.reply}> <Form
<Form initial={{
initial={{ text: ''
text: '' }}
}} schema={commentSchema}
schema={commentSchema} 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
placeholder={placeholder} placeholder={placeholder}
hint={me?.sats < 1 && <FreebieDialog />} hint={me?.sats < 1 && <FreebieDialog />}
innerRef={replyInput} innerRef={replyInput}
/> />
{reply && {reply &&
<div className='mt-1'> <div className='mt-1'>
<FeeButton <FeeButton
baseFee={1} parentId={parentId} text='reply' baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow ChildButton={SubmitButton} variant='secondary' alwaysShow
/> />
</div>} </div>}
</Form> </Form>
</div>} </div>
</div> </div>
) )
} })
export function ReplySkeleton () { export function ReplySkeleton () {
return ( return (

View File

@ -35,3 +35,8 @@ export function extractUrls (md) {
return Array.from(urls) return Array.from(urls)
} }
export const quote = (orig) =>
orig.split('\n')
.map(line => `> ${line}`)
.join('\n') + '\n'