From a6c41d8d3fcfe44943b8dc8482efe2843c74108c Mon Sep 17 00:00:00 2001 From: codytseng Date: Mon, 1 Dec 2025 00:16:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=92=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NormalFeed/index.tsx | 2 + src/components/UserAggregationList/index.tsx | 609 ++++++++++--------- 2 files changed, 335 insertions(+), 276 deletions(-) diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 22fd2b1d..d59ebf14 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -92,6 +92,8 @@ export default function NormalFeed({ showKinds={temporaryShowKinds} subRequests={subRequests} filterFn={filterFn} + areAlgoRelays={areAlgoRelays} + showRelayCloseReason={showRelayCloseReason} /> ) : ( boolean filterMutedNotes?: boolean + areAlgoRelays?: boolean + showRelayCloseReason?: boolean } ->(({ subRequests, showKinds, filterFn, filterMutedNotes = true }, ref) => { - const { t } = useTranslation() - const { startLogin } = useNostr() - const { push } = useSecondaryPage() - const { hideUntrustedNotes, isUserTrusted } = useUserTrust() - const { mutePubkeySet } = useMuteList() - const { pinnedPubkeySet } = usePinnedUsers() - const { hideContentMentioningMutedUsers } = useContentPolicy() - const { isEventDeleted } = useDeletedEvent() - const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) - const [events, setEvents] = useState([]) - const [timelineKey, setTimelineKey] = useState(undefined) - const [loading, setLoading] = useState(true) - const [showLoadingBar, setShowLoadingBar] = useState(true) - const [refreshCount, setRefreshCount] = useState(0) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const [hasMore, setHasMore] = useState(true) - const supportTouch = useMemo(() => isTouchDevice(), []) - const feedId = useMemo(() => { - return userAggregationService.getFeedId(subRequests, showKinds) - }, [JSON.stringify(subRequests), JSON.stringify(showKinds)]) - const bottomRef = useRef(null) - const topRef = useRef(null) +>( + ( + { + subRequests, + showKinds, + filterFn, + filterMutedNotes = true, + areAlgoRelays = false, + showRelayCloseReason = false + }, + ref + ) => { + const { t } = useTranslation() + const { startLogin } = useNostr() + const { push } = useSecondaryPage() + const { hideUntrustedNotes, isUserTrusted } = useUserTrust() + const { mutePubkeySet } = useMuteList() + const { pinnedPubkeySet } = usePinnedUsers() + const { hideContentMentioningMutedUsers } = useContentPolicy() + const { isEventDeleted } = useDeletedEvent() + const { addReplies } = useReply() + const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) + const [events, setEvents] = useState([]) + const [newEvents, setNewEvents] = useState([]) + const [timelineKey, setTimelineKey] = useState(undefined) + const [loading, setLoading] = useState(true) + const [showLoadingBar, setShowLoadingBar] = useState(true) + const [refreshCount, setRefreshCount] = useState(0) + const [showCount, setShowCount] = useState(SHOW_COUNT) + const [hasMore, setHasMore] = useState(true) + const supportTouch = useMemo(() => isTouchDevice(), []) + const feedId = useMemo(() => { + return userAggregationService.getFeedId(subRequests, showKinds) + }, [JSON.stringify(subRequests), JSON.stringify(showKinds)]) + const bottomRef = useRef(null) + const topRef = useRef(null) - const scrollToTop = (behavior: ScrollBehavior = 'instant') => { - setTimeout(() => { - topRef.current?.scrollIntoView({ behavior, block: 'start' }) - }, 20) - } - - const refresh = () => { - scrollToTop() - setTimeout(() => { - setRefreshCount((count) => count + 1) - }, 500) - } - - useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) - - useEffect(() => { - return () => { - userAggregationService.clearAggregations(feedId) + const scrollToTop = (behavior: ScrollBehavior = 'instant') => { + setTimeout(() => { + topRef.current?.scrollIntoView({ behavior, block: 'start' }) + }, 20) } - }, [feedId]) - useEffect(() => { - if (!subRequests.length) return + const refresh = () => { + scrollToTop() + setTimeout(() => { + setRefreshCount((count) => count + 1) + }, 500) + } - setSince(dayjs().subtract(1, 'day').unix()) - setHasMore(true) + useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) - async function init() { - setLoading(true) - setEvents([]) - - if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { - setLoading(false) - return () => {} + useEffect(() => { + return () => { + userAggregationService.clearAggregations(feedId) } + }, [feedId]) - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests.map(({ urls, filter }) => ({ - urls, - filter: { - kinds: showKinds ?? [], - ...filter, - limit: LIMIT - } - })), - { - onEvents: (events, eosed) => { - if (events.length > 0) { - setEvents(events) + useEffect(() => { + if (!subRequests.length) return + + setSince(dayjs().subtract(1, 'day').unix()) + setHasMore(true) + + async function init() { + setLoading(true) + setEvents([]) + setNewEvents([]) + setHasMore(true) + + if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { + setLoading(false) + setHasMore(false) + return () => {} + } + + const { closer, timelineKey } = await client.subscribeTimeline( + subRequests.map(({ urls, filter }) => ({ + urls, + filter: { + kinds: showKinds ?? [], + ...filter, + limit: LIMIT } - if (eosed) { - setLoading(false) - if (events.length === 0) { + })), + { + onEvents: (events, eosed) => { + if (events.length > 0) { + setEvents(events) + } + if (areAlgoRelays) { setHasMore(false) } + if (eosed) { + setLoading(false) + setHasMore(events.length > 0) + addReplies(events) + } + }, + onNew: (event) => { + setNewEvents((oldEvents) => + [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + ) + addReplies([event]) + }, + onClose: (url, reason) => { + if (!showRelayCloseReason) return + // ignore reasons from nostr-tools + if ( + [ + 'closed by caller', + 'relay connection errored', + 'relay connection closed', + 'pingpong timed out', + 'relay connection closed by us' + ].includes(reason) + ) { + return + } + + toast.error(`${url}: ${reason}`) } }, - onNew: (event) => { - setEvents((oldEvents) => { - const newEvents = oldEvents.some((e) => e.id === event.id) - ? oldEvents - : [event, ...oldEvents] - return newEvents - }) + { + startLogin, + needSort: !areAlgoRelays } - }, - { - startLogin, - needSort: true + ) + setTimelineKey(timelineKey) + + return closer + } + + const promise = init() + return () => { + promise.then((closer) => closer()) + } + }, [feedId, refreshCount]) + + useEffect(() => { + if (loading || !hasMore || !timelineKey || !events.length) { + return + } + + const until = events[events.length - 1].created_at - 1 + if (until < since) { + return + } + + setLoading(true) + client.loadMoreTimeline(timelineKey, until, LIMIT).then((moreEvents) => { + if (moreEvents.length === 0) { + setHasMore(false) + setLoading(false) + return } - ) - setTimelineKey(timelineKey) - - return closer - } - - const promise = init() - return () => { - promise.then((closer) => closer()) - } - }, [feedId, refreshCount]) - - useEffect(() => { - if (loading || !hasMore || !timelineKey || !events.length) { - return - } - - const until = events[events.length - 1].created_at - 1 - if (until < since) { - return - } - - setLoading(true) - client.loadMoreTimeline(timelineKey, until, LIMIT).then((moreEvents) => { - if (moreEvents.length === 0) { - setHasMore(false) + setEvents((oldEvents) => [...oldEvents, ...moreEvents]) setLoading(false) + }) + }, [loading, timelineKey, events, since, hasMore]) + + useEffect(() => { + if (loading) { + setShowLoadingBar(true) return } - setEvents((oldEvents) => [...oldEvents, ...moreEvents]) - setLoading(false) - }) - }, [loading, timelineKey, events, since, hasMore]) - useEffect(() => { - if (loading) { - setShowLoadingBar(true) - return - } + const timeout = setTimeout(() => { + setShowLoadingBar(false) + }, 1000) - const timeout = setTimeout(() => { - setShowLoadingBar(false) - }, 1000) + return () => clearTimeout(timeout) + }, [loading]) - return () => clearTimeout(timeout) - }, [loading]) + const shouldHideEvent = useCallback( + (evt: Event) => { + if (isEventDeleted(evt)) return true + if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true + if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true + if ( + filterMutedNotes && + hideContentMentioningMutedUsers && + isMentioningMutedUsers(evt, mutePubkeySet) + ) { + return true + } + if (filterFn && !filterFn(evt)) { + return true + } - const shouldHideEvent = useCallback( - (evt: Event) => { - if (isEventDeleted(evt)) return true - if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true - if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true - if ( - filterMutedNotes && - hideContentMentioningMutedUsers && - isMentioningMutedUsers(evt, mutePubkeySet) - ) { - return true + return false + }, + [hideUntrustedNotes, mutePubkeySet, isEventDeleted, filterFn] + ) + + const lastXDays = useMemo(() => { + return dayjs().diff(dayjs.unix(since), 'day') + }, [since]) + + const filteredEvents = useMemo(() => { + return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) + }, [events, since, shouldHideEvent]) + + const filteredNewEvents = useMemo(() => { + return newEvents.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) + }, [newEvents, since, shouldHideEvent]) + + const aggregations = useMemo(() => { + const aggs = userAggregationService.aggregateByUser(filteredEvents) + userAggregationService.saveAggregations(feedId, aggs) + return aggs + }, [feedId, filteredEvents]) + + const pinnedAggregations = useMemo(() => { + return aggregations.filter((agg) => pinnedPubkeySet.has(agg.pubkey)) + }, [aggregations, pinnedPubkeySet]) + + const normalAggregations = useMemo(() => { + return aggregations.filter((agg) => !pinnedPubkeySet.has(agg.pubkey)) + }, [aggregations, pinnedPubkeySet]) + + const displayedNormalAggregations = useMemo(() => { + return normalAggregations.slice(0, showCount) + }, [normalAggregations, showCount]) + + const hasMoreToDisplay = useMemo(() => { + return normalAggregations.length > displayedNormalAggregations.length + }, [normalAggregations, displayedNormalAggregations]) + + useEffect(() => { + const options = { + root: null, + rootMargin: '10px', + threshold: 1 } - if (filterFn && !filterFn(evt)) { - return true + if (!hasMoreToDisplay) return + + const observerInstance = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + setShowCount((count) => count + SHOW_COUNT) + } + }, options) + + const currentBottomRef = bottomRef.current + if (currentBottomRef) { + observerInstance.observe(currentBottomRef) } - return false - }, - [hideUntrustedNotes, mutePubkeySet, isEventDeleted, filterFn] - ) - - const lastXDays = useMemo(() => { - return dayjs().diff(dayjs.unix(since), 'day') - }, [since]) - - const filteredEvents = useMemo(() => { - return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) - }, [events, since, shouldHideEvent]) - - const aggregations = useMemo(() => { - const aggs = userAggregationService.aggregateByUser(filteredEvents) - userAggregationService.saveAggregations(feedId, aggs) - return aggs - }, [feedId, filteredEvents]) - - const pinnedAggregations = useMemo(() => { - return aggregations.filter((agg) => pinnedPubkeySet.has(agg.pubkey)) - }, [aggregations, pinnedPubkeySet]) - - const normalAggregations = useMemo(() => { - return aggregations.filter((agg) => !pinnedPubkeySet.has(agg.pubkey)) - }, [aggregations, pinnedPubkeySet]) - - const displayedNormalAggregations = useMemo(() => { - return normalAggregations.slice(0, showCount) - }, [normalAggregations, showCount]) - - const hasMoreToDisplay = useMemo(() => { - return normalAggregations.length > displayedNormalAggregations.length - }, [normalAggregations, displayedNormalAggregations]) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 1 - } - if (!hasMoreToDisplay) return - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - setShowCount((count) => count + SHOW_COUNT) + return () => { + if (observerInstance && currentBottomRef) { + observerInstance.unobserve(currentBottomRef) + } } - }, options) + }, [hasMoreToDisplay]) - const currentBottomRef = bottomRef.current - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } + const handleViewUser = (agg: TUserAggregation) => { + // Mark as viewed when user clicks + userAggregationService.markAsViewed(feedId, agg.pubkey) - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) + if (agg.count === 1) { + const evt = agg.events[0] + if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { + push(toNote(agg.events[0])) + return + } } - } - }, [hasMoreToDisplay]) - const handleViewUser = (agg: TUserAggregation) => { - // Mark as viewed when user clicks - userAggregationService.markAsViewed(feedId, agg.pubkey) - - if (agg.count === 1) { - const evt = agg.events[0] - if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { - push(toNote(agg.events[0])) - return - } + push(toUserAggregationDetail(feedId, agg.pubkey)) } - push(toUserAggregationDetail(feedId, agg.pubkey)) - } + const handleLoadEarlier = () => { + setSince((prevSince) => dayjs.unix(prevSince).subtract(1, 'day').unix()) + setShowCount(SHOW_COUNT) + } - const handleLoadEarlier = () => { - setSince((prevSince) => dayjs.unix(prevSince).subtract(1, 'day').unix()) - setShowCount(SHOW_COUNT) - } + const showNewEvents = () => { + setEvents((oldEvents) => [...newEvents, ...oldEvents]) + setNewEvents([]) + setTimeout(() => { + scrollToTop('smooth') + }, 0) + } - const list = ( -
- {pinnedAggregations.map((agg) => ( - handleViewUser(agg)} - /> - ))} + const list = ( +
+ {pinnedAggregations.map((agg) => ( + handleViewUser(agg)} + /> + ))} - {normalAggregations.map((agg) => ( - handleViewUser(agg)} - /> - ))} + {normalAggregations.map((agg) => ( + handleViewUser(agg)} + /> + ))} - {loading || hasMoreToDisplay ? ( -
- -
- ) : aggregations.length === 0 ? ( -
- +
+ ) : ( +
{t('no more notes')}
+ )} +
+ ) + + return ( +
+
+ {showLoadingBar && } +
+
+ + {lastXDays === 1 + ? t('Last 24 hours') + : t('Last {{count}} days', { count: lastXDays })} + + · + + {filteredEvents.length} {t('notes')} + +
+
- ) : ( -
{t('no more notes')}
- )} -
- ) - - return ( -
-
- {showLoadingBar && } -
-
- - {lastXDays === 1 ? t('Last 24 hours') : t('Last {{count}} days', { count: lastXDays })} - - · - - {filteredEvents.length} {t('notes')} - -
- + {supportTouch ? ( + { + refresh() + await new Promise((resolve) => setTimeout(resolve, 1000)) + }} + pullingContent="" + > + {list} + + ) : ( + list + )} +
+ {filteredNewEvents.length > 0 && ( + + )}
- {supportTouch ? ( - { - refresh() - await new Promise((resolve) => setTimeout(resolve, 1000)) - }} - pullingContent="" - > - {list} - - ) : ( - list - )} -
- ) -}) + ) + } +) UserAggregationList.displayName = 'UserAggregationList' export default UserAggregationList