From e78e2c2078ee089714e9c6ed250123e6c7c2ee54 Mon Sep 17 00:00:00 2001 From: codytseng Date: Mon, 11 Aug 2025 22:34:48 +0800 Subject: [PATCH] refactor: note list component --- src/components/Feed/index.tsx | 303 +++++++++++ src/components/NormalFeed/index.tsx | 168 ++++++ src/components/NoteList/index.tsx | 477 +++++------------- src/components/Tabs/index.tsx | 2 +- .../primary/NoteListPage/FollowingFeed.tsx | 28 + src/pages/primary/NoteListPage/RelaysFeed.tsx | 36 ++ src/pages/primary/NoteListPage/index.tsx | 25 +- src/pages/secondary/NoteListPage/index.tsx | 57 ++- src/pages/secondary/ProfilePage/index.tsx | 4 +- src/pages/secondary/RelayPage/index.tsx | 10 +- src/providers/FeedProvider.tsx | 13 +- src/services/client.service.ts | 50 ++ src/types/index.d.ts | 12 +- 13 files changed, 766 insertions(+), 419 deletions(-) create mode 100644 src/components/Feed/index.tsx create mode 100644 src/components/NormalFeed/index.tsx create mode 100644 src/pages/primary/NoteListPage/FollowingFeed.tsx create mode 100644 src/pages/primary/NoteListPage/RelaysFeed.tsx diff --git a/src/components/Feed/index.tsx b/src/components/Feed/index.tsx new file mode 100644 index 00000000..be8942ec --- /dev/null +++ b/src/components/Feed/index.tsx @@ -0,0 +1,303 @@ +import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { isReplyNoteEvent } from '@/lib/event' +import { checkAlgoRelay } from '@/lib/relay' +import { isSafari } from '@/lib/utils' +import { useMuteList } from '@/providers/MuteListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useUserTrust } from '@/providers/UserTrustProvider' +import client from '@/services/client.service' +import storage from '@/services/local-storage.service' +import relayInfoService from '@/services/relay-info.service' +import { TNoteListMode } from '@/types' +import dayjs from 'dayjs' +import { Event, Filter, kinds } from 'nostr-tools' +import { useEffect, useMemo, useRef, useState } from 'react' +import NoteList, { TNoteListRef } from '../NoteList' +import Tabs from '../Tabs' + +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 +] + +export default function Feed({ + relayUrls = [], + filter = {}, + author, + className, + filterMutedNotes = true, + needCheckAlgoRelay = false, + isMainFeed = false, + topSpace = 0, + skipTrustCheck = false +}: { + relayUrls?: string[] + filter?: Filter + author?: string + className?: string + filterMutedNotes?: boolean + needCheckAlgoRelay?: boolean + isMainFeed?: boolean + topSpace?: number + skipTrustCheck?: boolean +}) { + const { pubkey, startLogin } = useNostr() + const { mutePubkeys } = useMuteList() + const [refreshCount, setRefreshCount] = useState(0) + const [timelineKey, setTimelineKey] = useState(undefined) + const [events, setEvents] = useState([]) + const [newEvents, setNewEvents] = useState([]) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(true) + const [listMode, setListMode] = useState(() => + isMainFeed ? storage.getNoteListMode() : 'posts' + ) + const [filterType, setFilterType] = useState>('posts') + const noteListRef = useRef(null) + const { isUserTrusted, hideUntrustedNotes } = useUserTrust() + const filteredNewEvents = useMemo(() => { + return newEvents.filter((event: Event) => { + return ( + (!filterMutedNotes || !mutePubkeys.includes(event.pubkey)) && + (listMode !== 'posts' || !isReplyNoteEvent(event)) && + (skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey)) + ) + }) + }, [newEvents, listMode, filterMutedNotes, mutePubkeys, hideUntrustedNotes]) + + useEffect(() => { + switch (listMode) { + case 'posts': + case 'postsAndReplies': + setFilterType('posts') + break + case 'you': + if (!pubkey || pubkey === author) { + setFilterType('posts') + } else { + setFilterType('you') + } + break + } + }, [listMode, pubkey]) + + useEffect(() => { + if (relayUrls.length === 0 && !filter.authors?.length && !author) return + + async function init() { + setLoading(true) + setEvents([]) + setNewEvents([]) + setHasMore(true) + + let areAlgoRelays = false + const subRequests: { + urls: string[] + filter: Omit & { limit: number } + }[] = [] + if (filterType === 'you' && author && pubkey && pubkey !== author) { + const [myRelayList, targetRelayList] = await Promise.all([ + client.fetchRelayList(pubkey), + client.fetchRelayList(author) + ]) + subRequests.push({ + urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), + filter: { + kinds: KINDS, + authors: [pubkey], + '#p': [author], + limit: LIMIT + } + }) + subRequests.push({ + urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), + filter: { + kinds: KINDS, + authors: [author], + '#p': [pubkey], + limit: LIMIT + } + }) + } else { + if (needCheckAlgoRelay) { + const relayInfos = await relayInfoService.getRelayInfos(relayUrls) + areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) + } + const _filter = { + ...filter, + kinds: KINDS, + limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + } + if (relayUrls.length === 0 && (_filter.authors?.length || author)) { + if (!_filter.authors?.length) { + _filter.authors = [author!] + } + + // If many websocket connections are initiated simultaneously, it will be + // very slow on Safari (for unknown reason) + if ((_filter.authors?.length ?? 0) > 5 && isSafari()) { + if (!pubkey) { + subRequests.push({ urls: BIG_RELAY_URLS, filter: _filter }) + } else { + const relayList = await client.fetchRelayList(pubkey) + const urls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 5) + subRequests.push({ urls, filter: _filter }) + } + } else { + const relayLists = await client.fetchRelayLists(_filter.authors) + const group: Record> = {} + relayLists.forEach((relayList, index) => { + relayList.write.slice(0, 4).forEach((url) => { + if (!group[url]) { + group[url] = new Set() + } + group[url].add(_filter.authors![index]) + }) + }) + + const relayCount = Object.keys(group).length + const coveredCount = new Map() + Object.entries(group) + .sort(([, a], [, b]) => b.size - a.size) + .forEach(([url, pubkeys]) => { + if ( + relayCount > 10 && + pubkeys.size < 10 && + Array.from(pubkeys).every((pubkey) => (coveredCount.get(pubkey) ?? 0) >= 2) + ) { + delete group[url] + } else { + pubkeys.forEach((pubkey) => { + coveredCount.set(pubkey, (coveredCount.get(pubkey) ?? 0) + 1) + }) + } + }) + + subRequests.push( + ...Object.entries(group).map(([url, authors]) => ({ + urls: [url], + filter: { ..._filter, authors: Array.from(authors) } + })) + ) + } + } else { + subRequests.push({ urls: relayUrls, filter: _filter }) + } + } + + const { closer, timelineKey } = await client.subscribeTimeline( + subRequests, + { + 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(relayUrls), filterType, refreshCount, JSON.stringify(filter)]) + + const loadMore = async () => { + if (!timelineKey) 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) + } + + return ( +
+ { + setListMode(listMode as TNoteListMode) + if (isMainFeed) { + storage.setNoteListMode(listMode as TNoteListMode) + } + setTimeout(() => { + noteListRef.current?.scrollToTop() + }, 0) + }} + threshold={Math.max(800, topSpace)} + /> + + (listMode !== 'posts' || !isReplyNoteEvent(event)) && + (skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey)) + )} + hasMore={hasMore} + loading={loading} + loadMore={loadMore} + filterMutedNotes={filterMutedNotes} + onRefresh={() => { + setRefreshCount((count) => count + 1) + }} + newEvents={filteredNewEvents} + showNewEvents={showNewEvents} + /> +
+ ) +} diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx new file mode 100644 index 00000000..1a356092 --- /dev/null +++ b/src/components/NormalFeed/index.tsx @@ -0,0 +1,168 @@ +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 +] + +export default function NormalFeed({ + subRequests, + areAlgoRelays = false +}: { + subRequests: TNormalFeedSubRequest[] + areAlgoRelays?: boolean +}) { + const { startLogin } = useNostr() + const { isUserTrusted, 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) + setTimeout(() => { + noteListRef.current?.scrollToTop() + }, 0) + } + + return ( + <> + { + handleListModeChange(listMode as TNoteListMode) + }} + /> + + (listMode !== 'posts' || !isReplyNoteEvent(event)) && + (!hideUntrustedNotes || isUserTrusted(event.pubkey)) + )} + hasMore={hasMore} + loading={loading} + loadMore={loadMore} + onRefresh={() => { + setRefreshCount((count) => count + 1) + }} + newEvents={filteredNewEvents} + showNewEvents={showNewEvents} + /> + + ) +} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 82d00405..02cc0e23 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,386 +1,149 @@ import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' -import { - getReplaceableCoordinateFromEvent, - isReplaceableEvent, - isReplyNoteEvent -} from '@/lib/event' -import { checkAlgoRelay } from '@/lib/relay' -import { isSafari } from '@/lib/utils' -import { useMuteList } from '@/providers/MuteListProvider' -import { useNostr } from '@/providers/NostrProvider' -import { useUserTrust } from '@/providers/UserTrustProvider' -import client from '@/services/client.service' -import storage from '@/services/local-storage.service' -import relayInfoService from '@/services/relay-info.service' -import { TNoteListMode } from '@/types' -import dayjs from 'dayjs' -import { Event, Filter, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { Event } from 'nostr-tools' +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' -import Tabs from '../Tabs' -const LIMIT = 100 -const ALGO_LIMIT = 500 const SHOW_COUNT = 10 -const KINDS = [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Highlights, - kinds.LongFormArticle, - ExtendedKind.COMMENT, - ExtendedKind.POLL, - ExtendedKind.VOICE, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.PICTURE -] -export default function NoteList({ - relayUrls = [], - filter = {}, - author, - className, - filterMutedNotes = true, - needCheckAlgoRelay = false, - isMainFeed = false, - topSpace = 0, - skipTrustCheck = false -}: { - relayUrls?: string[] - filter?: Filter - author?: string - className?: string - filterMutedNotes?: boolean - needCheckAlgoRelay?: boolean - isMainFeed?: boolean - topSpace?: number - skipTrustCheck?: boolean -}) { - const { t } = useTranslation() - const { pubkey, startLogin } = useNostr() - const { mutePubkeys } = useMuteList() - const [refreshCount, setRefreshCount] = useState(0) - const [timelineKey, setTimelineKey] = useState(undefined) - const [events, setEvents] = useState([]) - const [newEvents, setNewEvents] = useState([]) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const [hasMore, setHasMore] = useState(true) - const [loading, setLoading] = useState(true) - const [listMode, setListMode] = useState(() => - isMainFeed ? storage.getNoteListMode() : 'posts' - ) - const [filterType, setFilterType] = useState>('posts') - const bottomRef = useRef(null) - const topRef = useRef(null) - const { isUserTrusted, hideUntrustedNotes } = useUserTrust() - const filteredNewEvents = useMemo(() => { - return newEvents.filter((event: Event) => { - return ( - (!filterMutedNotes || !mutePubkeys.includes(event.pubkey)) && - (listMode !== 'posts' || !isReplyNoteEvent(event)) && - (skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey)) - ) - }) - }, [newEvents, listMode, filterMutedNotes, mutePubkeys, hideUntrustedNotes]) +const NoteList = forwardRef( + ( + { + events, + hasMore, + loading, + loadMore, + newEvents = [], + showNewEvents, + onRefresh, + filterMutedNotes + }: { + events: Event[] + hasMore: boolean + loading: boolean + loadMore?: () => void + newEvents?: Event[] + showNewEvents?: () => void + onRefresh?: () => void + filterMutedNotes?: boolean + }, + ref + ) => { + const { t } = useTranslation() + const [showCount, setShowCount] = useState(SHOW_COUNT) + const bottomRef = useRef(null) + const topRef = useRef(null) - useEffect(() => { - switch (listMode) { - case 'posts': - case 'postsAndReplies': - setFilterType('posts') - break - case 'you': - if (!pubkey || pubkey === author) { - setFilterType('posts') - } else { - setFilterType('you') + useImperativeHandle( + ref, + () => ({ + scrollToTop: () => { + topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } - break - } - }, [listMode, pubkey]) + }), + [] + ) - useEffect(() => { - if (relayUrls.length === 0 && !filter.authors?.length && !author) return + const idSet = new Set() - async function init() { - setLoading(true) - setEvents([]) - setNewEvents([]) - setHasMore(true) - - let areAlgoRelays = false - const subRequests: { - urls: string[] - filter: Omit & { limit: number } - }[] = [] - if (filterType === 'you' && author && pubkey && pubkey !== author) { - const [myRelayList, targetRelayList] = await Promise.all([ - client.fetchRelayList(pubkey), - client.fetchRelayList(author) - ]) - subRequests.push({ - urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), - filter: { - kinds: KINDS, - authors: [pubkey], - '#p': [author], - limit: LIMIT - } - }) - subRequests.push({ - urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), - filter: { - kinds: KINDS, - authors: [author], - '#p': [pubkey], - limit: LIMIT - } - }) - } else { - if (needCheckAlgoRelay) { - const relayInfos = await relayInfoService.getRelayInfos(relayUrls) - areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) - } - const _filter = { - ...filter, - kinds: KINDS, - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT - } - if (relayUrls.length === 0 && (_filter.authors?.length || author)) { - if (!_filter.authors?.length) { - _filter.authors = [author!] - } - - // If many websocket connections are initiated simultaneously, it will be - // very slow on Safari (for unknown reason) - if ((_filter.authors?.length ?? 0) > 5 && isSafari()) { - if (!pubkey) { - subRequests.push({ urls: BIG_RELAY_URLS, filter: _filter }) - } else { - const relayList = await client.fetchRelayList(pubkey) - const urls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 5) - subRequests.push({ urls, filter: _filter }) - } - } else { - const relayLists = await client.fetchRelayLists(_filter.authors) - const group: Record> = {} - relayLists.forEach((relayList, index) => { - relayList.write.slice(0, 4).forEach((url) => { - if (!group[url]) { - group[url] = new Set() - } - group[url].add(_filter.authors![index]) - }) - }) - - const relayCount = Object.keys(group).length - const coveredCount = new Map() - Object.entries(group) - .sort(([, a], [, b]) => b.size - a.size) - .forEach(([url, pubkeys]) => { - if ( - relayCount > 10 && - pubkeys.size < 10 && - Array.from(pubkeys).every((pubkey) => (coveredCount.get(pubkey) ?? 0) >= 2) - ) { - delete group[url] - } else { - pubkeys.forEach((pubkey) => { - coveredCount.set(pubkey, (coveredCount.get(pubkey) ?? 0) + 1) - }) - } - }) - - subRequests.push( - ...Object.entries(group).map(([url, authors]) => ({ - urls: [url], - filter: { ..._filter, authors: Array.from(authors) } - })) - ) - } - } else { - subRequests.push({ urls: relayUrls, filter: _filter }) - } + useEffect(() => { + const options = { + root: null, + rootMargin: '10px', + threshold: 0.1 } - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests, - { - 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) - ) + const _loadMore = async () => { + if (showCount < events.length) { + setShowCount((prev) => prev + SHOW_COUNT) + // preload more + if (events.length - showCount > 2 * SHOW_COUNT) { + return } - }, - { - startLogin, - needSort: !areAlgoRelays } - ) - setTimelineKey(timelineKey) - return closer - } - const promise = init() - return () => { - promise.then((closer) => closer()) - } - }, [JSON.stringify(relayUrls), filterType, refreshCount, JSON.stringify(filter)]) + if (loading || !hasMore) return + loadMore?.() + } - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 0.1 - } + const observerInstance = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + _loadMore() + } + }, options) - const loadMore = async () => { - if (showCount < events.length) { - setShowCount((prev) => prev + SHOW_COUNT) - // preload more - if (events.length - showCount > LIMIT / 2) { - return + const currentBottomRef = bottomRef.current + + if (currentBottomRef) { + observerInstance.observe(currentBottomRef) + } + + return () => { + if (observerInstance && currentBottomRef) { + observerInstance.unobserve(currentBottomRef) } } + }, [loading, hasMore, events, 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() - } - }, options) - - const currentBottomRef = bottomRef.current - - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } - - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [timelineKey, loading, hasMore, events, filterType, showCount]) - - const showNewEvents = () => { - setEvents((oldEvents) => [...newEvents, ...oldEvents]) - setNewEvents([]) - setTimeout(() => { - topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 0) - } - - const idSet = new Set() - - return ( -
- { - setListMode(listMode as TNoteListMode) - setShowCount(SHOW_COUNT) - if (isMainFeed) { - storage.setNoteListMode(listMode as TNoteListMode) - } - setTimeout(() => { - topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 0) - }} - threshold={Math.max(800, topSpace)} - /> - {filteredNewEvents.length > 0 && ( - - )} -
- { - setRefreshCount((count) => count + 1) - await new Promise((resolve) => setTimeout(resolve, 1000)) - }} - pullingContent="" - > -
- {events - .slice(0, showCount) - .filter((event: Event) => { + return ( +
+ {newEvents.length > 0 && } +
+ { + onRefresh?.() + 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 false + + if (idSet.has(id)) { + return null + } + idSet.add(id) return ( - (listMode !== 'posts' || !isReplyNoteEvent(event)) && - (skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey)) + ) - }) - .map((event) => ( - - ))} - {hasMore || loading ? ( -
- -
- ) : events.length ? ( -
- {t('no more notes')} -
- ) : ( -
- -
- )} -
-
-
-
- ) + })} + {hasMore || loading ? ( +
+ +
+ ) : events.length ? ( +
+ {t('no more notes')} +
+ ) : ( +
+ +
+ )} +
+ +
+
+ ) + } +) +NoteList.displayName = 'NoteList' +export default NoteList + +export type TNoteListRef = { + scrollToTop: () => void } diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 8456c6e2..67524f8f 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -26,7 +26,7 @@ export default function Tabs({ return (
threshold ? '-translate-y-[calc(100%+12rem)]' : '' )} > diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx new file mode 100644 index 00000000..45c13d4c --- /dev/null +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -0,0 +1,28 @@ +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 { useEffect, useState } from 'react' + +export default function FollowingFeed() { + const { pubkey } = useNostr() + const { feedInfo } = useFeed() + const [subRequests, setSubRequests] = useState([]) + + useEffect(() => { + async function init() { + if (feedInfo.feedType !== 'following' || !pubkey) { + setSubRequests([]) + return + } + + const followings = await client.fetchFollowings(pubkey) + setSubRequests(await client.generateSubRequestsForPubkeys(followings, pubkey)) + } + + init() + }, [feedInfo.feedType, pubkey]) + + return +} diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx new file mode 100644 index 00000000..6e436896 --- /dev/null +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -0,0 +1,36 @@ +import NormalFeed from '@/components/NormalFeed' +import { checkAlgoRelay } from '@/lib/relay' +import { useFeed } from '@/providers/FeedProvider' +import relayInfoService from '@/services/relay-info.service' +import { useEffect, useState } from 'react' + +export default function RelaysFeed() { + const { feedInfo, relayUrls } = useFeed() + const [isReady, setIsReady] = useState(false) + const [areAlgoRelays, setAreAlgoRelays] = useState(false) + + useEffect(() => { + const init = async () => { + const relayInfos = await relayInfoService.getRelayInfos(relayUrls) + setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) + setIsReady(true) + } + init() + }, [relayUrls]) + + if (!isReady) { + return null + } + + if ( + feedInfo.feedType !== 'relay' && + feedInfo.feedType !== 'relays' && + feedInfo.feedType !== 'temporary' + ) { + return null + } + + return ( + + ) +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 9cae9f3d..d1124a4a 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -1,5 +1,4 @@ import BookmarkList from '@/components/BookmarkList' -import NoteList from '@/components/NoteList' import PostEditor from '@/components/PostEditor' import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu' import { Button } from '@/components/ui/button' @@ -12,13 +11,15 @@ import { PencilLine } from 'lucide-react' import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import FeedButton from './FeedButton' +import FollowingFeed from './FollowingFeed' +import RelaysFeed from './RelaysFeed' import SearchButton from './SearchButton' const NoteListPage = forwardRef((_, ref) => { const { t } = useTranslation() const layoutRef = useRef(null) const { pubkey, checkLogin } = useNostr() - const { feedInfo, relayUrls, isReady, filter } = useFeed() + const { feedInfo, relayUrls, isReady } = useFeed() useImperativeHandle(ref, () => layoutRef.current) useEffect(() => { @@ -27,8 +28,10 @@ const NoteListPage = forwardRef((_, ref) => { } }, [JSON.stringify(relayUrls), feedInfo]) - let content =
{t('loading...')}
- if (feedInfo.feedType === 'following' && !pubkey) { + let content: React.ReactNode = null + if (!isReady) { + content =
{t('loading...')}
+ } else if (feedInfo.feedType === 'following' && !pubkey) { content = (
) const pubkeys = await fetchPubkeysFromDomain(domain) - console.log(domain, pubkeys) setData({ type: 'domain', - domain, - filter: { authors: pubkeys } + domain }) if (pubkeys.length) { + setSubRequests(await client.generateSubRequestsForPubkeys(pubkeys, pubkey)) setControls(
-
)} - ) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 48b685b2..b34e9d77 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -7,7 +7,7 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import relayInfoService from '@/services/relay-info.service' import { TFeedInfo, TFeedType } from '@/types' -import { Filter, kinds } from 'nostr-tools' +import { kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useRef, useState } from 'react' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useNostr } from './NostrProvider' @@ -16,7 +16,6 @@ type TFeedContext = { feedInfo: TFeedInfo relayUrls: string[] temporaryRelayUrls: string[] - filter: Filter isReady: boolean switchFeed: ( feedType: TFeedType, @@ -40,7 +39,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { const { relaySets, favoriteRelays } = useFavoriteRelays() const [relayUrls, setRelayUrls] = useState([]) const [temporaryRelayUrls, setTemporaryRelayUrls] = useState([]) - const [filter, setFilter] = useState({}) const [isReady, setIsReady] = useState(false) const [feedInfo, setFeedInfo] = useState({ feedType: 'relay', @@ -125,7 +123,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { setFeedInfo(newFeedInfo) feedInfoRef.current = newFeedInfo setRelayUrls([normalizedUrl]) - setFilter({}) storage.setFeedInfo(newFeedInfo, pubkey) setIsReady(true) @@ -158,7 +155,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { setFeedInfo(newFeedInfo) feedInfoRef.current = newFeedInfo setRelayUrls(relaySet.relayUrls) - setFilter({}) storage.setFeedInfo(newFeedInfo, pubkey) setIsReady(true) @@ -180,11 +176,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { feedInfoRef.current = newFeedInfo storage.setFeedInfo(newFeedInfo, pubkey) - const followings = await client.fetchFollowings(options.pubkey) setRelayUrls([]) - setFilter({ - authors: followings.includes(options.pubkey) ? followings : [...followings, options.pubkey] - }) setIsReady(true) return } @@ -200,7 +192,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { storage.setFeedInfo(newFeedInfo, pubkey) setRelayUrls([]) - setFilter({}) setIsReady(true) return } @@ -216,7 +207,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { feedInfoRef.current = newFeedInfo setTemporaryRelayUrls(urls) setRelayUrls(urls) - setFilter({}) setIsReady(true) const relayInfos = await relayInfoService.getRelayInfos(urls) @@ -234,7 +224,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { feedInfo, relayUrls, temporaryRelayUrls, - filter, isReady, switchFeed }} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 28867a52..0b7c0871 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -27,6 +27,7 @@ import { } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' +import { isSafari } from '@/lib/utils' type TTimelineRef = [string, number] @@ -1180,6 +1181,55 @@ class ClientService extends EventTarget { async updateBlossomServerListEventCache(evt: NEvent) { await this.updateReplaceableEventCache(evt) } + + // ================= Utils ================= + + async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) { + // If many websocket connections are initiated simultaneously, it will be + // very slow on Safari (for unknown reason) + if (isSafari()) { + let urls = BIG_RELAY_URLS + if (myPubkey) { + const relayList = await this.fetchRelayList(myPubkey) + urls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 5) + } + return [{ urls, filter: { authors: pubkeys } }] + } + + const relayLists = await this.fetchRelayLists(pubkeys) + const group: Record> = {} + relayLists.forEach((relayList, index) => { + relayList.write.slice(0, 4).forEach((url) => { + if (!group[url]) { + group[url] = new Set() + } + group[url].add(pubkeys[index]) + }) + }) + + const relayCount = Object.keys(group).length + const coveredCount = new Map() + Object.entries(group) + .sort(([, a], [, b]) => b.size - a.size) + .forEach(([url, pubkeys]) => { + if ( + relayCount > 10 && + pubkeys.size < 10 && + Array.from(pubkeys).every((pubkey) => (coveredCount.get(pubkey) ?? 0) >= 2) + ) { + delete group[url] + } else { + pubkeys.forEach((pubkey) => { + coveredCount.set(pubkey, (coveredCount.get(pubkey) ?? 0) + 1) + }) + } + }) + + return Object.entries(group).map(([url, authors]) => ({ + urls: [url], + filter: { authors: Array.from(authors) } + })) + } } const instance = ClientService.getInstance() diff --git a/src/types/index.d.ts b/src/types/index.d.ts index ab8fb604..787978d4 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,6 +1,16 @@ -import { Event, VerifiedEvent } from 'nostr-tools' +import { Event, VerifiedEvent, Filter } from 'nostr-tools' import { POLL_TYPE } from './constants' +export type TSubRequest = { + urls: string[] + filter: Omit & { limit: number } +} + +export type TNormalFeedSubRequest = { + urls: string[] + filter: Omit +} + export type TProfile = { username: string pubkey: string