13 changed files with 765 additions and 418 deletions
@ -0,0 +1,303 @@
@@ -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<string | undefined>(undefined) |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
const [newEvents, setNewEvents] = useState<Event[]>([]) |
||||
const [hasMore, setHasMore] = useState<boolean>(true) |
||||
const [loading, setLoading] = useState(true) |
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => |
||||
isMainFeed ? storage.getNoteListMode() : 'posts' |
||||
) |
||||
const [filterType, setFilterType] = useState<Exclude<TNoteListMode, 'postsAndReplies'>>('posts') |
||||
const noteListRef = useRef<TNoteListRef | null>(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<Filter, 'since' | 'until'> & { 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<string, Set<string>> = {} |
||||
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<string, number>() |
||||
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 ( |
||||
<div className={className}> |
||||
<Tabs |
||||
value={listMode} |
||||
tabs={ |
||||
pubkey && author && pubkey !== author |
||||
? [ |
||||
{ value: 'posts', label: 'Notes' }, |
||||
{ value: 'postsAndReplies', label: 'Replies' }, |
||||
{ value: 'you', label: 'YouTabName' } |
||||
] |
||||
: [ |
||||
{ value: 'posts', label: 'Notes' }, |
||||
{ value: 'postsAndReplies', label: 'Replies' } |
||||
] |
||||
} |
||||
onTabChange={(listMode) => { |
||||
setListMode(listMode as TNoteListMode) |
||||
if (isMainFeed) { |
||||
storage.setNoteListMode(listMode as TNoteListMode) |
||||
} |
||||
setTimeout(() => { |
||||
noteListRef.current?.scrollToTop() |
||||
}, 0) |
||||
}} |
||||
threshold={Math.max(800, topSpace)} |
||||
/> |
||||
<NoteList |
||||
ref={noteListRef} |
||||
events={events.filter( |
||||
(event: Event) => |
||||
(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} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,168 @@
@@ -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<TNoteListMode>(() => storage.getNoteListMode()) |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
const [newEvents, setNewEvents] = useState<Event[]>([]) |
||||
const [hasMore, setHasMore] = useState<boolean>(true) |
||||
const [loading, setLoading] = useState(true) |
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) |
||||
const [refreshCount, setRefreshCount] = useState(0) |
||||
const noteListRef = useRef<TNoteListRef>(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 ( |
||||
<> |
||||
<Tabs |
||||
value={listMode} |
||||
tabs={[ |
||||
{ value: 'posts', label: 'Notes' }, |
||||
{ value: 'postsAndReplies', label: 'Replies' } |
||||
]} |
||||
onTabChange={(listMode) => { |
||||
handleListModeChange(listMode as TNoteListMode) |
||||
}} |
||||
/> |
||||
<NoteList |
||||
ref={noteListRef} |
||||
events={events.filter( |
||||
(event: Event) => |
||||
(listMode !== 'posts' || !isReplyNoteEvent(event)) && |
||||
(!hideUntrustedNotes || isUserTrusted(event.pubkey)) |
||||
)} |
||||
hasMore={hasMore} |
||||
loading={loading} |
||||
loadMore={loadMore} |
||||
onRefresh={() => { |
||||
setRefreshCount((count) => count + 1) |
||||
}} |
||||
newEvents={filteredNewEvents} |
||||
showNewEvents={showNewEvents} |
||||
/> |
||||
</> |
||||
) |
||||
} |
||||
@ -1,386 +1,149 @@
@@ -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<string | undefined>(undefined) |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
const [newEvents, setNewEvents] = useState<Event[]>([]) |
||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
||||
const [hasMore, setHasMore] = useState<boolean>(true) |
||||
const [loading, setLoading] = useState(true) |
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => |
||||
isMainFeed ? storage.getNoteListMode() : 'posts' |
||||
) |
||||
const [filterType, setFilterType] = useState<Exclude<TNoteListMode, 'postsAndReplies'>>('posts') |
||||
const bottomRef = useRef<HTMLDivElement | null>(null) |
||||
const topRef = useRef<HTMLDivElement | null>(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') |
||||
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<HTMLDivElement | null>(null) |
||||
const topRef = useRef<HTMLDivElement | null>(null) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
scrollToTop: () => { |
||||
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
||||
} |
||||
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<Filter, 'since' | 'until'> & { 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!] |
||||
} |
||||
const idSet = new Set<string>() |
||||
|
||||
// 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<string, Set<string>> = {} |
||||
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<string, number>() |
||||
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)]) |
||||
|
||||
useEffect(() => { |
||||
const options = { |
||||
root: null, |
||||
rootMargin: '10px', |
||||
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 (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() |
||||
} |
||||
}, options) |
||||
|
||||
const currentBottomRef = bottomRef.current |
||||
const observerInstance = new IntersectionObserver((entries) => { |
||||
if (entries[0].isIntersecting && hasMore) { |
||||
_loadMore() |
||||
} |
||||
}, options) |
||||
|
||||
if (currentBottomRef) { |
||||
observerInstance.observe(currentBottomRef) |
||||
} |
||||
const currentBottomRef = bottomRef.current |
||||
|
||||
return () => { |
||||
if (observerInstance && currentBottomRef) { |
||||
observerInstance.unobserve(currentBottomRef) |
||||
if (currentBottomRef) { |
||||
observerInstance.observe(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<string>() |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<Tabs |
||||
value={listMode} |
||||
tabs={ |
||||
pubkey && author && pubkey !== author |
||||
? [ |
||||
{ value: 'posts', label: 'Notes' }, |
||||
{ value: 'postsAndReplies', label: 'Replies' }, |
||||
{ value: 'you', label: 'YouTabName' } |
||||
] |
||||
: [ |
||||
{ value: 'posts', label: 'Notes' }, |
||||
{ value: 'postsAndReplies', label: 'Replies' } |
||||
] |
||||
return () => { |
||||
if (observerInstance && currentBottomRef) { |
||||
observerInstance.unobserve(currentBottomRef) |
||||
} |
||||
onTabChange={(listMode) => { |
||||
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 && ( |
||||
<NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} /> |
||||
)} |
||||
<div ref={topRef} className="scroll-mt-24" /> |
||||
<PullToRefresh |
||||
onRefresh={async () => { |
||||
setRefreshCount((count) => count + 1) |
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
||||
}} |
||||
pullingContent="" |
||||
> |
||||
<div className="min-h-screen"> |
||||
{events |
||||
.slice(0, showCount) |
||||
.filter((event: Event) => { |
||||
} |
||||
}, [loading, hasMore, events, loadMore]) |
||||
|
||||
return ( |
||||
<div> |
||||
{newEvents.length > 0 && <NewNotesButton newEvents={newEvents} onClick={showNewEvents} />} |
||||
<div ref={topRef} className="scroll-mt-24" /> |
||||
<PullToRefresh |
||||
onRefresh={async () => { |
||||
onRefresh?.() |
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
||||
}} |
||||
pullingContent="" |
||||
> |
||||
<div className="min-h-screen"> |
||||
{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)) |
||||
<NoteCard |
||||
key={event.id} |
||||
className="w-full" |
||||
event={event} |
||||
filterMutedNotes={filterMutedNotes} |
||||
/> |
||||
) |
||||
}) |
||||
.map((event) => ( |
||||
<NoteCard |
||||
key={event.id} |
||||
className="w-full" |
||||
event={event} |
||||
filterMutedNotes={filterMutedNotes} |
||||
/> |
||||
))} |
||||
{hasMore || loading ? ( |
||||
<div ref={bottomRef}> |
||||
<NoteCardLoadingSkeleton /> |
||||
</div> |
||||
) : events.length ? ( |
||||
<div className="text-center text-sm text-muted-foreground mt-2"> |
||||
{t('no more notes')} |
||||
</div> |
||||
) : ( |
||||
<div className="flex justify-center w-full mt-2"> |
||||
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}> |
||||
{t('reload notes')} |
||||
</Button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</PullToRefresh> |
||||
<div className="h-40" /> |
||||
</div> |
||||
) |
||||
})} |
||||
{hasMore || loading ? ( |
||||
<div ref={bottomRef}> |
||||
<NoteCardLoadingSkeleton /> |
||||
</div> |
||||
) : events.length ? ( |
||||
<div className="text-center text-sm text-muted-foreground mt-2"> |
||||
{t('no more notes')} |
||||
</div> |
||||
) : ( |
||||
<div className="flex justify-center w-full mt-2"> |
||||
<Button size="lg" onClick={onRefresh}> |
||||
{t('reload notes')} |
||||
</Button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</PullToRefresh> |
||||
<div className="h-40" /> |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
NoteList.displayName = 'NoteList' |
||||
export default NoteList |
||||
|
||||
export type TNoteListRef = { |
||||
scrollToTop: () => void |
||||
} |
||||
|
||||
@ -0,0 +1,28 @@
@@ -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<TNormalFeedSubRequest[]>([]) |
||||
|
||||
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 <NormalFeed subRequests={subRequests} /> |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -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 ( |
||||
<NormalFeed subRequests={[{ urls: relayUrls, filter: {} }]} areAlgoRelays={areAlgoRelays} /> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue