InfiniteScroll component

This commit is contained in:
Martti Malmi 2023-08-12 12:02:16 +03:00
parent c8547b909d
commit 1f3dbddb5c
9 changed files with 73 additions and 132 deletions

View File

@ -3,10 +3,11 @@ import { debounce } from 'lodash';
import { useEffect, useState } from 'preact/hooks';
import { Link, route } from 'preact-router';
import InfiniteScroll from '@/components/helpers/InfiniteScroll.tsx';
import Events from '../../../nostr/Events';
import Key from '../../../nostr/Key';
import { translate as t } from '../../../translations/Translation.mjs';
import For from '../../helpers/For';
import Show from '../../helpers/Show';
import EventComponent from '../EventComponent';
@ -156,11 +157,11 @@ const Note = ({
<Show when={!(isQuote || asInlineQuote)}>
<hr className="opacity-10" />
</Show>
<For each={replies}>
{(r) => (
<InfiniteScroll>
{replies.map((r) => (
<EventComponent key={r} id={r} isReply={true} isQuoting={!standalone} showReplies={1} />
)}
</For>
))}
</InfiniteScroll>
</>
);
};

View File

@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useMemo, useRef, useState } from 'react';
import Image from '@/components/embed/Image';
import Video from '@/components/embed/Video';
@ -9,13 +9,11 @@ import ImageGridItem from '@/components/feed/ImageGridItem';
import ImageModal from '@/components/feed/ImageModal';
import ShowNewEvents from '@/components/feed/ShowNewEvents';
import { DisplayAs, FeedProps, ImageOrVideo } from '@/components/feed/types';
import useInfiniteScroll from '@/components/feed/useInfiniteScroll';
import InfiniteScroll from '@/components/helpers/InfiniteScroll.tsx';
import Show from '@/components/helpers/Show';
import useSubscribe from '@/hooks/useSubscribe';
import { useLocalState } from '@/LocalState';
const PAGE_SIZE = 6;
function mapEventsToMedia(events: any[]): ImageOrVideo[] {
return events.flatMap((event) => {
const imageMatches = (event.content.match(Image.regex) || []).map((url: string) => ({
@ -39,13 +37,10 @@ const Feed = (props: FeedProps) => {
throw new Error('Feed requires at least one filter option');
}
const [filterOption, setFilterOption] = useState(filterOptions[0]);
const [displayCount, setDisplayCount] = useState(PAGE_SIZE);
const [displayAs, setDisplayAs] = useState<DisplayAs>('feed');
const [modalItemIndex, setModalImageIndex] = useState<number | null>(null);
const lastElementRef = useRef(null);
const [mutedUsers] = useLocalState('muted', {});
const [hasNewEvents, setHasNewEvents] = useState(false);
const [listedEvents, setListedEvents] = useState<any[]>([]);
const { events: allEvents, loadMore } = useSubscribe({
filter: filterOption.filter,
@ -65,38 +60,14 @@ const Feed = (props: FeedProps) => {
return filtered;
}, [allEvents, filterOption]);
useEffect(() => {
if (listedEvents.length < 10) {
setListedEvents(allEventsFiltered);
} else {
const lastShownEvent = listedEvents[Math.min(displayCount, listedEvents.length) - 1];
const oldEvents = allEventsFiltered.filter(
(event) => event.created_at < lastShownEvent.created_at,
);
setListedEvents((prevListedEvents) => [...prevListedEvents, ...oldEvents]);
setHasNewEvents(true);
}
}, [allEventsFiltered]);
const isEmpty = listedEvents.length === 0;
const hasMoreItems = displayCount < listedEvents.length;
useInfiniteScroll(lastElementRef, loadMoreItems, hasMoreItems);
function loadMoreItems() {
if (displayCount < listedEvents.length) {
setDisplayCount((prevCount) => prevCount + PAGE_SIZE);
} else {
loadMore?.();
}
}
const isEmpty = allEventsFiltered.length === 0;
const imagesAndVideos = useMemo(() => {
if (displayAs === 'feed') {
return [];
}
return mapEventsToMedia(listedEvents).slice(0, displayCount);
}, [listedEvents, displayCount, displayAs]) as ImageOrVideo[];
return mapEventsToMedia(allEventsFiltered);
}, [allEventsFiltered, displayAs]) as ImageOrVideo[];
return (
<>
@ -104,8 +75,6 @@ const Feed = (props: FeedProps) => {
<ShowNewEvents
onClick={() => {
setHasNewEvents(false);
setDisplayCount(PAGE_SIZE);
setListedEvents(allEventsFiltered);
if (feedTopRef.current) {
feedTopRef.current.scrollIntoView({ behavior: 'smooth' });
}
@ -119,14 +88,12 @@ const Feed = (props: FeedProps) => {
activeOption={filterOption}
onOptionClick={(opt) => {
setFilterOption(opt);
setDisplayCount(PAGE_SIZE);
}}
/>
</Show>
<Show when={showDisplayAs !== false}>
<DisplaySelector
onDisplayChange={(displayAs) => {
setDisplayCount(PAGE_SIZE);
setDisplayAs(displayAs);
}}
activeDisplay={displayAs}
@ -140,26 +107,19 @@ const Feed = (props: FeedProps) => {
<Show when={isEmpty}>{emptyMessage || 'No Posts'}</Show>
<Show when={displayAs === 'grid'}>
<div className="grid grid-cols-3 gap-px">
{imagesAndVideos.map((item, index) => (
<ImageGridItem
item={item}
index={index}
setModalImageIndex={setModalImageIndex}
lastElementRef={lastElementRef}
imagesAndVideosLength={imagesAndVideos.length}
/>
))}
<InfiniteScroll loadMore={loadMore}>
{imagesAndVideos.map((item, index) => (
<ImageGridItem item={item} index={index} setModalImageIndex={setModalImageIndex} />
))}
</InfiniteScroll>
</div>
</Show>
<Show when={displayAs === 'feed'}>
{listedEvents.slice(0, displayCount).map((event, index, self) => {
const isLastElement = index === self.length - 1;
return (
<div key={`feed${event.id}${index}`} ref={isLastElement ? lastElementRef : null}>
<EventComponent id={event.id} {...filterOption.eventProps} />
</div>
);
})}
<InfiniteScroll loadMore={loadMore}>
{allEventsFiltered.map((event) => {
return <EventComponent id={event.id} {...filterOption.eventProps} />;
})}
</InfiniteScroll>
</Show>
</>
);

View File

@ -6,29 +6,26 @@ type ImageGridItemProps = {
item: ImageOrVideo;
index: number;
setModalImageIndex: (index: number) => void;
imagesAndVideosLength: number;
lastElementRef?: React.MutableRefObject<HTMLDivElement | null>;
lastElementRef?: React.MutableRefObject<HTMLDivElement>;
};
export const ImageGridItem = ({
item,
index,
setModalImageIndex,
imagesAndVideosLength,
lastElementRef,
}: ImageGridItemProps) => {
const url =
item.type === 'video' ? `https://imgproxy.iris.to/thumbnail/638/${item.url}` : item.url;
const isLast = index === imagesAndVideosLength - 1;
return (
<div
key={`feed${url}${index}`}
className="aspect-square cursor-pointer relative bg-neutral-300 hover:opacity-80"
ref={isLast ? lastElementRef : null}
onClick={() => {
setModalImageIndex(index);
}}
ref={lastElementRef}
>
<SafeImg square={true} width={319} src={url} alt="" className="w-full h-full object-cover" />
{item.type === 'video' && (

View File

@ -1,29 +0,0 @@
import { useEffect } from 'react';
const LOAD_MORE_MARGIN = '0px 0px 2000px 0px';
export default function useInfiniteScroll(
target: React.RefObject<Element>,
callback: () => void,
hasMore: boolean,
): void {
useEffect(() => {
if (target.current && hasMore) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
callback();
}
},
{
threshold: 0.0,
rootMargin: LOAD_MORE_MARGIN,
},
);
observer.observe(target.current);
return () => {
observer.disconnect();
};
}
}, [target, callback, hasMore]);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Event, Filter } from 'nostr-tools';
import PubSub, { Unsubscribe } from '@/nostr/PubSub';
@ -10,45 +10,51 @@ const useSubscribe = (ops: {
mergeSubscriptions?: boolean;
enabled?: boolean;
}) => {
const [sortedEvents, setSortedEvents] = useState(new SortedEventMap());
const sortedEvents = useRef(new SortedEventMap());
const [loadMoreUnsubscribe, setLoadMoreUnsubscribe] = useState<Unsubscribe | null>(null);
const { filter, enabled = true, sinceLastOpened = false, mergeSubscriptions = true } = ops;
const [events, setEvents] = useState<Event[]>([]);
const lastUntilRef = useRef<number | null>(null);
const handleEvent = (event: Event) => {
if (sortedEvents.has(event.id)) return;
sortedEvents.add(event);
setEvents(sortedEvents.events());
if (sortedEvents.current.has(event.id)) return;
sortedEvents.current.add(event);
setEvents(sortedEvents.current.events());
};
useEffect(() => {
setSortedEvents(new SortedEventMap());
sortedEvents.current = new SortedEventMap();
}, [filter]);
useEffect(() => {
if (!enabled || !filter) return;
filter.limit = filter.limit || 10;
return PubSub.subscribe(filter, handleEvent, sinceLastOpened, mergeSubscriptions);
}, [ops]);
// Using useCallback to memoize the loadMore function.
const loadMore = () => {
const until = sortedEvents.last()?.created_at;
if (!until) return;
const newFilter = { ...filter, limit: filter.limit || 100 };
return PubSub.subscribe(newFilter, handleEvent, sinceLastOpened, mergeSubscriptions);
}, [filter, enabled, sinceLastOpened, mergeSubscriptions]);
const loadMore = useCallback(() => {
const until = sortedEvents.current.last()?.created_at;
console.log('loadMore', until && new Date(until * 1000).toISOString());
if (!until || lastUntilRef.current === until) return;
lastUntilRef.current = until;
// If there's a previous subscription from loadMore, unsubscribe from it
if (loadMoreUnsubscribe) {
loadMoreUnsubscribe();
setLoadMoreUnsubscribe(null); // Reset the stored unsubscribe function
setLoadMoreUnsubscribe(null);
}
const newFilter = Object.assign({}, filter, { until, limit: 10 });
const newFilter = { ...filter, until, limit: 100 };
console.log('load more until', new Date(until * 1000), 'filter', newFilter);
// Subscribe with the new filter and store the returned unsubscribe function
const unsubscribe = PubSub.subscribe(newFilter, handleEvent, false, true);
const unsubscribe = PubSub.subscribe(newFilter, handleEvent, false, false);
setLoadMoreUnsubscribe(unsubscribe);
}; // Dependencies for the useCallback
}, [sortedEvents, filter, loadMoreUnsubscribe]);
return { events, loadMore };
};

View File

@ -2,6 +2,8 @@ import { memo } from 'react';
import throttle from 'lodash/throttle';
import { Link } from 'preact-router';
import InfiniteScroll from '@/components/helpers/InfiniteScroll.tsx';
import Follow from '../components/buttons/Follow';
import Show from '../components/helpers/Show';
import Avatar from '../components/user/Avatar';
@ -123,10 +125,6 @@ class Follows extends View {
SocialNetwork.setFollowed(this.state.follows);
}
renderFollows() {
return this.state.follows.map((hexKey) => <FollowedUser hexKey={hexKey} />);
}
renderView() {
const showFollowAll =
this.state.follows.length > 1 && !(this.props.id === this.myPub && !this.props.followers);
@ -158,7 +156,11 @@ class Follows extends View {
</p>
</Show>
<div className="flex flex-col w-full gap-4">
{this.renderFollows() /* TODO limit if lots of follows */}
<InfiniteScroll>
{this.state.follows.map((hexKey) => (
<FollowedUser hexKey={hexKey} />
))}
</InfiniteScroll>
{this.state.follows.length === 0 ? '—' : ''}
</div>
</div>

View File

@ -117,18 +117,18 @@ class Profile extends View {
filterOptions={[
{
name: t('posts'),
filter: { authors: [this.state.hexPub], kinds: [1], limit: 5 },
filter: { authors: [this.state.hexPub], kinds: [1], limit: 100 },
filterFn: (event) => !Events.getEventReplyingTo(event),
eventProps: { showRepliedMsg: true },
},
{
name: t('posts_and_replies'),
filter: { authors: [this.state.hexPub], kinds: [1], limit: 5 },
filter: { authors: [this.state.hexPub], kinds: [1], limit: 100 },
eventProps: { showRepliedMsg: true, fullWidth: false },
},
{
name: t('likes'),
filter: { authors: [this.state.hexPub], kinds: [7], limit: 5 },
filter: { authors: [this.state.hexPub], kinds: [7], limit: 100 },
},
]}
/>

View File

@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import InfiniteScroll from '@/components/helpers/InfiniteScroll.tsx';
import localState from '../../LocalState';
import Key from '../../nostr/Key';
import { translate as t } from '../../translations/Translation.mjs';
@ -75,15 +77,17 @@ const ChatList = ({ activeChat, className }) => {
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
<NewChatButton active={activeChatHex === 'new'} />
{Array.from<[string, any]>(chats.entries() as any).map(([pubkey, data]) => (
<ChatListItem
active={pubkey === activeChatHex}
key={pubkey}
chat={pubkey}
latestMsg={data?.latest}
name={data?.name}
/>
))}
<InfiniteScroll>
{Array.from<[string, any]>(chats.entries() as any).map(([pubkey, data]) => (
<ChatListItem
active={pubkey === activeChatHex}
key={pubkey}
chat={pubkey}
latestMsg={data?.latest}
name={data?.name}
/>
))}
</InfiniteScroll>
</div>
</section>
);

View File

@ -30,13 +30,13 @@ class Feed extends View {
filterOptions={[
{
name: t('posts'),
filter: { kinds: [1] },
filter: { kinds: [1], limit: 100 },
filterFn: (event) => !Events.getEventReplyingTo(event),
eventProps: { showRepliedMsg: true },
},
{
name: t('posts_and_replies'),
filter: { kinds: [1] },
filter: { kinds: [1], limit: 100 },
eventProps: { showRepliedMsg: true, fullWidth: false },
},
]}