From 3d9aa726d61b8fe3cdca7510a71222494767ba0b Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Tue, 26 Sep 2023 21:41:51 -0400 Subject: [PATCH 1/7] Quote reply support on text-based posts and comments --- components/comment.js | 6 ++- components/form.js | 3 +- components/item-full.js | 6 ++- components/item-info.js | 6 ++- components/item.js | 5 ++- components/reply.js | 84 ++++++++++++++++++++++++++--------------- lib/md.js | 5 +++ 7 files changed, 78 insertions(+), 37 deletions(-) diff --git a/components/comment.js b/components/comment.js index d92eab6c2..24d0ed78c 100644 --- a/components/comment.js +++ b/components/comment.js @@ -136,6 +136,7 @@ export default function Comment ({ ? 'fwd' : null const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) + const replyRef = useRef() return (
{op}} + onQuoteReply={() => { + replyRef.current?.quoteReply?.() + }} extraInfo={ <> {includeParent && } @@ -221,7 +225,7 @@ export default function Comment ({ : (
{!noReply && - + {root.bounty && !bountyPaid && } } {children} diff --git a/components/form.js b/components/form.js index c94bbf0c9..27278baac 100644 --- a/components/form.js +++ b/components/form.js @@ -483,7 +483,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, const StorageKeyPrefixContext = createContext() 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 initialErrorToasted = useRef(false) @@ -538,6 +538,7 @@ export function Form ({ toaster.danger(err.message || err.toString?.()) } }} + innerRef={innerRef} > diff --git a/components/item-full.js b/components/item-full.js index d93493a39..29902ebe0 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -12,7 +12,7 @@ import Button from 'react-bootstrap/Button' import { TwitterTweetEmbed } from 'react-twitter-embed' import YouTube from 'react-youtube' import useDarkMode from './dark-mode' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import Poll from './poll' import { commentsViewed } from '../lib/new-comments' import Related from './related' @@ -121,10 +121,12 @@ function FwdUsers ({ forwards }) { function TopLevelItem ({ item, noReply, ...props }) { const ItemComponent = item.isJob ? ItemJob : Item + const replyRef = useRef() return ( {!noReply && <> - + {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && } {item.bounty > 0 && } } diff --git a/components/item-info.js b/components/item-info.js index e37191b48..c75576c30 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -2,6 +2,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import Badge from 'react-bootstrap/Badge' +import Dropdown from 'react-bootstrap/Dropdown' import Countdown from './countdown' import { abbrNum, numWithUnits } from '../lib/format' import { newComments, commentsViewedAt } from '../lib/new-comments' @@ -20,7 +21,8 @@ import MuteDropdownItem from './mute' export default function ItemInfo ({ 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 me = useMe() @@ -146,6 +148,8 @@ export default function ItemInfo ({
} + {(item.parentId || item.text) && onQuoteReply && + quote reply} {extraInfo}
diff --git a/components/item.js b/components/item.js index 02fd0c8ab..03aa7ca64 100644 --- a/components/item.js +++ b/components/item.js @@ -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 router = useRouter() const [pendingSats, setPendingSats] = useState(0) @@ -85,6 +85,9 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{ + replyRef?.current?.quoteReply?.() + }} embellishUser={Number(item?.user?.id) === AD_USER_ID && AD} /> {belowTitle} diff --git a/components/reply.js b/components/reply.js index 859a695ee..91c1cead2 100644 --- a/components/reply.js +++ b/components/reply.js @@ -9,6 +9,7 @@ import FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' +import { quote } from '../lib/md' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -33,10 +34,32 @@ function FreebieDialog () { ) } -export default function Reply ({ item, onSuccess, replyOpen, children, placeholder }) { +export default function Reply ({ item, onSuccess, replyOpen, children, placeholder, innerRef }) { const [reply, setReply] = useState(replyOpen) const me = useMe() const parentId = item.id + const replyInput = useRef(null) + const formInnerRef = useRef() + const quoteReply = useCallback(() => { + if (!reply) { + setReply(true) + } + let updatedValue + if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { + updatedValue = quote(item.text) + } else if (formInnerRef.current?.values?.text) { + // append quote reply text if the input already has content + updatedValue = `${replyInput.current.value}\n${quote(item.text)}` + } + if (updatedValue) { + replyInput.current.value = updatedValue + formInnerRef.current.setValues({ text: updatedValue }) + window.localStorage.setItem(`reply-${parentId}-text`, updatedValue) + } + }, [reply, item]) + if (innerRef) { + innerRef.current = { quoteReply } + } useEffect(() => { setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -96,7 +119,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold setReply(replyOpen || false) }, [upsertComment, setReply, parentId]) - const replyInput = useRef(null) useEffect(() => { if (replyInput.current && reply && !replyOpen) replyInput.current.focus() }, [reply]) @@ -115,35 +137,35 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold {/* HACK if we need more items, we should probably do a comment toolbar */} {children} )} - {reply && -
-
- } - innerRef={replyInput} - /> - {reply && -
- -
} - -
} +
+
+ } + innerRef={replyInput} + /> + {reply && +
+ +
} + +
) } diff --git a/lib/md.js b/lib/md.js index 1084e274e..f7abc339b 100644 --- a/lib/md.js +++ b/lib/md.js @@ -35,3 +35,8 @@ export function extractUrls (md) { return Array.from(urls) } + +export const quote = (orig) => + orig.split('\n') + .map(line => `> ${line}`) + .join('\n') From b6845c543fea6681f93e46b81536cf3fa227ba19 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Wed, 27 Sep 2023 09:25:27 -0400 Subject: [PATCH 2/7] Clean up the `onQuoteReply` prop usage --- components/comment.js | 4 +--- components/item.js | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/components/comment.js b/components/comment.js index 24d0ed78c..43dcde7cf 100644 --- a/components/comment.js +++ b/components/comment.js @@ -166,9 +166,7 @@ export default function Comment ({ commentTextSingular='reply' className={`${itemStyles.other} ${styles.other}`} embellishUser={op && <> {op}} - onQuoteReply={() => { - replyRef.current?.quoteReply?.() - }} + onQuoteReply={replyRef?.current?.quoteReply} extraInfo={ <> {includeParent && } diff --git a/components/item.js b/components/item.js index 03aa7ca64..927f28d21 100644 --- a/components/item.js +++ b/components/item.js @@ -85,9 +85,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s { - replyRef?.current?.quoteReply?.() - }} + onQuoteReply={replyRef?.current?.quoteReply} embellishUser={Number(item?.user?.id) === AD_USER_ID && AD} /> {belowTitle} From 4b17c70b619b8cb3d710b648150971d475022ade Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Wed, 27 Sep 2023 11:17:19 -0400 Subject: [PATCH 3/7] Refactor to use `useImperativeHandle` for Reply --- components/comment.js | 2 +- components/item-full.js | 2 +- components/reply.js | 43 ++++++++++++++++++++--------------------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/components/comment.js b/components/comment.js index 43dcde7cf..d34118a76 100644 --- a/components/comment.js +++ b/components/comment.js @@ -223,7 +223,7 @@ export default function Comment ({ : (
{!noReply && - + {root.bounty && !bountyPaid && } } {children} diff --git a/components/item-full.js b/components/item-full.js index 29902ebe0..8129f7b0e 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -158,7 +158,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
{!noReply && <> - + {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && } {item.bounty > 0 && } } diff --git a/components/reply.js b/components/reply.js index 91c1cead2..89a862b63 100644 --- a/components/reply.js +++ b/components/reply.js @@ -3,7 +3,7 @@ import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' import { COMMENTS } from '../fragments/comments' 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 FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' @@ -34,32 +34,31 @@ function FreebieDialog () { ) } -export default function Reply ({ item, onSuccess, replyOpen, children, placeholder, innerRef }) { +export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder }, ref) { const [reply, setReply] = useState(replyOpen) const me = useMe() const parentId = item.id const replyInput = useRef(null) const formInnerRef = useRef() - const quoteReply = useCallback(() => { - if (!reply) { - setReply(true) - } - let updatedValue - if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { - updatedValue = quote(item.text) - } else if (formInnerRef.current?.values?.text) { - // append quote reply text if the input already has content - updatedValue = `${replyInput.current.value}\n${quote(item.text)}` - } - if (updatedValue) { - replyInput.current.value = updatedValue - formInnerRef.current.setValues({ text: updatedValue }) - window.localStorage.setItem(`reply-${parentId}-text`, updatedValue) + useImperativeHandle(ref, () => ({ + quoteReply: () => { + if (!reply) { + setReply(true) + } + let updatedValue + if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { + updatedValue = quote(item.text) + } else if (formInnerRef.current?.values?.text) { + // append quote reply text if the input already has content + updatedValue = `${replyInput.current.value}\n${quote(item.text)}` + } + if (updatedValue) { + replyInput.current.value = updatedValue + formInnerRef.current.setValues({ text: updatedValue }) + window.localStorage.setItem(`reply-${parentId}-text`, updatedValue) + } } - }, [reply, item]) - if (innerRef) { - innerRef.current = { quoteReply } - } + }), [reply, item]) useEffect(() => { setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -168,7 +167,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold ) -} +}) export function ReplySkeleton () { return ( From 0eeb5aadc224d35563ae4bfac647ed93b6b25683 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Wed, 27 Sep 2023 15:48:36 -0400 Subject: [PATCH 4/7] quote selected text if any, otherwise quote whole item --- components/action-dropdown.js | 2 +- components/reply.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/action-dropdown.js b/components/action-dropdown.js index b922967d1..497f150d0 100644 --- a/components/action-dropdown.js +++ b/components/action-dropdown.js @@ -8,7 +8,7 @@ export default function ActionDropdown ({ children }) { } return ( - + e.preventDefault()}> diff --git a/components/reply.js b/components/reply.js index 89a862b63..54db9b9d3 100644 --- a/components/reply.js +++ b/components/reply.js @@ -45,12 +45,14 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children if (!reply) { setReply(true) } + const selection = window.getSelection() + const textToQuote = selection.isCollapsed ? item.text : selection.toString() let updatedValue if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { - updatedValue = quote(item.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(item.text)}` + updatedValue = `${replyInput.current.value}\n${quote(textToQuote)}` } if (updatedValue) { replyInput.current.value = updatedValue From f75ec5c1e96d845dabd3a652e7f9942633e52716 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Wed, 27 Sep 2023 16:25:23 -0400 Subject: [PATCH 5/7] Only quote selected text if it's from the item we're replying to, not just any selected text --- components/comment.js | 5 +++-- components/item-full.js | 5 +++-- components/reply.js | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/components/comment.js b/components/comment.js index d34118a76..c7775f0eb 100644 --- a/components/comment.js +++ b/components/comment.js @@ -137,6 +137,7 @@ export default function Comment ({ : null const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) const replyRef = useRef() + const contentContainerRef = useRef() return (
) : ( -
+
{truncate ? truncateString(item.text) : item.searchText || item.text} @@ -223,7 +224,7 @@ export default function Comment ({ : (
{!noReply && - + {root.bounty && !bountyPaid && } } {children} diff --git a/components/item-full.js b/components/item-full.js index 8129f7b0e..e94712c09 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -122,6 +122,7 @@ function FwdUsers ({ forwards }) { function TopLevelItem ({ item, noReply, ...props }) { const ItemComponent = item.isJob ? ItemJob : Item const replyRef = useRef() + const contentContainerRef = useRef() return ( 0 && } {...props} > -
+
{item.text && } {item.url && } {item.poll && } @@ -158,7 +159,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
{!noReply && <> - + {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && } {item.bounty > 0 && } } diff --git a/components/reply.js b/components/reply.js index 54db9b9d3..66c02cd36 100644 --- a/components/reply.js +++ b/components/reply.js @@ -34,7 +34,7 @@ function FreebieDialog () { ) } -export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder }, ref) { +export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, contentContainerRef }, ref) { const [reply, setReply] = useState(replyOpen) const me = useMe() const parentId = item.id @@ -46,7 +46,9 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children setReply(true) } const selection = window.getSelection() - const textToQuote = selection.isCollapsed ? item.text : selection.toString() + const selectedText = selection.isCollapsed ? undefined : selection.toString() + const isSelectedTextInTarget = contentContainerRef?.current?.contains(selection.anchorNode) + const textToQuote = isSelectedTextInTarget ? selectedText : item.text let updatedValue if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { updatedValue = quote(textToQuote) From d95e872f8a02440f411aa8148039c014224db788 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Thu, 28 Sep 2023 21:01:06 -0400 Subject: [PATCH 6/7] add trailing newline to copied text --- lib/md.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/md.js b/lib/md.js index f7abc339b..35dc48892 100644 --- a/lib/md.js +++ b/lib/md.js @@ -39,4 +39,4 @@ export function extractUrls (md) { export const quote = (orig) => orig.split('\n') .map(line => `> ${line}`) - .join('\n') + .join('\n') + '\n' From a6c8587e70cede0a2930286c4548623d54c12be2 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 3 Oct 2023 20:06:48 -0500 Subject: [PATCH 7/7] onPointerDown for mobile, quote+reply quotes text --- components/action-dropdown.js | 2 +- components/form.js | 100 ++++++++++++++++++---------------- components/item-info.js | 4 +- components/reply.js | 11 +++- 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/components/action-dropdown.js b/components/action-dropdown.js index 497f150d0..ea19ebaca 100644 --- a/components/action-dropdown.js +++ b/components/action-dropdown.js @@ -8,7 +8,7 @@ export default function ActionDropdown ({ children }) { } return ( - e.preventDefault()}> + e.preventDefault()}> diff --git a/components/form.js b/components/form.js index 8b947eb84..d135256c5 100644 --- a/components/form.js +++ b/components/form.js @@ -92,6 +92,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH const [, meta, helpers] = useField(props) const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 }) innerRef = innerRef || useRef(null) + const previousTab = useRef(tab) props.as ||= TextareaAutosize props.rows ||= props.minRows || 6 @@ -100,6 +101,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH !meta.value && setTab('write') }, [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(() => { if (selectionRange.start <= selectionRange.end && innerRef?.current) { const { start, end } = selectionRange @@ -125,53 +134,49 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH - {tab === 'write' - ? ( -
- { - if (onChange) onChange(formik, e) - if (setHasImgLink) { - setHasImgLink(mdHas(e.target.value, ['link', 'image'])) - } - }} - innerRef={innerRef} - onKeyDown={(e) => { - const metaOrCtrl = e.metaKey || e.ctrlKey - if (metaOrCtrl) { - if (e.key === 'k') { - // some browsers use CTRL+K to focus search bar so we have to prevent that behavior - 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 - 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 - e.preventDefault() - insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - if (e.key === 'Tab' && e.altKey) { - e.preventDefault() - insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - } - - if (onKeyDown) onKeyDown(e) - }} - /> -
) - : ( -
-
- {meta.value} -
-
- )} +
+ { + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) + } + }} + innerRef={innerRef} + onKeyDown={(e) => { + const metaOrCtrl = e.metaKey || e.ctrlKey + if (metaOrCtrl) { + if (e.key === 'k') { + // some browsers use CTRL+K to focus search bar so we have to prevent that behavior + 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 + 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 + e.preventDefault() + insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'Tab' && e.altKey) { + e.preventDefault() + insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + } + if (onKeyDown) onKeyDown(e) + }} + /> +
+ {tab !== 'write' && +
+
+ {meta.value} +
+
}
) @@ -225,7 +230,8 @@ function FormGroup ({ className, label, children }) { function InputInner ({ 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 formik = noForm ? null : useFormikContext() diff --git a/components/item-info.js b/components/item-info.js index c75576c30..783d70536 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -133,6 +133,8 @@ export default function ItemInfo ({ } + {(item.parentId || item.text) && onQuoteReply && + quote reply} {me && } {me && !item.mine && } {item.otsHash && @@ -148,8 +150,6 @@ export default function ItemInfo ({
} - {(item.parentId || item.text) && onQuoteReply && - quote reply}
{extraInfo}
diff --git a/components/reply.js b/components/reply.js index 66c02cd36..9bdd16bf2 100644 --- a/components/reply.js +++ b/components/reply.js @@ -41,13 +41,14 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children const replyInput = useRef(null) const formInnerRef = useRef() useImperativeHandle(ref, () => ({ - quoteReply: () => { + 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) { @@ -133,7 +134,13 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children : (
setReply(!reply)} + onPointerDown={e => { + if (!reply) { + e.preventDefault() + ref?.current?.quoteReply({ selectionOnly: true }) + } + setReply(!reply) + }} > {reply ? 'cancel' : 'reply'}