feat: mentions

This commit is contained in:
codytseng
2025-01-23 11:01:49 +08:00
parent c92545a22d
commit 86468e75cb
9 changed files with 280 additions and 28 deletions

View File

@@ -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 &&

View File

@@ -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} />}

View File

@@ -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">

View File

@@ -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

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

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />