diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index eac41784..b858572b 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -168,7 +168,7 @@ export default function Note(props: NoteProps) { useLayoutEffect(() => { if (entry && inView && extendable === false) { - const h = entry?.target.clientHeight ?? 0; + const h = (entry?.target as HTMLDivElement)?.offsetHeight ?? 0; if (h > 650) { setExtendable(true); } diff --git a/packages/app/src/Element/Timeline.tsx b/packages/app/src/Element/Timeline.tsx index d5b2999d..5628258a 100644 --- a/packages/app/src/Element/Timeline.tsx +++ b/packages/app/src/Element/Timeline.tsx @@ -1,10 +1,10 @@ import "./Timeline.css"; import { FormattedMessage } from "react-intl"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useInView } from "react-intersection-observer"; import ArrowUp from "Icons/ArrowUp"; -import { dedupeByPubkey, tagFilterOfTextRepost } from "Util"; +import { dedupeById, dedupeByPubkey, tagFilterOfTextRepost } from "Util"; import ProfileImage from "Element/ProfileImage"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "@snort/nostr"; @@ -16,6 +16,9 @@ import NoteReaction from "Element/NoteReaction"; import useModeration from "Hooks/useModeration"; import ProfilePreview from "./ProfilePreview"; import Skeleton from "Element/Skeleton"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "State/Store"; +import { setTimeline } from "State/Cache"; export interface TimelineProps { postsOnly: boolean; @@ -38,7 +41,9 @@ export default function Timeline({ relay, }: TimelineProps) { const { muted, isMuted } = useModeration(); - const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { + const dispatch = useDispatch(); + const cache = useSelector((s: RootState) => s.cache.timeline); + const feed = useTimelineFeed(subject, { method, window: timeWindow, relay, @@ -52,20 +57,34 @@ export default function Timeline({ ?.filter(a => (postsOnly ? !a.tags.some(b => b[0] === "e") : true)) .filter(a => ignoreModeration || !isMuted(a.pubkey)); }, - [postsOnly, muted] + [postsOnly, muted, ignoreModeration] ); const mainFeed = useMemo(() => { - return filterPosts(main.notes); - }, [main, filterPosts]); + return filterPosts(cache.main); + }, [cache, filterPosts]); const latestFeed = useMemo(() => { - return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)); - }, [latest, mainFeed, filterPosts]); + return filterPosts(cache.latest).filter(a => !mainFeed.some(b => b.id === a.id)); + }, [cache, filterPosts]); const latestAuthors = useMemo(() => { return dedupeByPubkey(latestFeed).map(e => e.pubkey); }, [latestFeed]); + useEffect(() => { + const key = `${subject.type}-${subject.discriminator}`; + const newFeed = key !== cache.key; + dispatch( + setTimeline({ + key: key, + main: dedupeById([...(newFeed ? [] : cache.main), ...feed.main.notes]), + latest: [...feed.latest.notes], + related: dedupeById([...(newFeed ? [] : cache.related), ...feed.related.notes]), + parent: dedupeById([...(newFeed ? [] : cache.parent), ...feed.parent.notes]), + }) + ); + }, [feed.main, feed.latest, feed.related, feed.parent]); + function eventElement(e: TaggedRawEvent) { switch (e.kind) { case EventKind.SetMetadata: { @@ -74,9 +93,9 @@ export default function Timeline({ case EventKind.TextNote: { const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1); if (eRef) { - return a.id === eRef)} />; + return a.id === eRef)} />; } - return ; + return ; } case EventKind.ZapReceipt: { const zap = parseZap(e); @@ -85,18 +104,17 @@ export default function Timeline({ case EventKind.Reaction: case EventKind.Repost: { const eRef = e.tags.find(a => a[0] === "e")?.at(1); - return a.id === eRef)} />; + return a.id === eRef)} />; } } } function onShowLatest(scrollToTop = false) { - showLatest(); + feed.showLatest(); if (scrollToTop) { window.scrollTo(0, 0); } } - return (
{latestFeed.length > 0 && ( @@ -126,7 +144,7 @@ export default function Timeline({ )} {mainFeed.map(eventElement)} - + diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index 92498433..04acb31f 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -11,7 +11,7 @@ import { System } from "System"; import { TimelineSubject } from "Feed/TimelineFeed"; import messages from "./messages"; -import { debounce } from "Util"; +import { debounce, unwrap } from "Util"; interface RelayOption { url: string; @@ -46,11 +46,12 @@ export default function RootPage() { } }); const [relay, setRelay] = useState(); - const [globalRelays, setGlobalRelays] = useState([]); + const [allRelays, setAllRelays] = useState(); const tagTabs = tags.map((t, idx) => { return { text: `#${t}`, value: idx + 3 }; }); const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs]; + const isGlobal = loggedOut || tab.value === RootTab.Global.value; function followHints() { if (follows?.length === 0 && pubKey && tab !== RootTab.Global) { @@ -69,28 +70,63 @@ export default function RootPage() { } } + function globalRelaySelector() { + if (!isGlobal || !allRelays || allRelays.length === 0) return null; + + const paidRelays = allRelays.filter(a => a.paid); + const publicRelays = allRelays.filter(a => !a.paid); + return ( +
+ +   + +
+ ); + } + useEffect(() => { - return debounce(1_000, () => { - const ret: RelayOption[] = []; - System.Sockets.forEach((v, k) => { - ret.push({ - url: k, - paid: v.Info?.limitation?.payment_required ?? false, + if (isGlobal) { + return debounce(500, () => { + const ret: RelayOption[] = []; + System.Sockets.forEach((v, k) => { + ret.push({ + url: k, + paid: v.Info?.limitation?.payment_required ?? false, + }); }); + ret.sort(a => (a.paid ? -1 : 1)); + + if (ret.length > 0 && !relay) { + setRelay(ret[0]); + } + setAllRelays(ret); }); - ret.sort(a => (a.paid ? 1 : -1)); + } + }, [relays, relay, tab]); - if (ret.length > 0 && !relay) { - setRelay(ret[0]); - } - setGlobalRelays(ret); - }); - }, [relays, relay]); - - const isGlobal = loggedOut || tab.value === RootTab.Global.value; const timelineSubect: TimelineSubject = (() => { if (isGlobal) { - return { type: "global", items: [], discriminator: "all" }; + return { type: "global", items: [], discriminator: `all-${relay?.url}` }; } if (tab.value >= 3) { const hashtag = tab.text.slice(1); @@ -100,49 +136,29 @@ export default function RootPage() { return { type: "pubkey", items: follows, discriminator: "follows" }; })(); - const paidRelays = globalRelays.filter(a => a.paid); - const publicRelays = globalRelays.filter(a => !a.paid); - return ( - <> -
- {pubKey && } - {isGlobal && globalRelays.length > 0 && ( -
- -   - -
- )} -
- {followHints()} + function renderTimeline() { + if (isGlobal && !relay) return null; + + return ( + ); + } + + return ( + <> +
+ {pubKey && } + {globalRelaySelector()} +
+ {followHints()} + {renderTimeline()} ); } diff --git a/packages/app/src/State/Cache.ts b/packages/app/src/State/Cache.ts new file mode 100644 index 00000000..9d2201e7 --- /dev/null +++ b/packages/app/src/State/Cache.ts @@ -0,0 +1,38 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { TaggedRawEvent, u256 } from "@snort/nostr"; + +export interface TimelineCache { + key: string; + main: TaggedRawEvent[]; + related: TaggedRawEvent[]; + latest: TaggedRawEvent[]; + parent: TaggedRawEvent[]; +} + +export interface FeedCache { + timeline: TimelineCache; +} + +const InitState = { + timeline: { + key: "", + main: [], + related: [], + latest: [], + parent: [], + }, +} as FeedCache; + +const CacheSlice = createSlice({ + name: "Cache", + initialState: InitState, + reducers: { + setTimeline: (state, action: PayloadAction) => { + state.timeline = action.payload; + }, + }, +}); + +export const { setTimeline } = CacheSlice.actions; + +export const reducer = CacheSlice.reducer; diff --git a/packages/app/src/State/Store.ts b/packages/app/src/State/Store.ts index 16650559..196dad23 100644 --- a/packages/app/src/State/Store.ts +++ b/packages/app/src/State/Store.ts @@ -1,11 +1,13 @@ import { configureStore } from "@reduxjs/toolkit"; import { reducer as LoginReducer } from "State/Login"; import { reducer as UsersReducer } from "State/Users"; +import { reducer as CacheReducer } from "State/Cache"; const store = configureStore({ reducer: { login: LoginReducer, users: UsersReducer, + cache: CacheReducer, }, }); diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 8d9fea15..49e236bb 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -177,6 +177,23 @@ export function dedupeByPubkey(events: TaggedRawEvent[]) { return deduped.list as TaggedRawEvent[]; } +export function dedupeById(events: TaggedRawEvent[]) { + const deduped = events.reduce( + ({ list, seen }: { list: TaggedRawEvent[]; seen: Set }, ev) => { + if (seen.has(ev.id)) { + return { list, seen }; + } + seen.add(ev.id); + return { + seen, + list: [...list, ev], + }; + }, + { list: [], seen: new Set([]) } + ); + return deduped.list as TaggedRawEvent[]; +} + export function unwrap(v: T | undefined | null): T { if (v === undefined || v === null) { throw new Error("missing value");