fix: ensure events are sent to mentioned users' read relays

This commit is contained in:
codytseng
2025-03-13 12:03:38 +08:00
parent 24a18e4d7a
commit 78caabeafc
7 changed files with 45 additions and 55 deletions

View 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>
)
}

View 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
)
}
}