diff --git a/src/components/column/Column.tsx b/src/components/column/Column.tsx index aa0eead..0ee5630 100644 --- a/src/components/column/Column.tsx +++ b/src/components/column/Column.tsx @@ -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 = (props) => { fallback={ <>
{props.header}
-
+
{props.children}
diff --git a/src/components/column/FollwingColumn.tsx b/src/components/column/FollwingColumn.tsx index 1432267..d23ba54 100644 --- a/src/components/column/FollwingColumn.tsx +++ b/src/components/column/FollwingColumn.tsx @@ -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 = (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 = (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 = (props) => { createEffect(() => { console.log('home', events()); + loadMore.setEvents(events()); }); onMount(() => console.log('home timeline mounted')); @@ -68,8 +76,11 @@ const FollowingColumn: Component = (props) => { width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/components/column/LoadMore.tsx b/src/components/column/LoadMore.tsx new file mode 100644 index 0000000..27d8a7b --- /dev/null +++ b/src/components/column/LoadMore.tsx @@ -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; + until: Accessor; + continuous: Accessor; + 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([]); + const [since, setSince] = createSignal(calcSince(epoch())); + const [until, setUntil] = createSignal(); + 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 = (props) => { + const i18n = useTranslation(); + + return ( + <> + + + + + + {props.children} + + + + + ); +}; + +export default LoadMore; diff --git a/src/components/column/NotificationColumn.tsx b/src/components/column/NotificationColumn.tsx index 71163e3..d4181c8 100644 --- a/src/components/column/NotificationColumn.tsx +++ b/src/components/column/NotificationColumn.tsx @@ -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 = (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 ( = (props) => width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/components/column/PostsColumn.tsx b/src/components/column/PostsColumn.tsx index 4682867..c615776 100644 --- a/src/components/column/PostsColumn.tsx +++ b/src/components/column/PostsColumn.tsx @@ -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 = (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 ( = (props) => { width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/components/column/ReactionsColumn.tsx b/src/components/column/ReactionsColumn.tsx index f53065f..600f753 100644 --- a/src/components/column/ReactionsColumn.tsx +++ b/src/components/column/ReactionsColumn.tsx @@ -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 = (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 ( = (props) => { width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/components/column/RelaysColumn.tsx b/src/components/column/RelaysColumn.tsx index 0297671..af84d4b 100644 --- a/src/components/column/RelaysColumn.tsx +++ b/src/components/column/RelaysColumn.tsx @@ -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 = (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 ( = (props) => { width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/components/column/SearchColumn.tsx b/src/components/column/SearchColumn.tsx index 8217df5..3f622bf 100644 --- a/src/components/column/SearchColumn.tsx +++ b/src/components/column/SearchColumn.tsx @@ -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 = (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 = (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 = (props) => { }; }); + createEffect(() => { + loadMore.setEvents(events()); + }); + return ( = (props) => { width={props.column.width} columnIndex={props.columnIndex} lastColumn={props.lastColumn} + timelineRef={loadMore.timelineRef} > - + + + ); }; diff --git a/src/hooks/useScroll.ts b/src/hooks/useScroll.ts new file mode 100644 index 0000000..0b3e387 --- /dev/null +++ b/src/hooks/useScroll.ts @@ -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(); + + 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; diff --git a/src/locales/en.ts b/src/locales/en.ts index 80d64a2..ceb76d0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 9dbe8cb..d58d8f4 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -31,6 +31,8 @@ export default { myPosts: '自分の投稿', myReactions: '自分のリアクション', back: '戻る', + loadLatest: '最新の投稿を読み込む', + loadOld: '古い投稿を読み込む', config: { columnWidth: 'カラム幅', widest: '特大', diff --git a/src/nostr/event/comparator.ts b/src/nostr/event/comparator.ts index 32bb62b..74ca111 100644 --- a/src/nostr/event/comparator.ts +++ b/src/nostr/event/comparator.ts @@ -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)); diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index e428f5e..38b023c 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -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([]); + const [eose, setEose] = createSignal(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;