feat: load more

This commit is contained in:
Shusui MOYATANI
2024-01-07 16:36:08 +09:00
parent 022256e0a3
commit fd08875d0f
13 changed files with 294 additions and 44 deletions

View File

@@ -8,6 +8,7 @@ import { useHandleCommand } from '@/hooks/useCommandBus';
import { useTranslation } from '@/i18n/useTranslation';
export type ColumnProps = {
timelineRef?: (el: HTMLDivElement) => void;
columnIndex: number;
lastColumn: boolean;
width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined;
@@ -59,7 +60,7 @@ const Column: Component<ColumnProps> = (props) => {
fallback={
<>
<div class="shrink-0 border-b border-border">{props.header}</div>
<div class="scrollbar flex flex-col overflow-y-scroll scroll-smooth pb-16">
<div ref={props.timelineRef} class="scrollbar flex flex-col overflow-y-scroll pb-16">
{props.children}
</div>
</>

View File

@@ -6,6 +6,7 @@ import { uniq } from 'lodash';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Timeline from '@/components/timeline/Timeline';
import { FollowingColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
@@ -13,7 +14,6 @@ import useConfig from '@/core/useConfig';
import { useTranslation } from '@/i18n/useTranslation';
import useFollowings from '@/nostr/useFollowings';
import useSubscription from '@/nostr/useSubscription';
import epoch from '@/utils/epoch';
type FollowingColumnDisplayProps = {
columnIndex: number;
@@ -27,7 +27,11 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
const { followingPubkeys } = useFollowings(() => ({ pubkey: props.column.pubkey }));
const { events } = useSubscription(() => {
const loadMore = useLoadMore(() => ({
duration: 4 * 60 * 60,
}));
const { events, eose } = useSubscription(() => {
const authors = uniq([...followingPubkeys()]);
if (authors.length === 0) return null;
return {
@@ -37,10 +41,13 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
{
kinds: [1, 6],
authors,
limit: 10,
since: epoch() - 4 * 60 * 60,
limit: 20,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 20,
continuous: loadMore.continuous(),
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
@@ -50,6 +57,7 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
createEffect(() => {
console.log('home', events());
loadMore.setEvents(events());
});
onMount(() => console.log('home timeline mounted'));
@@ -68,8 +76,11 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Timeline events={events()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Timeline events={events()} />
</LoadMore>
</Column>
);
};

View File

@@ -0,0 +1,112 @@
import {
createSignal,
createMemo,
batch,
Show,
type JSX,
type Accessor,
type Component,
} from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools';
import ColumnItem from '@/components/ColumnItem';
import useScroll from '@/hooks/useScroll';
import { useTranslation } from '@/i18n/useTranslation';
import { pickOldestEvent } from '@/nostr/event/comparator';
import epoch from '@/utils/epoch';
export type UseLoadMoreProps = {
duration: number | null;
};
export type UseLoadMore = {
timelineRef: (el: HTMLElement) => void;
setEvents: (event: NostrEvent[]) => void;
since: Accessor<number | undefined>;
until: Accessor<number | undefined>;
continuous: Accessor<boolean>;
loadLatest: () => void;
loadOld: () => void;
};
export type LoadMoreProps = {
loadMore: UseLoadMore;
children: JSX.Element;
eose: boolean;
};
export const useLoadMore = (propsProvider: () => UseLoadMoreProps): UseLoadMore => {
const props = createMemo(propsProvider);
const calcSince = (base: number): number | undefined => {
const { duration } = props();
if (duration == null) return undefined;
return base - duration;
};
const [events, setEvents] = createSignal<NostrEvent[]>([]);
const [since, setSince] = createSignal<number | undefined>(calcSince(epoch()));
const [until, setUntil] = createSignal<number | undefined>();
const continuous = () => until() == null;
const scroll = useScroll();
const loadLatest = () => {
batch(() => {
setUntil(undefined);
setSince(calcSince(epoch()));
});
scroll.scrollToTop();
};
const loadOld = () => {
const oldest = pickOldestEvent(events());
if (oldest == null) return;
batch(() => {
setUntil(oldest.created_at);
setSince(calcSince(oldest.created_at));
});
scroll.scrollToTop();
};
return {
timelineRef: scroll.targetRef,
setEvents,
since,
until,
continuous,
loadLatest,
loadOld,
};
};
const LoadMore: Component<LoadMoreProps> = (props) => {
const i18n = useTranslation();
return (
<>
<Show when={!props.loadMore.continuous()}>
<ColumnItem>
<button
class="flex h-12 w-full flex-col items-center justify-center hover:text-fg-secondary"
onClick={() => props.loadMore.loadLatest()}
>
<span>{i18n()('column.loadLatest')}</span>
</button>
</ColumnItem>
</Show>
{props.children}
<ColumnItem>
<button
class="flex h-12 w-full flex-col items-center justify-center hover:text-fg-secondary disabled:text-fg-secondary/30"
disabled={!props.eose}
onClick={() => props.loadMore.loadOld()}
>
<span>{i18n()('column.loadOld')}</span>
</button>
</ColumnItem>
</>
);
};
export default LoadMore;

View File

@@ -1,10 +1,11 @@
import { Component } from 'solid-js';
import { createEffect, Component } from 'solid-js';
import Bell from 'heroicons/24/outline/bell.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Notification from '@/components/timeline/Notification';
import { NotificationColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
@@ -22,21 +23,28 @@ const NotificationColumn: Component<NotificationColumnDisplayProps> = (props) =>
const i18n = useTranslation();
const { config, removeColumn } = useConfig();
const { events: notifications } = useSubscription(() => ({
const loadMore = useLoadMore(() => ({ duration: null }));
const { events: notifications, eose } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6, 7, 9735],
'#p': [props.column.pubkey],
limit: 10,
limit: 20,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 20,
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
createEffect(() => loadMore.setEvents(notifications()));
return (
<Column
header={
@@ -50,8 +58,11 @@ const NotificationColumn: Component<NotificationColumnDisplayProps> = (props) =>
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Notification events={notifications()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Notification events={notifications()} />
</LoadMore>
</Column>
);
};

View File

@@ -1,10 +1,11 @@
import { Component } from 'solid-js';
import { createEffect, Component } from 'solid-js';
import User from 'heroicons/24/outline/user.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Timeline from '@/components/timeline/Timeline';
import { PostsColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
@@ -22,21 +23,28 @@ const PostsColumn: Component<PostsColumnDisplayProps> = (props) => {
const i18n = useTranslation();
const { config, removeColumn } = useConfig();
const { events } = useSubscription(() => ({
const loadMore = useLoadMore(() => ({ duration: null }));
const { events, eose } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors: [props.column.pubkey],
limit: 10,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 10,
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
createEffect(() => loadMore.setEvents(events()));
return (
<Column
header={
@@ -50,8 +58,11 @@ const PostsColumn: Component<PostsColumnDisplayProps> = (props) => {
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Timeline events={events()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Timeline events={events()} />
</LoadMore>
</Column>
);
};

View File

@@ -1,10 +1,11 @@
import { Component } from 'solid-js';
import { createEffect, Component } from 'solid-js';
import Heart from 'heroicons/24/outline/heart.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Notification from '@/components/timeline/Notification';
import { ReactionsColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
@@ -22,21 +23,28 @@ const ReactionsColumn: Component<ReactionsColumnDisplayProps> = (props) => {
const i18n = useTranslation();
const { config, removeColumn } = useConfig();
const { events: reactions } = useSubscription(() => ({
const loadMore = useLoadMore(() => ({ duration: null }));
const { events: reactions, eose } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [7],
authors: [props.column.pubkey],
limit: 10,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 10,
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
createEffect(() => loadMore.setEvents(reactions()));
return (
<Column
header={
@@ -50,8 +58,11 @@ const ReactionsColumn: Component<ReactionsColumnDisplayProps> = (props) => {
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Notification events={reactions()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Notification events={reactions()} />
</LoadMore>
</Column>
);
};

View File

@@ -1,17 +1,17 @@
import { Component } from 'solid-js';
import { createEffect, Component } from 'solid-js';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Timeline from '@/components/timeline/Timeline';
import { RelaysColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import { useTranslation } from '@/i18n/useTranslation';
import useSubscription from '@/nostr/useSubscription';
import epoch from '@/utils/epoch';
type RelaysColumnDisplayProps = {
columnIndex: number;
@@ -23,21 +23,29 @@ const RelaysColumn: Component<RelaysColumnDisplayProps> = (props) => {
const i18n = useTranslation();
const { removeColumn } = useConfig();
const { events } = useSubscription(() => ({
const loadMore = useLoadMore(() => ({
duration: 4 * 60 * 60,
}));
const { events, eose } = useSubscription(() => ({
relayUrls: props.column.relayUrls,
filters: [
{
kinds: [1],
limit: 25,
since: epoch() - 4 * 60 * 60,
limit: 20,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 20,
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
createEffect(() => loadMore.setEvents(events()));
return (
<Column
header={
@@ -51,8 +59,11 @@ const RelaysColumn: Component<RelaysColumnDisplayProps> = (props) => {
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Timeline events={events()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Timeline events={events()} />
</LoadMore>
</Column>
);
};

View File

@@ -1,10 +1,11 @@
import { Component, createSignal, Show, JSX, onMount } from 'solid-js';
import { Component, createEffect, createSignal, Show, JSX, onMount } from 'solid-js';
import EllipsisVertical from 'heroicons/24/outline/ellipsis-vertical.svg';
import MagnifyingGlass from 'heroicons/24/outline/magnifying-glass.svg';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import LoadMore, { useLoadMore } from '@/components/column/LoadMore';
import Timeline from '@/components/timeline/Timeline';
import { SearchColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
@@ -84,7 +85,11 @@ export type SearchColumnDisplayProps = {
const SearchColumn: Component<SearchColumnDisplayProps> = (props) => {
const { removeColumn } = useConfig();
const { events } = useSubscription(() => {
const loadMore = useLoadMore(() => ({
duration: null,
}));
const { events, eose } = useSubscription(() => {
const { query } = props.column;
if (query.length === 0) return null;
@@ -93,11 +98,14 @@ const SearchColumn: Component<SearchColumnDisplayProps> = (props) => {
relayUrls: relaysForSearching,
filters: [
{
kinds: [1, 6],
kinds: [1],
search: query,
limit: 25,
limit: 20,
since: loadMore.since(),
until: loadMore.until(),
},
],
eoseLimit: 20,
clientEventFilter: (event) => {
if (event.tags.findIndex(([tagName]) => tagName === 'mostr' || tagName === 'proxy') >= 0)
return false;
@@ -107,6 +115,10 @@ const SearchColumn: Component<SearchColumnDisplayProps> = (props) => {
};
});
createEffect(() => {
loadMore.setEvents(events());
});
return (
<Column
header={
@@ -119,8 +131,11 @@ const SearchColumn: Component<SearchColumnDisplayProps> = (props) => {
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
timelineRef={loadMore.timelineRef}
>
<Timeline events={events()} />
<LoadMore loadMore={loadMore} eose={eose()}>
<Timeline events={events()} />
</LoadMore>
</Column>
);
};

35
src/hooks/useScroll.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createSignal } from 'solid-js';
export type UseScroll = {
targetRef: (el: HTMLElement) => void;
currentPosition: () => number;
scrollToTop: () => void;
scrollToBottom: () => void;
};
const useScroll = (): UseScroll => {
const [elementRef, setElementRef] = createSignal<HTMLElement | undefined>();
const scrollToTop = () => {
const el = elementRef();
if (el == null) return;
el.scrollTo(0, 0);
};
const scrollToBottom = () => {
const el = elementRef();
if (el == null) return;
el.scrollTo(0, el.scrollHeight);
};
const currentPosition = () => elementRef()?.scrollTop ?? 0;
return {
targetRef: setElementRef,
currentPosition,
scrollToTop,
scrollToBottom,
};
};
export default useScroll;

View File

@@ -32,6 +32,8 @@ export default {
myPosts: 'My posts',
myReactions: 'My reactions',
back: 'Back',
loadLatest: 'Load latest posts',
loadOld: 'Load old posts',
config: {
columnWidth: 'Column width',
widest: 'Widest',

View File

@@ -31,6 +31,8 @@ export default {
myPosts: '自分の投稿',
myReactions: '自分のリアクション',
back: '戻る',
loadLatest: '最新の投稿を読み込む',
loadOld: '古い投稿を読み込む',
config: {
columnWidth: 'カラム幅',
widest: '特大',

View File

@@ -19,5 +19,11 @@ export const pickLatestEvent = (events: NostrEvent[]): NostrEvent | undefined =>
return events.reduce((a, b) => (compareEvents(a, b) > 0 ? a : b));
};
export const pickOldestEvent = (events: NostrEvent[]): NostrEvent | undefined => {
if (events.length === 0) return undefined;
if (events.length === 1) return events[0];
return events.reduce((a, b) => (-compareEvents(a, b) > 0 ? a : b));
};
export const sortEvents = (events: NostrEvent[]) =>
Array.from(events).sort((a, b) => -compareEvents(a, b));

View File

@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, onCleanup, on } from 'solid-js';
import { createSignal, createEffect, createMemo, onMount, onCleanup, on } from 'solid-js';
import uniqBy from 'lodash/uniqBy';
import { type Filter } from 'nostr-tools/filter';
@@ -25,6 +25,11 @@ export type UseSubscriptionProps = {
* limit the number of events
*/
limit?: number;
/**
* limit the number of events until EOSE
* This should be same to `limit` of REQ
*/
eoseLimit?: number;
clientEventFilter?: (event: NostrEvent) => boolean;
onEvent?: (event: NostrEvent & { id: string }) => void;
onEOSE?: () => void;
@@ -43,6 +48,11 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const { config, shouldMuteEvent } = useConfig();
const pool = usePool();
const [events, setEvents] = createSignal<NostrEvent[]>([]);
const [eose, setEose] = createSignal<boolean>(false);
const props = createMemo(propsProvider);
const eoseLimit = () => propsProvider()?.eoseLimit ?? 25;
const limit = () => propsProvider()?.limit ?? 50;
createEffect(
on(
@@ -63,7 +73,6 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const addEvent = (event: NostrEvent) => {
const SecondsToIgnore = 300; // 5 min
const limit = propsProvider()?.limit ?? 50;
const diffSec = event.created_at - epoch();
if (diffSec > SecondsToIgnore) return;
@@ -73,7 +82,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
}
setEvents((current) => {
const sorted = insertEventIntoDescendingList(current, event).slice(0, limit);
const sorted = insertEventIntoDescendingList(current, event).slice(0, limit());
// FIXME なぜか重複して取得される問題があるが一旦uniqByで対処
// https://github.com/syusui-s/rabbit/issues/5
const deduped = uniqBy(sorted, (e) => e.id);
@@ -87,16 +96,26 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const startSubscription = () => {
console.debug('startSubscription: start');
const props = propsProvider();
if (props == null) return;
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
const currentProps = props();
if (currentProps == null) return;
const {
relayUrls,
filters,
options,
onEvent,
onEOSE,
clientEventFilter,
continuous = true,
} = currentProps;
let subscribing = true;
count += 1;
let pushed = false;
let eose = false;
setEose(false);
const storedEvents: NostrEvent[] = [];
const updateEvents = () => setEvents(sortEvents(storedEvents).slice(0, eoseLimit()));
const sub = pool().subscribeMany(
relayUrls,
filters,
@@ -107,11 +126,11 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (onEvent != null) {
onEvent(event as NostrEvent & { id: string });
}
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
if (clientEventFilter != null && !clientEventFilter(event)) {
return;
}
if (!eose) {
if (!eose()) {
pushed = true;
storedEvents.push(event);
} else {
@@ -123,8 +142,8 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
onEOSE();
}
eose = true;
setEvents(sortEvents(storedEvents));
setEose(true);
updateEvents();
if (!continuous) {
sub.close();
@@ -142,14 +161,14 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const intervalId = setInterval(() => {
if (updating) return;
updating = true;
if (eose) {
if (eose()) {
clearInterval(intervalId);
updating = false;
return;
}
if (pushed) {
pushed = false;
setEvents(sortEvents(storedEvents));
updateEvents();
}
updating = false;
}, 100);
@@ -165,11 +184,14 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
});
};
createEffect(() => {
startSubscription();
});
createEffect(
on(
() => [props()],
() => startSubscription(),
),
);
return { events };
return { events, eose };
};
export default useSubscription;