feat: mentions
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
|
import { useFetchProfile } from '@/hooks'
|
||||||
import { useFetchNip05 } from '@/hooks/useFetchNip05'
|
import { useFetchNip05 } from '@/hooks/useFetchNip05'
|
||||||
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
||||||
|
|
||||||
export default function Nip05({ nip05, pubkey }: { nip05?: string; pubkey: string }) {
|
export default function Nip05({ pubkey }: { pubkey: string }) {
|
||||||
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
|
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(profile?.nip05, pubkey)
|
||||||
|
|
||||||
|
if (!profile?.nip05) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
nip05Name &&
|
nip05Name &&
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { StorageKey } from '@/constants'
|
import { StorageKey } from '@/constants'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
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 { Event, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
||||||
import Mentions from './Mentions'
|
import Mentions from './Mentions'
|
||||||
import Preview from './Preview'
|
import Preview from './Preview'
|
||||||
import Uploader from './Uploader'
|
import Uploader from './Uploader'
|
||||||
@@ -39,10 +39,6 @@ export default function NormalPostContent({
|
|||||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
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) => {
|
const post = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
checkLogin(async () => {
|
checkLogin(async () => {
|
||||||
@@ -103,10 +99,10 @@ export default function NormalPostContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Textarea
|
<TextareaWithMentions
|
||||||
className="h-32"
|
className="h-32"
|
||||||
onChange={handleTextareaChange}
|
setTextValue={setContent}
|
||||||
value={content}
|
textValue={content}
|
||||||
placeholder={t('Write something...')}
|
placeholder={t('Write something...')}
|
||||||
/>
|
/>
|
||||||
{content && <Preview content={content} />}
|
{content && <Preview content={content} />}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { StorageKey } from '@/constants'
|
import { StorageKey } from '@/constants'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
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 { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
|
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
||||||
import Mentions from './Mentions'
|
import Mentions from './Mentions'
|
||||||
import Uploader from './Uploader'
|
import Uploader from './Uploader'
|
||||||
|
|
||||||
@@ -29,10 +29,6 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
|||||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
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) => {
|
const post = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
checkLogin(async () => {
|
checkLogin(async () => {
|
||||||
@@ -91,10 +87,10 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
|||||||
{t('A special note for picture-first clients like Olas')}
|
{t('A special note for picture-first clients like Olas')}
|
||||||
</div>
|
</div>
|
||||||
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
|
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
|
||||||
<Textarea
|
<TextareaWithMentions
|
||||||
className="h-32"
|
className="h-32"
|
||||||
onChange={handleTextareaChange}
|
setTextValue={setContent}
|
||||||
value={content}
|
textValue={content}
|
||||||
placeholder={t('Write something...')}
|
placeholder={t('Write something...')}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SimpleUserAvatar } from '../UserAvatar'
|
|||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
const { username, nip05, about } = profile || {}
|
const { username, about } = profile || {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
@@ -16,7 +16,7 @@ export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold truncate">{username}</div>
|
<div className="text-lg font-semibold truncate">{username}</div>
|
||||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
<Nip05 pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
{about && (
|
{about && (
|
||||||
<div
|
<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 }) {
|
export default function UserItem({ pubkey }: { pubkey: string }) {
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
const { nip05, about } = profile || {}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
<Nip05 pubkey={pubkey} />
|
||||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
|
||||||
</div>
|
</div>
|
||||||
<FollowButton pubkey={pubkey} />
|
<FollowButton pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,15 +71,14 @@ export default function MuteListPage({ index }: { index?: number }) {
|
|||||||
|
|
||||||
function UserItem({ pubkey }: { pubkey: string }) {
|
function UserItem({ pubkey }: { pubkey: string }) {
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
const { nip05, about } = profile || {}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
<Nip05 pubkey={pubkey} />
|
||||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
|
||||||
</div>
|
</div>
|
||||||
<MuteButton pubkey={pubkey} />
|
<MuteButton pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
|||||||
}
|
}
|
||||||
if (!profile) return <NotFoundPage />
|
if (!profile) return <NotFoundPage />
|
||||||
|
|
||||||
const { banner, username, nip05, about, avatar, pubkey } = profile
|
const { banner, username, about, avatar, pubkey } = profile
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
|
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
@@ -111,7 +111,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
|||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-xl font-semibold">{username}</div>
|
<div className="text-xl font-semibold">{username}</div>
|
||||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
<Nip05 pubkey={pubkey} />
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<PubkeyCopy pubkey={pubkey} />
|
||||||
<QrCodePopover pubkey={pubkey} />
|
<QrCodePopover pubkey={pubkey} />
|
||||||
|
|||||||
Reference in New Issue
Block a user