fix: ensure events are sent to mentioned users' read relays
This commit is contained in:
208
src/components/TextareaWithMentions/index.tsx
Normal file
208
src/components/TextareaWithMentions/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import client from '@/services/client.service'
|
||||
import { TProfile } from '@/types'
|
||||
import React, {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import Nip05 from '../Nip05'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
import { getCurrentWord, replaceWord } from './utils'
|
||||
|
||||
export default function TextareaWithMentions({
|
||||
textValue,
|
||||
setTextValue,
|
||||
cursorOffset = 0,
|
||||
...props
|
||||
}: ComponentProps<'textarea'> & {
|
||||
textValue: string
|
||||
setTextValue: Dispatch<SetStateAction<string>>
|
||||
cursorOffset?: number
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const [debouncedCommandValue, setDebouncedCommandValue] = useState(commandValue)
|
||||
const [profiles, setProfiles] = useState<TProfile[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && cursorOffset !== 0) {
|
||||
const textarea = textareaRef.current
|
||||
const newPos = Math.max(0, textarea.selectionStart - cursorOffset)
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}
|
||||
}, [cursorOffset])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedCommandValue(commandValue)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [commandValue])
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles([])
|
||||
if (debouncedCommandValue) {
|
||||
const fetchProfiles = async () => {
|
||||
const newProfiles = await client.searchProfilesFromIndex(debouncedCommandValue, 100)
|
||||
setProfiles(newProfiles)
|
||||
}
|
||||
fetchProfiles()
|
||||
}
|
||||
}, [debouncedCommandValue])
|
||||
|
||||
useEffect(() => {
|
||||
const dropdown = dropdownRef.current
|
||||
if (!dropdown) return
|
||||
|
||||
if (profiles.length > 0) {
|
||||
dropdown.classList.remove('hidden')
|
||||
} else {
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}, [profiles])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const dropdown = dropdownRef.current
|
||||
if (dropdown) {
|
||||
dropdown.classList.add('hidden')
|
||||
setCommandValue('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const textarea = textareaRef.current
|
||||
const input = inputRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && input && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
const isDropdownHidden = dropdown.classList.contains('hidden')
|
||||
if (currentWord.startsWith('@') && !isDropdownHidden) {
|
||||
if (
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'Escape'
|
||||
) {
|
||||
e.preventDefault()
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTextValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
setTextValue(text)
|
||||
if (currentWord.startsWith('@') && currentWord.length > 1) {
|
||||
setCommandValue(currentWord.slice(1))
|
||||
} else {
|
||||
// REMINDER: apparently, we need it when deleting
|
||||
if (commandValue !== '') {
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setTextValue, commandValue]
|
||||
)
|
||||
|
||||
const onCommandSelect = useCallback((value: string) => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && dropdown) {
|
||||
replaceWord(textarea, `${value}`)
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleSectionChange = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
if (!currentWord.startsWith('@') && commandValue !== '') {
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}, [commandValue])
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
textarea?.addEventListener('keydown', handleKeyDown)
|
||||
textarea?.addEventListener('blur', handleBlur)
|
||||
document?.addEventListener('selectionchange', handleSectionChange)
|
||||
dropdown?.addEventListener('mousedown', handleMouseDown)
|
||||
return () => {
|
||||
textarea?.removeEventListener('keydown', handleKeyDown)
|
||||
textarea?.removeEventListener('blur', handleBlur)
|
||||
document?.removeEventListener('selectionchange', handleSectionChange)
|
||||
dropdown?.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange])
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Textarea {...props} ref={textareaRef} value={textValue} onChange={onTextValueChange} />
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
className={cn(
|
||||
'sm:fixed hidden translate-y-2 h-auto w-full sm:w-[462px] z-10 border border-popover shadow'
|
||||
)}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="hidden">
|
||||
<CommandInput ref={inputRef} value={commandValue} />
|
||||
</div>
|
||||
<CommandList scrollAreaClassName="h-44">
|
||||
{profiles.map((p) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={p.pubkey}
|
||||
value={`nostr:${pubkeyToNpub(p.pubkey)}`}
|
||||
onSelect={onCommandSelect}
|
||||
>
|
||||
<div className="flex gap-2 items-center pointer-events-none truncate">
|
||||
<SimpleUserAvatar userId={p.pubkey} />
|
||||
<div>
|
||||
<SimpleUsername userId={p.pubkey} className="font-semibold truncate" />
|
||||
<Nip05 pubkey={p.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/TextareaWithMentions/utils.ts
Normal file
67
src/components/TextareaWithMentions/utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export function getCaretPosition(element: HTMLTextAreaElement) {
|
||||
return {
|
||||
caretStartIndex: element.selectionStart || 0,
|
||||
caretEndIndex: element.selectionEnd || 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentWord(element: HTMLTextAreaElement) {
|
||||
const text = element.value
|
||||
const { caretStartIndex } = getCaretPosition(element)
|
||||
|
||||
// Find the start position of the word
|
||||
let start = caretStartIndex
|
||||
while (start > 0 && text[start - 1].match(/\S/)) {
|
||||
start--
|
||||
}
|
||||
|
||||
// Find the end position of the word
|
||||
let end = caretStartIndex
|
||||
while (end < text.length && text[end].match(/\S/)) {
|
||||
end++
|
||||
}
|
||||
|
||||
const w = text.substring(start, end)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
export function replaceWord(element: HTMLTextAreaElement, value: string) {
|
||||
const text = element.value
|
||||
const caretPos = element.selectionStart
|
||||
|
||||
// Find the word that needs to be replaced
|
||||
const wordRegex = /[\w@#]+/g
|
||||
let match
|
||||
let startIndex
|
||||
let endIndex
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
startIndex = match.index
|
||||
endIndex = startIndex + match[0].length
|
||||
|
||||
if (caretPos >= startIndex && caretPos <= endIndex) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the word with a new word using document.execCommand
|
||||
if (startIndex !== undefined && endIndex !== undefined) {
|
||||
// Preserve the current selection range
|
||||
const selectionStart = element.selectionStart
|
||||
const selectionEnd = element.selectionEnd
|
||||
|
||||
// Modify the selected range to encompass the word to be replaced
|
||||
element.setSelectionRange(startIndex, endIndex)
|
||||
|
||||
// REMINDER: Fastest way to include CMD + Z compatibility
|
||||
// Execute the command to replace the selected text with the new word
|
||||
document.execCommand('insertText', false, value)
|
||||
|
||||
// Restore the original selection range
|
||||
element.setSelectionRange(
|
||||
selectionStart - (endIndex - startIndex) + value.length,
|
||||
selectionEnd - (endIndex - startIndex) + value.length
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user