6b8820b7ab
* Handle quote reply of selections in iOS Safari Approach borrowed from https://stackoverflow.com/a/72537632 Basically this makes a copy of the selection when the "touchend" event occurs, so we can use it for processing later This code listens to that event for each instance of the reply component, removing the event listener on unmount * Update docker-compose up command in dev notes --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
218 lines
7.5 KiB
JavaScript
218 lines
7.5 KiB
JavaScript
import { Form, MarkdownInput, SubmitButton } from '../components/form'
|
|
import { gql, useMutation } from '@apollo/client'
|
|
import styles from './reply.module.css'
|
|
import { COMMENTS } from '../fragments/comments'
|
|
import { useMe } from './me'
|
|
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'
|
|
import { commentSchema } from '../lib/validate'
|
|
import Info from './info'
|
|
import { quote } from '../lib/md'
|
|
|
|
export function ReplyOnAnotherPage ({ parentId }) {
|
|
return (
|
|
<Link href={`/items/${parentId}`} className={`${styles.replyButtons} text-muted`}>
|
|
reply on another page
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function FreebieDialog () {
|
|
return (
|
|
<div className='text-muted'>
|
|
you have no sats, so this one is on us
|
|
<Info>
|
|
<ul className='fw-bold'>
|
|
<li>Free comments have limited visibility and are listed at the bottom of the comment section until other stackers zap them.</li>
|
|
<li>Free comments will not cover comments that cost more than 1 sat.</li>
|
|
<li>To get fully visibile and unrestricted comments right away, fund your account with a few sats or earn some on Stacker News.</li>
|
|
</ul>
|
|
</Info>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, contentContainerRef }, ref) {
|
|
const [reply, setReply] = useState(replyOpen)
|
|
const me = useMe()
|
|
const parentId = item.id
|
|
const replyInput = useRef(null)
|
|
const formInnerRef = useRef()
|
|
|
|
// 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(() => {
|
|
document.addEventListener('touchend', onTouchEnd)
|
|
return () => document.removeEventListener('touchend', onTouchEnd)
|
|
}, [])
|
|
// 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])
|
|
|
|
useEffect(() => {
|
|
setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text'))
|
|
}, [])
|
|
|
|
const [upsertComment] = useMutation(
|
|
gql`
|
|
${COMMENTS}
|
|
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) {
|
|
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
|
|
...CommentFields
|
|
comments {
|
|
...CommentsRecursive
|
|
}
|
|
}
|
|
}`, {
|
|
update (cache, { data: { upsertComment } }) {
|
|
cache.modify({
|
|
id: `Item:${parentId}`,
|
|
fields: {
|
|
comments (existingCommentRefs = []) {
|
|
const newCommentRef = cache.writeFragment({
|
|
data: upsertComment,
|
|
fragment: COMMENTS,
|
|
fragmentName: 'CommentsRecursive'
|
|
})
|
|
return [newCommentRef, ...existingCommentRefs]
|
|
}
|
|
}
|
|
})
|
|
|
|
const ancestors = item.path.split('.')
|
|
|
|
// update all ancestors
|
|
ancestors.forEach(id => {
|
|
cache.modify({
|
|
id: `Item:${id}`,
|
|
fields: {
|
|
ncomments (existingNComments = 0) {
|
|
return existingNComments + 1
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// 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
|
|
const root = ancestors[0]
|
|
commentsViewedAfterComment(root, upsertComment.createdAt)
|
|
}
|
|
}
|
|
)
|
|
|
|
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
|
|
await upsertComment({ variables: { parentId, hash, hmac, ...values } })
|
|
resetForm({ text: '' })
|
|
setReply(replyOpen || false)
|
|
}, [upsertComment, setReply, parentId])
|
|
|
|
useEffect(() => {
|
|
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
|
|
}, [reply])
|
|
|
|
return (
|
|
<div>
|
|
{replyOpen
|
|
? <div className={styles.replyButtons} />
|
|
: (
|
|
<div className={styles.replyButtons}>
|
|
<div
|
|
onPointerDown={e => {
|
|
if (!reply) {
|
|
e.preventDefault()
|
|
ref?.current?.quoteReply({ selectionOnly: true })
|
|
}
|
|
setReply(!reply)
|
|
}}
|
|
>
|
|
{reply ? 'cancel' : 'reply'}
|
|
</div>
|
|
{/* HACK if we need more items, we should probably do a comment toolbar */}
|
|
{children}
|
|
</div>)}
|
|
<div className={styles.reply} style={{ display: reply ? 'block' : 'none' }}>
|
|
<Form
|
|
initial={{
|
|
text: ''
|
|
}}
|
|
schema={commentSchema}
|
|
invoiceable
|
|
onSubmit={onSubmit}
|
|
storageKeyPrefix={`reply-${parentId}`}
|
|
innerRef={formInnerRef}
|
|
>
|
|
<MarkdownInput
|
|
name='text'
|
|
minRows={6}
|
|
autoFocus={!replyOpen}
|
|
required
|
|
placeholder={placeholder}
|
|
hint={me?.sats < 1 && <FreebieDialog />}
|
|
innerRef={replyInput}
|
|
/>
|
|
{reply &&
|
|
<div className='mt-1'>
|
|
<FeeButton
|
|
baseFee={1} parentId={parentId} text='reply'
|
|
ChildButton={SubmitButton} variant='secondary' alwaysShow
|
|
/>
|
|
</div>}
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export function ReplySkeleton () {
|
|
return (
|
|
<div className={`${styles.reply} ${styles.skeleton}`}>
|
|
<div className={`${styles.input} clouds`} />
|
|
<div className={`${styles.button} clouds`} />
|
|
</div>
|
|
)
|
|
}
|