feat: nip05 feeds
This commit is contained in:
parent
e08172f4a7
commit
5619905ae0
28 changed files with 395 additions and 165 deletions
15
src/components/Favicon/index.tsx
Normal file
15
src/components/Favicon/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
export function Favicon({ domain, className }: { domain: string; className?: string }) {
|
||||
const [error, setError] = useState(false)
|
||||
if (error) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://${domain}/favicon.ico`}
|
||||
alt={domain}
|
||||
className={className}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,22 @@ import dayjs from 'dayjs'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function FormattedTimestamp({
|
||||
timestamp,
|
||||
short = false,
|
||||
className
|
||||
}: {
|
||||
timestamp: number
|
||||
short?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<FormattedTimestampContent timestamp={timestamp} short={short} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FormattedTimestampContent({
|
||||
timestamp,
|
||||
short = false
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { useFetchNip05 } from '@/hooks/useFetchNip05'
|
||||
import { toNoteList } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
||||
import { Favicon } from '../Favicon'
|
||||
|
||||
export default function Nip05({ pubkey }: { pubkey: string }) {
|
||||
export default function Nip05({ pubkey, append }: { pubkey: string; append?: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05IsVerified, nip05Name, nip05Domain, isFetching } = useFetchNip05(
|
||||
profile?.nip05,
|
||||
|
|
@ -13,30 +16,27 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
|
|||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center py-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile?.nip05) return null
|
||||
if (!profile?.nip05 || !nip05Name || !nip05Domain) return null
|
||||
|
||||
return (
|
||||
nip05Name &&
|
||||
nip05Domain && (
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
{nip05Name !== '_' ? (
|
||||
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
|
||||
) : null}
|
||||
<a
|
||||
href={`https://${nip05Domain}`}
|
||||
target="_blank"
|
||||
className={`flex items-center space-x-1 hover:underline truncate ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{nip05IsVerified ? <BadgeCheck className="size-4" /> : <BadgeAlert className="size-4" />}
|
||||
<div className="text-sm truncate">{nip05Domain}</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
<div className="flex items-center gap-1 truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{nip05Name !== '_' ? (
|
||||
<span className="text-sm text-muted-foreground truncate">@{nip05Name}</span>
|
||||
) : null}
|
||||
<SecondaryPageLink
|
||||
to={toNoteList({ domain: nip05Domain })}
|
||||
className={`flex items-center gap-1 hover:underline truncate [&_svg]:size-3.5 [&_svg]:shrink-0 ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{nip05IsVerified ? <BadgeCheck /> : <BadgeAlert />}
|
||||
<span className="text-sm truncate">{nip05Domain}</span>
|
||||
</SecondaryPageLink>
|
||||
<Favicon domain={nip05Domain} className="w-3.5 h-3.5" />
|
||||
{append && <span className="text-sm text-muted-foreground truncate">{append}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import {
|
|||
isSupportedKind
|
||||
} from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
|
|
@ -33,6 +35,7 @@ export default function Note({
|
|||
hideParentNotePreview?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const parentEventId = useMemo(
|
||||
() => (hideParentNotePreview ? undefined : getParentEventId(event)),
|
||||
[event, hideParentNotePreview]
|
||||
|
|
@ -47,28 +50,33 @@ export default function Note({
|
|||
<div className={className}>
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
|
||||
<div
|
||||
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
|
||||
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||
/>
|
||||
{usingClient && size === 'normal' && (
|
||||
<div className="text-xs text-muted-foreground shrink-0">using {usingClient}</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">using {usingClient}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
<div className="flex items-baseline gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={event.pubkey} append="·" />
|
||||
<FormattedTimestamp
|
||||
timestamp={event.created_at}
|
||||
className="shrink-0"
|
||||
short={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
|
||||
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
||||
{size === 'normal' && (
|
||||
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{parentEventId && (
|
||||
|
|
|
|||
45
src/components/ProfileList/index.tsx
Normal file
45
src/components/ProfileList/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import UserItem from '../UserItem'
|
||||
|
||||
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
|
||||
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setVisiblePubkeys(pubkeys.slice(0, 10))
|
||||
}, [pubkeys])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && pubkeys.length > visiblePubkeys.length) {
|
||||
setVisiblePubkeys((prev) => [...prev, ...pubkeys.slice(prev.length, prev.length + 10)])
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [visiblePubkeys, pubkeys])
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
{visiblePubkeys.map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
{pubkeys.length > visiblePubkeys.length && <div ref={bottomRef} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getUsingClient } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Collapsible from '../Collapsible'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
|
|
@ -28,6 +31,7 @@ export default function ReplyNote({
|
|||
highlight?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const [showMuted, setShowMuted] = useState(false)
|
||||
|
|
@ -35,6 +39,7 @@ export default function ReplyNote({
|
|||
() => showMuted || !mutePubkeys.includes(event.pubkey),
|
||||
[showMuted, mutePubkeys, event]
|
||||
)
|
||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -43,21 +48,33 @@ export default function ReplyNote({
|
|||
>
|
||||
<Collapsible>
|
||||
<div className="flex space-x-2 items-start px-4 pt-3">
|
||||
<UserAvatar userId={event.pubkey} className="shrink-0 h-8 w-8" />
|
||||
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-1" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex gap-2 items-center flex-1 w-0">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex gap-1 items-baseline">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
{usingClient && (
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
using {usingClient}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={event.pubkey} append="·" />
|
||||
<FormattedTimestamp
|
||||
timestamp={event.created_at}
|
||||
className="shrink-0"
|
||||
short={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0">
|
||||
<TranslateButton event={event} />
|
||||
<TranslateButton event={event} className="py-0" />
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export default function TranslateButton({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center text-muted-foreground hover:text-pink-400 px-2 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
|
||||
'flex items-center text-muted-foreground hover:text-pink-400 px-2 py-1 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
|
||||
className
|
||||
)}
|
||||
disabled={translating}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const UserAvatarSizeCnMap = {
|
|||
large: 'w-24 h-24',
|
||||
big: 'w-16 h-16',
|
||||
normal: 'w-10 h-10',
|
||||
medium: 'w-8 h-8',
|
||||
small: 'w-7 h-7',
|
||||
xSmall: 'w-5 h-5',
|
||||
tiny: 'w-4 h-4'
|
||||
|
|
@ -25,7 +26,7 @@ export default function UserAvatar({
|
|||
}: {
|
||||
userId: string
|
||||
className?: string
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ import FollowButton from '@/components/FollowButton'
|
|||
import Nip05 from '@/components/Nip05'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import Username from '@/components/Username'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
|
||||
export default function UserItem({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="flex gap-2 items-center h-14">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username
|
||||
|
|
@ -17,7 +14,6 @@ export default function UserItem({ pubkey }: { pubkey: string }) {
|
|||
skeletonClassName="h-4"
|
||||
/>
|
||||
<Nip05 pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue