From 4a946e4ab0a219e93899f68d6831221713d037bd Mon Sep 17 00:00:00 2001 From: codytseng Date: Tue, 12 Aug 2025 11:05:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NormalFeed/index.tsx | 144 ++--------- src/components/NoteList/index.tsx | 231 +++++++++++++----- .../primary/NoteListPage/FollowingFeed.tsx | 6 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 6 +- src/pages/secondary/NoteListPage/index.tsx | 8 +- .../secondary/ProfilePage/ProfileFeed.tsx | 99 ++++++++ src/pages/secondary/ProfilePage/index.tsx | 12 +- src/services/client.service.ts | 8 +- src/types/index.d.ts | 7 +- 9 files changed, 310 insertions(+), 211 deletions(-) create mode 100644 src/pages/secondary/ProfilePage/ProfileFeed.tsx diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 1a356092..8c528b0c 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -1,135 +1,28 @@ import NoteList, { TNoteListRef } from '@/components/NoteList' import Tabs from '@/components/Tabs' -import { ExtendedKind } from '@/constants' -import { isReplyNoteEvent } from '@/lib/event' -import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' -import client from '@/services/client.service' import storage from '@/services/local-storage.service' -import { TNormalFeedSubRequest, TNoteListMode } from '@/types' -import dayjs from 'dayjs' -import { Event, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' - -const LIMIT = 100 -const ALGO_LIMIT = 500 -const KINDS = [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Highlights, - kinds.LongFormArticle, - ExtendedKind.COMMENT, - ExtendedKind.POLL, - ExtendedKind.VOICE, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.PICTURE -] +import { TFeedSubRequest, TNoteListMode } from '@/types' +import { useRef, useState } from 'react' export default function NormalFeed({ subRequests, - areAlgoRelays = false + areAlgoRelays = false, + isMainFeed = false }: { - subRequests: TNormalFeedSubRequest[] + subRequests: TFeedSubRequest[] areAlgoRelays?: boolean + isMainFeed?: boolean }) { - const { startLogin } = useNostr() - const { isUserTrusted, hideUntrustedNotes } = useUserTrust() + const { hideUntrustedNotes } = useUserTrust() const [listMode, setListMode] = useState(() => storage.getNoteListMode()) - const [events, setEvents] = useState([]) - const [newEvents, setNewEvents] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [loading, setLoading] = useState(true) - const [timelineKey, setTimelineKey] = useState(undefined) - const [refreshCount, setRefreshCount] = useState(0) const noteListRef = useRef(null) - const filteredNewEvents = useMemo(() => { - return newEvents.filter((event: Event) => { - return ( - (listMode !== 'posts' || !isReplyNoteEvent(event)) && - (!hideUntrustedNotes || isUserTrusted(event.pubkey)) - ) - }) - }, [newEvents, listMode, hideUntrustedNotes]) - - useEffect(() => { - if (!subRequests.length) return - - async function init() { - setLoading(true) - setEvents([]) - setNewEvents([]) - setHasMore(true) - - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests.map(({ urls, filter }) => ({ - urls, - filter: { - ...filter, - kinds: KINDS, - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT - } - })), - { - onEvents: (events, eosed) => { - if (events.length > 0) { - setEvents(events) - } - if (areAlgoRelays) { - setHasMore(false) - } - if (eosed) { - setLoading(false) - setHasMore(events.length > 0) - } - }, - onNew: (event) => { - setNewEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) - } - }, - { - startLogin, - needSort: !areAlgoRelays - } - ) - setTimelineKey(timelineKey) - return closer - } - - const promise = init() - return () => { - promise.then((closer) => closer()) - } - }, [JSON.stringify(subRequests), refreshCount]) - - const loadMore = async () => { - if (!timelineKey || areAlgoRelays) return - setLoading(true) - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - setLoading(false) - if (newEvents.length === 0) { - setHasMore(false) - return - } - setEvents((oldEvents) => [...oldEvents, ...newEvents]) - } - - const showNewEvents = () => { - setEvents((oldEvents) => [...newEvents, ...oldEvents]) - setNewEvents([]) - setTimeout(() => { - noteListRef.current?.scrollToTop() - }, 0) - } const handleListModeChange = (mode: TNoteListMode) => { setListMode(mode) - storage.setNoteListMode(mode) + if (isMainFeed) { + storage.setNoteListMode(mode) + } setTimeout(() => { noteListRef.current?.scrollToTop() }, 0) @@ -149,19 +42,10 @@ export default function NormalFeed({ /> - (listMode !== 'posts' || !isReplyNoteEvent(event)) && - (!hideUntrustedNotes || isUserTrusted(event.pubkey)) - )} - hasMore={hasMore} - loading={loading} - loadMore={loadMore} - onRefresh={() => { - setRefreshCount((count) => count + 1) - }} - newEvents={filteredNewEvents} - showNewEvents={showNewEvents} + subRequests={subRequests} + hideReplies={listMode === 'posts'} + hideUntrustedNotes={hideUntrustedNotes} + areAlgoRelays={areAlgoRelays} /> ) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 02cc0e23..db6d89ff 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,53 +1,162 @@ import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { Event } from 'nostr-tools' -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { ExtendedKind } from '@/constants' +import { + getReplaceableCoordinateFromEvent, + isReplaceableEvent, + isReplyNoteEvent +} from '@/lib/event' +import { useMuteList } from '@/providers/MuteListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useUserTrust } from '@/providers/UserTrustProvider' +import client from '@/services/client.service' +import { TFeedSubRequest } from '@/types' +import dayjs from 'dayjs' +import { Event, kinds } from 'nostr-tools' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' +const LIMIT = 100 +const ALGO_LIMIT = 500 +const KINDS = [ + kinds.ShortTextNote, + kinds.Repost, + kinds.Highlights, + kinds.LongFormArticle, + ExtendedKind.COMMENT, + ExtendedKind.POLL, + ExtendedKind.VOICE, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.PICTURE +] + const SHOW_COUNT = 10 const NoteList = forwardRef( ( { - events, - hasMore, - loading, - loadMore, - newEvents = [], - showNewEvents, - onRefresh, - filterMutedNotes + subRequests, + filterMutedNotes = true, + hideReplies = false, + hideUntrustedNotes = false, + areAlgoRelays = false }: { - events: Event[] - hasMore: boolean - loading: boolean - loadMore?: () => void - newEvents?: Event[] - showNewEvents?: () => void - onRefresh?: () => void + subRequests: TFeedSubRequest[] filterMutedNotes?: boolean + hideReplies?: boolean + hideUntrustedNotes?: boolean + areAlgoRelays?: boolean }, ref ) => { const { t } = useTranslation() + const { startLogin } = useNostr() + const { isUserTrusted } = useUserTrust() + const { mutePubkeys } = useMuteList() + const [events, setEvents] = useState([]) + const [newEvents, setNewEvents] = useState([]) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(true) + const [timelineKey, setTimelineKey] = useState(undefined) + const [refreshCount, setRefreshCount] = useState(0) const [showCount, setShowCount] = useState(SHOW_COUNT) const bottomRef = useRef(null) const topRef = useRef(null) - useImperativeHandle( - ref, - () => ({ - scrollToTop: () => { - topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }), - [] - ) + const filteredEvents = useMemo(() => { + const idSet = new Set() - const idSet = new Set() + return events.slice(0, showCount).filter((evt) => { + if (hideReplies && isReplyNoteEvent(evt)) return false + if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false + + const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id + if (idSet.has(id)) { + return false + } + idSet.add(id) + return true + }) + }, [events, hideReplies, hideUntrustedNotes, showCount]) + + const filteredNewEvents = useMemo(() => { + const idSet = new Set() + + return newEvents.filter((event: Event) => { + if (hideReplies && isReplyNoteEvent(event)) return false + if (hideUntrustedNotes && !isUserTrusted(event.pubkey)) return false + if (filterMutedNotes && mutePubkeys.includes(event.pubkey)) return false + + const id = isReplaceableEvent(event.kind) + ? getReplaceableCoordinateFromEvent(event) + : event.id + if (idSet.has(id)) { + return false + } + idSet.add(id) + return true + }) + }, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys]) + + const scrollToTop = () => { + topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + useImperativeHandle(ref, () => ({ scrollToTop }), []) + + useEffect(() => { + if (!subRequests.length) return + + async function init() { + setLoading(true) + setEvents([]) + setNewEvents([]) + setHasMore(true) + + const { closer, timelineKey } = await client.subscribeTimeline( + subRequests.map(({ urls, filter }) => ({ + urls, + filter: { + ...filter, + kinds: KINDS, + limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + } + })), + { + onEvents: (events, eosed) => { + if (events.length > 0) { + setEvents(events) + } + if (areAlgoRelays) { + setHasMore(false) + } + if (eosed) { + setLoading(false) + setHasMore(events.length > 0) + } + }, + onNew: (event) => { + setNewEvents((oldEvents) => + [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + ) + } + }, + { + startLogin, + needSort: !areAlgoRelays + } + ) + setTimelineKey(timelineKey) + return closer + } + + const promise = init() + return () => { + promise.then((closer) => closer()) + } + }, [JSON.stringify(subRequests), refreshCount]) useEffect(() => { const options = { @@ -56,22 +165,33 @@ const NoteList = forwardRef( threshold: 0.1 } - const _loadMore = async () => { + const loadMore = async () => { if (showCount < events.length) { setShowCount((prev) => prev + SHOW_COUNT) // preload more - if (events.length - showCount > 2 * SHOW_COUNT) { + if (events.length - showCount > SHOW_COUNT * 5) { return } } - if (loading || !hasMore) return - loadMore?.() + if (!timelineKey || loading || !hasMore) return + setLoading(true) + const newEvents = await client.loadMoreTimeline( + timelineKey, + events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), + LIMIT + ) + setLoading(false) + if (newEvents.length === 0) { + setHasMore(false) + return + } + setEvents((oldEvents) => [...oldEvents, ...newEvents]) } const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasMore) { - _loadMore() + loadMore() } }, options) @@ -86,39 +206,38 @@ const NoteList = forwardRef( observerInstance.unobserve(currentBottomRef) } } - }, [loading, hasMore, events, loadMore]) + }, [loading, hasMore, events, showCount, timelineKey]) + + const showNewEvents = () => { + setEvents((oldEvents) => [...newEvents, ...oldEvents]) + setNewEvents([]) + setTimeout(() => { + scrollToTop() + }, 0) + } return (
- {newEvents.length > 0 && } + {filteredNewEvents.length > 0 && ( + + )}
{ - onRefresh?.() + setRefreshCount((count) => count + 1) await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" >
- {events.slice(0, showCount).map((event) => { - const id = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - - if (idSet.has(id)) { - return null - } - - idSet.add(id) - return ( - - ) - })} + {filteredEvents.map((event) => ( + + ))} {hasMore || loading ? (
@@ -129,7 +248,7 @@ const NoteList = forwardRef(
) : (
-
diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index 45c13d4c..0922cd67 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -2,13 +2,13 @@ import NormalFeed from '@/components/NormalFeed' import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { TNormalFeedSubRequest } from '@/types' +import { TFeedSubRequest } from '@/types' import { useEffect, useState } from 'react' export default function FollowingFeed() { const { pubkey } = useNostr() const { feedInfo } = useFeed() - const [subRequests, setSubRequests] = useState([]) + const [subRequests, setSubRequests] = useState([]) useEffect(() => { async function init() { @@ -24,5 +24,5 @@ export default function FollowingFeed() { init() }, [feedInfo.feedType, pubkey]) - return + return } diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 6e436896..a9ede5ed 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -31,6 +31,10 @@ export default function RelaysFeed() { } return ( - + ) } diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 410029f9..67e7ce26 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -8,7 +8,7 @@ import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { TNormalFeedSubRequest } from '@/types' +import { TFeedSubRequest } from '@/types' import { UserRound } from 'lucide-react' import React, { forwardRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -29,7 +29,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { } | null >(null) - const [subRequests, setSubRequests] = useState([]) + const [subRequests, setSubRequests] = useState([]) useEffect(() => { const init = async () => { @@ -94,6 +94,8 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { {pubkeys.length.toLocaleString()} ) + } else { + setSubRequests([]) } return } @@ -102,7 +104,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { }, []) let content: React.ReactNode = null - if (data?.type === 'domain' && setSubRequests.length === 0) { + if (data?.type === 'domain' && subRequests.length === 0) { content = (
diff --git a/src/pages/secondary/ProfilePage/ProfileFeed.tsx b/src/pages/secondary/ProfilePage/ProfileFeed.tsx new file mode 100644 index 00000000..793da0d9 --- /dev/null +++ b/src/pages/secondary/ProfilePage/ProfileFeed.tsx @@ -0,0 +1,99 @@ +import NoteList, { TNoteListRef } from '@/components/NoteList' +import Tabs from '@/components/Tabs' +import { BIG_RELAY_URLS } from '@/constants' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import storage from '@/services/local-storage.service' +import { TFeedSubRequest, TNoteListMode } from '@/types' +import { useEffect, useMemo, useRef, useState } from 'react' + +export default function ProfileFeed({ + pubkey, + topSpace = 0 +}: { + pubkey: string + topSpace?: number +}) { + const { pubkey: myPubkey } = useNostr() + const [listMode, setListMode] = useState(() => storage.getNoteListMode()) + const noteListRef = useRef(null) + const [subRequests, setSubRequests] = useState([]) + const tabs = useMemo(() => { + const _tabs = [ + { value: 'posts', label: 'Notes' }, + { value: 'postsAndReplies', label: 'Replies' } + ] + + if (myPubkey && myPubkey !== pubkey) { + _tabs.push({ value: 'you', label: 'YouTabName' }) + } + + return _tabs + }, [myPubkey, pubkey]) + + useEffect(() => { + const init = async () => { + if (listMode === 'you') { + if (!myPubkey) { + setSubRequests([]) + return + } + + const [relayList, myRelayList] = await Promise.all([ + client.fetchRelayList(pubkey), + client.fetchRelayList(myPubkey) + ]) + + setSubRequests([ + { + urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), + filter: { + authors: [myPubkey], + '#p': [pubkey] + } + }, + { + urls: relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), + filter: { + authors: [pubkey], + '#p': [myPubkey] + } + } + ]) + return + } + + const relayList = await client.fetchRelayList(pubkey) + setSubRequests([ + { + urls: relayList.write.concat(BIG_RELAY_URLS).slice(0, 8), + filter: { + authors: [pubkey] + } + } + ]) + } + init() + }, [pubkey, listMode]) + + const handleListModeChange = (mode: TNoteListMode) => { + setListMode(mode) + setTimeout(() => { + noteListRef.current?.scrollToTop() + }, 0) + } + + return ( + <> + { + handleListModeChange(listMode as TNoteListMode) + }} + threshold={Math.max(800, topSpace)} + /> + + + ) +} diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index bd127179..a2d600e8 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -1,13 +1,12 @@ import Collapsible from '@/components/Collapsible' import FollowButton from '@/components/FollowButton' import Nip05 from '@/components/Nip05' -import Feed from '@/components/Feed' +import NpubQrCode from '@/components/NpubQrCode' import ProfileAbout from '@/components/ProfileAbout' import ProfileBanner from '@/components/ProfileBanner' import ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' -import NpubQrCode from '@/components/NpubQrCode' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -25,6 +24,7 @@ import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' import FollowedBy from './FollowedBy' import Followings from './Followings' +import ProfileFeed from './ProfileFeed' import Relays from './Relays' const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => { @@ -193,13 +193,7 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
- + ) }) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0b7c0871..b352c085 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -10,7 +10,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' -import { ISigner, TProfile, TRelayList } from '@/types' +import { ISigner, TProfile, TRelayList, TSubRequestFilter } from '@/types' import { sha256 } from '@noble/hashes/sha2' import DataLoader from 'dataloader' import dayjs from 'dayjs' @@ -43,7 +43,7 @@ class ClientService extends EventTarget { string, | { refs: TTimelineRef[] - filter: Omit & { limit: number } + filter: TSubRequestFilter urls: string[] } | string[] @@ -178,7 +178,7 @@ class ClientService extends EventTarget { } async subscribeTimeline( - subRequests: { urls: string[]; filter: Omit & { limit: number } }[], + subRequests: { urls: string[]; filter: TSubRequestFilter }[], { onEvents, onNew @@ -407,7 +407,7 @@ class ClientService extends EventTarget { private async _subscribeTimeline( urls: string[], - filter: Omit & { limit: number }, // filter with limit, + filter: TSubRequestFilter, // filter with limit, { onEvents, onNew diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 787978d4..6eee69be 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,12 +1,9 @@ import { Event, VerifiedEvent, Filter } from 'nostr-tools' import { POLL_TYPE } from './constants' -export type TSubRequest = { - urls: string[] - filter: Omit & { limit: number } -} +export type TSubRequestFilter = Omit & { limit: number } -export type TNormalFeedSubRequest = { +export type TFeedSubRequest = { urls: string[] filter: Omit }