mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 06:03:22 +00:00
InfiniteScroll component
This commit is contained in:
parent
c8547b909d
commit
1f3dbddb5c
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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' && (
|
||||
|
@ -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]);
|
||||
}
|
@ -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 };
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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 },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 },
|
||||
},
|
||||
]}
|
||||
|
Loading…
Reference in New Issue
Block a user