Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 8 commits into from
Oct 4, 2023

Conversation

SatsAllDay
Copy link
Contributor

@SatsAllDay SatsAllDay commented Sep 27, 2023

Closes #516

Add support for a "Quote Reply" action on text-based posts and comments, available in the item info overflow menu.

Demo in 2 parts:
https://github.com/stackernews/stacker.news/assets/128755788/fd7ac334-492a-45b5-b7b1-e7a1545f4e28
https://github.com/stackernews/stacker.news/assets/128755788/8e226e09-b44f-4106-bb2e-1c27dd67a1ab

Comment on lines 43 to 61
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 }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently I just re-invented https://react.dev/reference/react/useImperativeHandle lol, I'll refactor to use that

@SatsAllDay SatsAllDay mentioned this pull request Sep 27, 2023
@SatsAllDay
Copy link
Contributor Author

...and here's quote reply selection. Note it's not going to give you full markdown quoted since it's based on the rendered text, not the raw markdown. I think that should suffice. You get full markdown quoted when you quote the entire item instead of a selection

cc @ekzyis

stacker.news.quote.reply.selection.mov

@@ -8,7 +8,7 @@ export default function ActionDropdown ({ children }) {
}
return (
<Dropdown className={`pointer ${styles.dropdown}`} as='span'>
<Dropdown.Toggle variant='success' as='a'>
<Dropdown.Toggle variant='success' as='a' onMouseDown={e => e.preventDefault()}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to prevent clearing a text selection when opening the action dropdown menu, thus enabling quoting selections.

@@ -197,7 +200,7 @@ export default function Comment ({
/>
)
: (
<div className={styles.text}>
<div className={styles.text} ref={contentContainerRef}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mark the DOM tree root where the item's text can be found, so we can check to see if a selection is made in the item when doing a quote reply, not just any selection in the entire page.

@@ -140,6 +142,8 @@ export default function ItemInfo ({
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
{item.mine && !item.position && !item.deletedAt &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
{(item.parentId || item.text) && onQuoteReply &&
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it has a parent, or if it has text, and if we have a handler for the quote reply action, render the menu item.

const replyInput = useRef(null)
const formInnerRef = useRef()
useImperativeHandle(ref, () => ({
quoteReply: () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An imperative quoteReply method to be triggered from above in the react tree.

Comment on lines 48 to 51
const selection = window.getSelection()
const selectedText = selection.isCollapsed ? undefined : selection.toString()
const isSelectedTextInTarget = contentContainerRef?.current?.contains(selection.anchorNode)
const textToQuote = isSelectedTextInTarget ? selectedText : item.text
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check to see what we should quote, the entire item, or a selection of text.

@@ -96,7 +122,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
setReply(replyOpen || false)
}, [upsertComment, setReply, parentId])

const replyInput = useRef(null)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just moved up above for earlier use.

</div>}
</Form>
</div>}
<div className={styles.reply} style={{ display: reply ? 'block' : 'none' }}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of conditionally rendering this div, I am toggling display CSS because I need the Form to exist even when the reply isn't shown, for the ref to be used.

@SatsAllDay SatsAllDay marked this pull request as ready for review September 27, 2023 20:33
@huumn
Copy link
Member

huumn commented Sep 28, 2023

omg this looks amazing! (haven't looked at the code yet)

maybe we should insert a newline after the quote or is that unergonomic for some reason?

@SatsAllDay
Copy link
Contributor Author

omg this looks amazing! (haven't looked at the code yet)

Thank you! This is one of those areas where react does not shine IMO (imperative code), so while I don't like the code that much, it works better than trying to do it declaratively via props.

maybe we should insert a newline after the quote or is that unergonomic for some reason?

Yep, that's a good idea. That way any text you write doesn't get lumped into the quote automatically.

@SatsAllDay
Copy link
Contributor Author

maybe we should insert a newline after the quote or is that unergonomic for some reason?

Done

Copy link
Member

@ekzyis ekzyis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To summarize and it helps me to make sure I understand what's going on:

You create two refs in <Comment>. One to reference the reply (replyRef) and one to reference the container holding the content we are replying to (contentContainerRef).

The contentContainerRef is simply passed to a <div> inside <Comment>. Nothing more to follow there.

Both refs get passed to <Reply>.

In <Reply>, replyRef gets transformed using useImperativeHandle to only expose the quoteReply function. That way you can pass replyRef?.current?.quoteReply as quoteReply to <ItemInfo> in <Comment>. <ItemInfo> then renders conditionally the menu item for quote item.

contentContainerRef is used in quoteReply to detect if we selected something in the text we're replying to.

In <Reply> two more refs are created: replyInput and formInnerRef.

replyInput is passed to <MarkdownInput> which then references the <input> element (after going through <InputInner>, too). This one then allows us to reference the existing reply in quoteReply so we don't overwrite it.

formInnerRef is passed to <Form> and then to <Formik>. That one is required to actually update the text in quoteReply since it's controlled by formik.

LGTM! 😄

But while playing around, I think I found a way to make quote item disappear as a menu item ... then I started recording to reproduce it but I wasn't able to, lol

Will take another look later, but now I have gotten a good overview of what you did :)

@SatsAllDay
Copy link
Contributor Author

You got it! As I mentioned above, the imperative stuff is kind of a mess. I wish this code was simpler but it just isn't. I welcome any simplification suggestions.

But while playing around, I think I found a way to make quote item disappear as a menu item ... then I started recording to reproduce it but I wasn't able to, lol

I have noticed it sometimes takes a moment for the "quote reply" action to show up when loading a page locally. I figured it was still loading the item into memory via GraphQL API call so that the conditional rendering logic could be satisfied, but I also was not able to reliably reproduce it. I figured it was a side-effect of my slow dev machine :P

@ekzyis
Copy link
Member

ekzyis commented Sep 29, 2023

I wish this code was simpler but it just isn't. I welcome any simplification suggestions.

I always wondered if we should make use of more local context. Afaik, we currently only use it on the global level to provide global context. But what if we use it to provide context for individual components, like to make Reply aware of what it's replying to etc.?

@SatsAllDay
Copy link
Contributor Author

I actually didn't realize you could use the same context provider in a nested fashion. That could be useful in this context, no pun intended lol

@SatsAllDay
Copy link
Contributor Author

Since the reply component already has the item to which it's replying as a prop, local context may only help with the "is the selection part of the item" aspect, which is cool, but we'll still need a ref for the DOM node, so I'm not sure it'll save us much in this case. Just my quick thinking, could be missing something...

@huumn
Copy link
Member

huumn commented Sep 29, 2023

Afaik, we currently only use it on the global level to provide global context.

fwiw we also use context to provide item roots to child comments

@huumn
Copy link
Member

huumn commented Oct 4, 2023

All this imperative stuff scares the crap out of me, but it's well written and super useful so we have to let it fly

I changed it to use onPointerDown to work on mobile. I also made it so that selecting text in the parent then hitting reply also quote replies.

Excellent work! 🚀🚀🚀

@huumn huumn merged commit f6141a6 into stackernews:master Oct 4, 2023
1 check passed
@SatsAllDay
Copy link
Contributor Author

All this imperative stuff scares the crap out of me, but it's well written and super useful so we have to let it fly

I changed it to use onPointerDown to work on mobile. I also made it so that selecting text in the parent then hitting reply also quote replies.

Excellent work! 🚀🚀🚀

Thank you for the kind words 🙏 and thank you for the changes to enable mobile support + the other quote reply vector. Nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Quote reply option
3 participants