From 74d7f9be291187d1f866754d5025e610f1a6ad06 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 11 Apr 2025 22:53:51 +0800 Subject: [PATCH] refactor: subscribe --- src/components/Nip22ReplyNoteList/index.tsx | 9 +- src/components/NoteList/index.tsx | 59 +++--- src/components/NotificationList/index.tsx | 84 +++++---- src/components/ReplyNoteList/index.tsx | 4 +- src/services/client.service.ts | 189 ++++++++------------ 5 files changed, 157 insertions(+), 188 deletions(-) diff --git a/src/components/Nip22ReplyNoteList/index.tsx b/src/components/Nip22ReplyNoteList/index.tsx index 5b6e7090..ed39c132 100644 --- a/src/components/Nip22ReplyNoteList/index.tsx +++ b/src/components/Nip22ReplyNoteList/index.tsx @@ -22,7 +22,7 @@ export default function Nip22ReplyNoteList({ className?: string }) { const { t } = useTranslation() - const { pubkey } = useNostr() + const { pubkey, startLogin } = useNostr() const [timelineKey, setTimelineKey] = useState(undefined) const [until, setUntil] = useState(() => dayjs().unix()) const [replies, setReplies] = useState([]) @@ -76,7 +76,9 @@ export default function Nip22ReplyNoteList({ }, { onEvents: (evts, eosed) => { - setReplies(evts.reverse()) + if (evts.length > 0) { + setReplies(evts.reverse()) + } if (eosed) { setLoading(false) setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) @@ -85,6 +87,9 @@ export default function Nip22ReplyNoteList({ onNew: (evt) => { onNewReply(evt) } + }, + { + startLogin } ) setTimelineKey(timelineKey) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 456acfbb..a2a94733 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -14,7 +14,7 @@ import relayInfoService from '@/services/relay-info.service' import { TNoteListMode } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' -import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard from '../NoteCard' @@ -47,7 +47,7 @@ export default function NoteList({ const [newEvents, setNewEvents] = useState([]) const [showCount, setShowCount] = useState(SHOW_COUNT) const [hasMore, setHasMore] = useState(true) - const [refreshing, setRefreshing] = useState(true) + const [loading, setLoading] = useState(true) const [listMode, setListMode] = useState(() => storage.getNoteListMode()) const bottomRef = useRef(null) const isPictures = useMemo(() => listMode === 'pictures', [listMode]) @@ -63,7 +63,7 @@ export default function NoteList({ if (relayUrls.length === 0 && !noteFilter.authors?.length) return async function init() { - setRefreshing(true) + setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) @@ -74,15 +74,11 @@ export default function NoteList({ areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) } - let eventCount = 0 const { closer, timelineKey } = await client.subscribeTimeline( [...relayUrls], { ...noteFilter, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT }, { onEvents: (events, eosed) => { - if (eventCount > events.length) return - eventCount = events.length - if (events.length > 0) { setEvents(events) } @@ -90,7 +86,7 @@ export default function NoteList({ setHasMore(false) } if (eosed) { - setRefreshing(false) + setLoading(false) setHasMore(events.length > 0) } }, @@ -115,25 +111,6 @@ export default function NoteList({ } }, [JSON.stringify(relayUrls), noteFilter, refreshCount]) - const loadMore = useCallback(async () => { - if (showCount < events.length) { - setShowCount((prev) => prev + SHOW_COUNT) - return - } - - if (!timelineKey || refreshing || !hasMore) return - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - if (newEvents.length === 0) { - setHasMore(false) - return - } - setEvents((oldEvents) => [...oldEvents, ...newEvents]) - }, [timelineKey, refreshing, hasMore, events, noteFilter, showCount]) - useEffect(() => { const options = { root: null, @@ -141,6 +118,30 @@ export default function NoteList({ threshold: 0.1 } + const loadMore = async () => { + if (showCount < events.length) { + setShowCount((prev) => prev + SHOW_COUNT) + // preload more + if (events.length - showCount > LIMIT / 2) { + return + } + } + + 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() @@ -158,7 +159,7 @@ export default function NoteList({ observerInstance.unobserve(currentBottomRef) } } - }, [loadMore]) + }, [timelineKey, loading, hasMore, events, noteFilter, showCount]) const showNewEvents = () => { topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) @@ -214,7 +215,7 @@ export default function NoteList({ ))} )} - {hasMore || refreshing ? ( + {hasMore || loading ? (
diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index ebb5ef63..8bcc8e59 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -10,15 +10,7 @@ import storage from '@/services/local-storage.service' import { TNotificationType } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { NotificationItem } from './NotificationItem' @@ -34,7 +26,7 @@ const NotificationList = forwardRef((_, ref) => { const [lastReadTime, setLastReadTime] = useState(0) const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) - const [refreshing, setRefreshing] = useState(true) + const [loading, setLoading] = useState(true) const [notifications, setNotifications] = useState([]) const [newNotifications, setNewNotifications] = useState([]) const [oldNotifications, setOldNotifications] = useState([]) @@ -57,11 +49,11 @@ const NotificationList = forwardRef((_, ref) => { ref, () => ({ refresh: () => { - if (refreshing) return + if (loading) return setRefreshCount((count) => count + 1) } }), - [refreshing] + [loading] ) useEffect(() => { @@ -71,12 +63,12 @@ const NotificationList = forwardRef((_, ref) => { } const init = async () => { - setRefreshing(true) + setLoading(true) setNotifications([]) setShowCount(SHOW_COUNT) setLastReadTime(storage.getLastReadNotificationTime(pubkey)) const relayList = await client.fetchRelayList(pubkey) - let eventCount = 0 + const { closer, timelineKey } = await client.subscribeTimeline( relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS, { @@ -86,11 +78,11 @@ const NotificationList = forwardRef((_, ref) => { }, { onEvents: (events, eosed) => { - if (eventCount > events.length) return - eventCount = events.length - setNotifications(events.filter((event) => event.pubkey !== pubkey)) + if (events.length > 0) { + setNotifications(events.filter((event) => event.pubkey !== pubkey)) + } if (eosed) { - setRefreshing(false) + setLoading(false) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) updateNoteStatsByEvents(events) } @@ -132,30 +124,6 @@ const NotificationList = forwardRef((_, ref) => { } }, [notifications, lastReadTime, showCount]) - const loadMore = useCallback(async () => { - if (showCount < notifications.length) { - setShowCount((count) => count + SHOW_COUNT) - return - } - - if (!pubkey || !timelineKey || !until || refreshing) return - - const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT) - if (newNotifications.length === 0) { - setUntil(undefined) - return - } - - if (newNotifications.length > 0) { - setNotifications((oldNotifications) => [ - ...oldNotifications, - ...newNotifications.filter((event) => event.pubkey !== pubkey) - ]) - } - - setUntil(newNotifications[newNotifications.length - 1].created_at - 1) - }, [pubkey, timelineKey, until, refreshing, showCount, notifications]) - useEffect(() => { const options = { root: null, @@ -163,6 +131,34 @@ const NotificationList = forwardRef((_, ref) => { threshold: 1 } + const loadMore = async () => { + if (showCount < notifications.length) { + setShowCount((count) => count + SHOW_COUNT) + // preload more + if (notifications.length - showCount > LIMIT / 2) { + return + } + } + + if (!pubkey || !timelineKey || !until || loading) return + setLoading(true) + const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT) + setLoading(false) + if (newNotifications.length === 0) { + setUntil(undefined) + return + } + + if (newNotifications.length > 0) { + setNotifications((oldNotifications) => [ + ...oldNotifications, + ...newNotifications.filter((event) => event.pubkey !== pubkey) + ]) + } + + setUntil(newNotifications[newNotifications.length - 1].created_at - 1) + } + const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { loadMore() @@ -180,7 +176,7 @@ const NotificationList = forwardRef((_, ref) => { observerInstance.unobserve(currentBottomRef) } } - }, [loadMore]) + }, [pubkey, timelineKey, until, loading, showCount, notifications]) return (
@@ -214,7 +210,7 @@ const NotificationList = forwardRef((_, ref) => { ))}
- {until || refreshing ? ( + {until || loading ? (
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 589f6f85..c0bd5f16 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -105,7 +105,9 @@ export default function ReplyNoteList({ }, { onEvents: (evts, eosed) => { - setEvents(evts.filter((evt) => isReplyNoteEvent(evt)).reverse()) + if (evts.length > 0) { + setEvents(evts.filter((evt) => isReplyNoteEvent(evt)).reverse()) + } if (eosed) { setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) setLoading(false) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 222bf65d..78391689 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -40,10 +40,8 @@ class ClientService extends EventTarget { | string[] | undefined > = {} - private eventCache = new LRUCache>({ max: 10000 }) - private eventDataLoader = new DataLoader( - (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))), - { cacheMap: this.eventCache } + private eventDataLoader = new DataLoader((ids) => + Promise.all(ids.map((id) => this._fetchEvent(id))) ) private fetchEventFromBigRelaysDataloader = new DataLoader( this.fetchEventsFromBigRelays.bind(this), @@ -343,9 +341,10 @@ class ClientService extends EventTarget { async function startSub() { startedCount++ - const relay = await that.pool.ensureRelay(url, { connectionTimeout: 2000 }).catch(() => { + const relay = await that.pool.ensureRelay(url, { connectionTimeout: 5000 }).catch(() => { return undefined }) + // cannot connect to relay if (!relay) { if (!eosed) { eosedCount++ @@ -356,6 +355,7 @@ class ClientService extends EventTarget { close: () => {} } } + return relay.subscribe(filters, { receivedEvent: (relay, id) => { that.trackEventSeenOn(id, relay) @@ -372,44 +372,52 @@ class ClientService extends EventTarget { onevent?.(evt) }, oneose: () => { + // make sure eosed is not called multiple times if (eosed) return + eosedCount++ eosed = eosedCount >= startedCount - oneose?.(eosed) }, onclose: (reason: string) => { - if (!reason.startsWith('auth-required')) { - closedCount++ - closeReasons.push(reason) - if (closedCount >= startedCount) { - onclose?.(closeReasons) + // auth-required + if (reason.startsWith('auth-required') && !hasAuthed) { + // already logged in + if (that.signer) { + relay + .auth(async (authEvt: EventTemplate) => { + const evt = await that.signer!.signEvent(authEvt) + if (!evt) { + throw new Error('sign event failed') + } + return evt as VerifiedEvent + }) + .then(() => { + hasAuthed = true + if (!eosed) { + subPromises.push(startSub()) + } + }) + .catch(() => { + // ignore + }) + return } - return - } - if (hasAuthed) return - if (that.signer) { - relay - .auth(async (authEvt: EventTemplate) => { - const evt = await that.signer!.signEvent(authEvt) - if (!evt) { - throw new Error('sign event failed') - } - return evt as VerifiedEvent - }) - .then(() => { - hasAuthed = true - if (!eosed) { - subPromises.push(startSub()) - } - }) - .catch(() => { - // ignore - }) - } else if (startLogin) { - startLogin() + // open login dialog + if (startLogin) { + startLogin() + return + } } + + // close the subscription + closedCount++ + closeReasons.push(reason) + if (closedCount >= startedCount) { + onclose?.(closeReasons) + } + return }, eoseTimeout: 10_000 // 10s }) @@ -432,56 +440,24 @@ class ClientService extends EventTarget { } private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { - const relays = Array.from(new Set(urls)) - const filters = Array.isArray(filter) ? filter : [filter] - const _knownIds = new Set() - const events: NEvent[] = [] - await Promise.allSettled( - relays.map(async (url) => { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this - const relay = await this.pool.ensureRelay(url) - let hasAuthed = false - - return new Promise((resolve, reject) => { - const startQuery = () => { - const sub = relay.subscribe(filters, { - receivedEvent(relay, id) { - that.trackEventSeenOn(id, relay) - }, - onclose(reason) { - if (!reason.startsWith('auth-required') || hasAuthed) { - resolve() - return - } - - if (that.signer) { - relay - .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) - .then(() => { - hasAuthed = true - startQuery() - }) - .catch(reject) - } - }, - oneose() { - sub.close() - resolve() - }, - onevent(evt) { - if (_knownIds.has(evt.id)) return - _knownIds.add(evt.id) - events.push(evt) - onevent?.(evt) - } - }) + return await new Promise((resolve) => { + const events: NEvent[] = [] + const sub = this.subscribe(urls, filter, { + onevent(evt) { + onevent?.(evt) + events.push(evt) + }, + oneose: (eosed) => { + if (eosed) { + sub.close() + resolve(events) } - startQuery() - }) + }, + onclose: () => { + resolve(events) + } }) - ) - return events + }) } private async _subscribeTimeline( @@ -509,20 +485,14 @@ class ClientService extends EventTarget { let since: number | undefined if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) { cachedEvents = ( - await Promise.all( - timeline.refs.slice(0, filter.limit).map(([id]) => this.eventCache.get(id)) - ) - ).filter(Boolean) as NEvent[] + await this.eventDataLoader.loadMany(timeline.refs.slice(0, filter.limit).map(([id]) => id)) + ).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[] if (cachedEvents.length) { onEvents([...cachedEvents], false) since = cachedEvents[0].created_at + 1 } } - if (!timeline && needSort) { - this.timelines[key] = { refs: [], filter, urls: relays } - } - // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this let events: NEvent[] = [] @@ -544,12 +514,8 @@ class ClientService extends EventTarget { if (!timeline || Array.isArray(timeline) || !timeline.refs.length) { return onNew(evt) } - // the event is newer than the first ref, insert it to the front - if (evt.created_at > timeline.refs[0][1]) { - onNew(evt) - return timeline.refs.unshift([evt.id, evt.created_at]) - } + // find the right position to insert let idx = 0 for (const ref of timeline.refs) { if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) { @@ -564,6 +530,11 @@ class ClientService extends EventTarget { // the event is too old, ignore it if (idx >= timeline.refs.length) return + // new event + if (idx === 0) { + onNew(evt) + } + // insert the event to the right position timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) }, @@ -575,7 +546,7 @@ class ClientService extends EventTarget { } if (!eosed) { events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) - return onEvents([...events.concat(cachedEvents)], false) + return onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], false) } events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) @@ -590,22 +561,16 @@ class ClientService extends EventTarget { return onEvents([...events], true) } - const newEvents = events.filter((evt) => { - const firstRef = timeline.refs[0] - return ( - evt.created_at > firstRef[1] || (evt.created_at === firstRef[1] && evt.id < firstRef[0]) - ) - }) - const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef) + const newRefs = events.map((evt) => [evt.id, evt.created_at] as TTimelineRef) - if (newRefs.length >= filter.limit) { + if (events.length >= filter.limit) { // if new refs are more than limit, means old refs are too old, replace them timeline.refs = newRefs - onEvents([...newEvents], true) + onEvents([...events], true) } else { // merge new refs with old refs timeline.refs = newRefs.concat(timeline.refs) - onEvents([...newEvents.concat(cachedEvents)], true) + onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true) } } }) @@ -625,14 +590,14 @@ class ClientService extends EventTarget { if (!timeline || Array.isArray(timeline)) return [] const { filter, urls, refs } = timeline - const startIdx = refs.findIndex(([, createdAt]) => createdAt < until) + const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) const cachedEvents = startIdx >= 0 ? (( - await Promise.all( - refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventCache.get(id)) + await this.eventDataLoader.loadMany( + refs.slice(startIdx, startIdx + limit).map(([id]) => id) ) - ).filter(Boolean) as NEvent[]) + ).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[]) : [] if (cachedEvents.length >= limit) { return cachedEvents @@ -640,7 +605,7 @@ class ClientService extends EventTarget { until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until limit = limit - cachedEvents.length - let events = await this.query(urls, { ...filter, until: until, limit: limit }) + let events = await this.query(urls, { ...filter, until, limit }) events.forEach((evt) => { this.eventDataLoader.prime(evt.id, Promise.resolve(evt)) }) @@ -687,7 +652,7 @@ class ClientService extends EventTarget { break } if (eventId) { - const cache = await this.eventCache.get(eventId) + const cache = await this.eventDataLoader.load(eventId) if (cache) { return cache }