feat: mentions
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { useFetchNip05 } from '@/hooks/useFetchNip05'
|
||||
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
||||
|
||||
export default function Nip05({ nip05, pubkey }: { nip05?: string; pubkey: string }) {
|
||||
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
|
||||
export default function Nip05({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(profile?.nip05, pubkey)
|
||||
|
||||
if (!profile?.nip05) return null
|
||||
|
||||
return (
|
||||
nip05Name &&
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StorageKey } from '@/constants'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
@@ -11,6 +10,7 @@ import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
||||
import Mentions from './Mentions'
|
||||
import Preview from './Preview'
|
||||
import Uploader from './Uploader'
|
||||
@@ -39,10 +39,6 @@ export default function NormalPostContent({
|
||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
||||
}, [])
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
}
|
||||
|
||||
const post = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
@@ -103,10 +99,10 @@ export default function NormalPostContent({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
<TextareaWithMentions
|
||||
className="h-32"
|
||||
onChange={handleTextareaChange}
|
||||
value={content}
|
||||
setTextValue={setContent}
|
||||
textValue={content}
|
||||
placeholder={t('Write something...')}
|
||||
/>
|
||||
{content && <Preview content={content} />}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StorageKey } from '@/constants'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
||||
@@ -11,6 +10,7 @@ import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Image from '../Image'
|
||||
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
||||
import Mentions from './Mentions'
|
||||
import Uploader from './Uploader'
|
||||
|
||||
@@ -29,10 +29,6 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
||||
}, [])
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
}
|
||||
|
||||
const post = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
@@ -91,10 +87,10 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
||||
{t('A special note for picture-first clients like Olas')}
|
||||
</div>
|
||||
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
|
||||
<Textarea
|
||||
<TextareaWithMentions
|
||||
className="h-32"
|
||||
onChange={handleTextareaChange}
|
||||
value={content}
|
||||
setTextValue={setContent}
|
||||
textValue={content}
|
||||
placeholder={t('Write something...')}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SimpleUserAvatar } from '../UserAvatar'
|
||||
|
||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { username, nip05, about } = profile || {}
|
||||
const { username, about } = profile || {}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
@@ -16,7 +16,7 @@ export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold truncate">{username}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||
<Nip05 pubkey={pubkey} />
|
||||
</div>
|
||||
{about && (
|
||||
<div
|
||||
|
||||
191
src/components/TextareaWithMentions.tsx/index.tsx
Normal file
191
src/components/TextareaWithMentions.tsx/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
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,
|
||||
...props
|
||||
}: ComponentProps<'textarea'> & {
|
||||
textValue: string
|
||||
setTextValue: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
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, isFetching } = useSearchProfiles(debouncedCommandValue, 10)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedCommandValue(commandValue)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [commandValue])
|
||||
|
||||
useEffect(() => {
|
||||
const dropdown = dropdownRef.current
|
||||
if (!dropdown) return
|
||||
|
||||
if (profiles.length > 0 && !isFetching) {
|
||||
dropdown.classList.remove('hidden')
|
||||
} else {
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}, [profiles, isFetching])
|
||||
|
||||
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 max-h-44 w-full sm:w-[462px] z-10 overflow-auto border border-popover shadow'
|
||||
)}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="hidden">
|
||||
<CommandInput ref={inputRef} value={commandValue} />
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{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">
|
||||
<SimpleUserAvatar userId={p.pubkey} />
|
||||
<SimpleUsername userId={p.pubkey} className="font-semibold" />
|
||||
<Nip05 pubkey={p.pubkey} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/TextareaWithMentions.tsx/utils.ts
Normal file
67
src/components/TextareaWithMentions.tsx/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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,14 @@ import { useFetchProfile } from '@/hooks'
|
||||
|
||||
export default function UserItem({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05, about } = profile || {}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||
<Nip05 pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
|
||||
@@ -71,15 +71,14 @@ export default function MuteListPage({ index }: { index?: number }) {
|
||||
|
||||
function UserItem({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05, about } = profile || {}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||
<Nip05 pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
|
||||
</div>
|
||||
<MuteButton pubkey={pubkey} />
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
}
|
||||
if (!profile) return <NotFoundPage />
|
||||
|
||||
const { banner, username, nip05, about, avatar, pubkey } = profile
|
||||
const { banner, username, about, avatar, pubkey } = profile
|
||||
return (
|
||||
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
@@ -111,7 +111,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||
<Nip05 pubkey={pubkey} />
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PubkeyCopy pubkey={pubkey} />
|
||||
<QrCodePopover pubkey={pubkey} />
|
||||
|
||||
Reference in New Issue
Block a user