From 5619905ae0e7dcc6c0c76bc2e750fe642b0aa72c Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 26 Jun 2025 23:21:12 +0800 Subject: [PATCH] feat: nip05 feeds --- public/.well-known/nostr.json | 7 + src/components/Favicon/index.tsx | 15 ++ src/components/FormattedTimestamp/index.tsx | 16 ++ src/components/Nip05/index.tsx | 40 ++--- src/components/Note/index.tsx | 26 ++-- src/components/ProfileList/index.tsx | 45 ++++++ src/components/ReplyNote/index.tsx | 39 +++-- src/components/TranslateButton/index.tsx | 2 +- src/components/UserAvatar/index.tsx | 3 +- src/components/UserItem/index.tsx | 6 +- src/i18n/locales/ar.ts | 3 +- src/i18n/locales/de.ts | 3 +- src/i18n/locales/en.ts | 3 +- src/i18n/locales/es.ts | 3 +- src/i18n/locales/fr.ts | 3 +- src/i18n/locales/it.ts | 3 +- src/i18n/locales/ja.ts | 3 +- src/i18n/locales/pl.ts | 3 +- src/i18n/locales/pt-BR.ts | 3 +- src/i18n/locales/pt-PT.ts | 3 +- src/i18n/locales/ru.ts | 3 +- src/i18n/locales/th.ts | 3 +- src/i18n/locales/zh.ts | 3 +- src/lib/link.ts | 8 +- src/lib/nip05.ts | 32 +++- .../secondary/FollowingListPage/index.tsx | 45 +----- src/pages/secondary/NoteListPage/index.tsx | 139 +++++++++++++----- src/pages/secondary/ProfileListPage/index.tsx | 94 +++++++++--- 28 files changed, 393 insertions(+), 163 deletions(-) create mode 100644 public/.well-known/nostr.json create mode 100644 src/components/Favicon/index.tsx create mode 100644 src/components/ProfileList/index.tsx diff --git a/public/.well-known/nostr.json b/public/.well-known/nostr.json new file mode 100644 index 0000000..16c64c4 --- /dev/null +++ b/public/.well-known/nostr.json @@ -0,0 +1,7 @@ +{ + "names": { + "_": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883", + "cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883", + "cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab" + } +} \ No newline at end of file diff --git a/src/components/Favicon/index.tsx b/src/components/Favicon/index.tsx new file mode 100644 index 0000000..43f7d8f --- /dev/null +++ b/src/components/Favicon/index.tsx @@ -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 ( + {domain} setError(true)} + /> + ) +} diff --git a/src/components/FormattedTimestamp/index.tsx b/src/components/FormattedTimestamp/index.tsx index 6c3b521..ebaef09 100644 --- a/src/components/FormattedTimestamp/index.tsx +++ b/src/components/FormattedTimestamp/index.tsx @@ -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 ( + + + + ) +} + +function FormattedTimestampContent({ timestamp, short = false }: { diff --git a/src/components/Nip05/index.tsx b/src/components/Nip05/index.tsx index 2f66827..bef22de 100644 --- a/src/components/Nip05/index.tsx +++ b/src/components/Nip05/index.tsx @@ -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 (
- +
) } - if (!profile?.nip05) return null + if (!profile?.nip05 || !nip05Name || !nip05Domain) return null return ( - nip05Name && - nip05Domain && ( -
- {nip05Name !== '_' ? ( -
@{nip05Name}
- ) : null} - - {nip05IsVerified ? : } -
{nip05Domain}
-
-
- ) +
e.stopPropagation()}> + {nip05Name !== '_' ? ( + @{nip05Name} + ) : null} + + {nip05IsVerified ? : } + {nip05Domain} + + + {append && {append}} +
) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index eb2a047..e7a7b79 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({
- -
-
+ +
+
{usingClient && size === 'normal' && ( -
using {usingClient}
+ using {usingClient} )}
-
- +
+ +
- {size === 'normal' && } + {size === 'normal' && ( + + )}
{parentEventId && ( diff --git a/src/components/ProfileList/index.tsx b/src/components/ProfileList/index.tsx new file mode 100644 index 0000000..f46e7b4 --- /dev/null +++ b/src/components/ProfileList/index.tsx @@ -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([]) + const bottomRef = useRef(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 ( +
+ {visiblePubkeys.map((pubkey, index) => ( + + ))} + {pubkeys.length > visiblePubkeys.length &&
} +
+ ) +} diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index a2a4840..35bf46d 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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 (
- +
-
- -
- +
+
+ + {usingClient && ( + + using {usingClient} + + )} +
+
+ +
- +
diff --git a/src/components/TranslateButton/index.tsx b/src/components/TranslateButton/index.tsx index 58d92e6..b116507 100644 --- a/src/components/TranslateButton/index.tsx +++ b/src/components/TranslateButton/index.tsx @@ -122,7 +122,7 @@ export default function TranslateButton({ return ( + ) + } + return } } - return { urls: BIG_RELAY_URLS } + init() }, []) + let content: React.ReactNode = null + if (data?.type === 'domain' && data.filter?.authors?.length === 0) { + content = ( +
+ + {t('No pubkeys found from {url}', { url: getWellKnownNip05Url(data.domain) })} + +
+ ) + } else if (data) { + content = + } + return ( - - + + {content} ) }) diff --git a/src/pages/secondary/ProfileListPage/index.tsx b/src/pages/secondary/ProfileListPage/index.tsx index ab75ed8..2b4b6bf 100644 --- a/src/pages/secondary/ProfileListPage/index.tsx +++ b/src/pages/secondary/ProfileListPage/index.tsx @@ -1,11 +1,13 @@ +import { Favicon } from '@/components/Favicon' +import ProfileList from '@/components/ProfileList' import UserItem from '@/components/UserItem' import { SEARCHABLE_RELAY_URLS } from '@/constants' import { useFetchRelayInfos } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { fetchPubkeysFromDomain } from '@/lib/nip05' import { useFeed } from '@/providers/FeedProvider' import client from '@/services/client.service' import dayjs from 'dayjs' -import { Filter } from 'nostr-tools' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,27 +15,75 @@ const LIMIT = 50 const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() + const [title, setTitle] = useState() + const [data, setData] = useState<{ + type: 'search' | 'domain' + id: string + } | null>(null) + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search) + const search = searchParams.get('s') + if (search) { + setTitle(`${t('Search')}: ${search}`) + setData({ type: 'search', id: search }) + return + } + + const domain = searchParams.get('d') + if (domain) { + setTitle( +
+ {domain} + +
+ ) + setData({ type: 'domain', id: domain }) + return + } + }, []) + + let content: React.ReactNode = null + if (data?.type === 'search') { + content = + } else if (data?.type === 'domain') { + content = + } + + return ( + + {content} + + ) +}) +ProfileListPage.displayName = 'ProfileListPage' +export default ProfileListPage + +function ProfileListByDomain({ domain }: { domain: string }) { + const [pubkeys, setPubkeys] = useState([]) + + useEffect(() => { + const init = async () => { + const _pubkeys = await fetchPubkeysFromDomain(domain) + setPubkeys(_pubkeys) + } + init() + }, [domain]) + + return +} + +function ProfileListBySearch({ search }: { search: string }) { const { relayUrls } = useFeed() const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const [until, setUntil] = useState(() => dayjs().unix()) const [hasMore, setHasMore] = useState(true) const [pubkeySet, setPubkeySet] = useState(new Set()) const bottomRef = useRef(null) - const filter = useMemo(() => { - const f: Filter = { until } - const searchParams = new URLSearchParams(window.location.search) - const search = searchParams.get('s') - if (search) { - f.search = search - } - return f - }, [until]) + const filter = { until, search } const urls = useMemo(() => { return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls }, [relayUrls, searchableRelayUrls, filter]) - const title = useMemo(() => { - return filter.search ? `${t('Search')}: ${filter.search}` : t('All users') - }, [filter]) useEffect(() => { if (!hasMore) return @@ -80,15 +130,11 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { } return ( - -
- {Array.from(pubkeySet).map((pubkey, index) => ( - - ))} - {hasMore &&
} -
- +
+ {Array.from(pubkeySet).map((pubkey, index) => ( + + ))} + {hasMore &&
} +
) -}) -ProfileListPage.displayName = 'ProfileListPage' -export default ProfileListPage +}