From 8b0083ffc5ffa8ad8748f6a565099eaa15dbdbec Mon Sep 17 00:00:00 2001 From: Zexi Date: Fri, 3 Nov 2023 05:16:55 +0800 Subject: [PATCH] fix: auto fetch more (#2472) * fix: auto fetch more * feat: use union type --- web/src/components/Memo.tsx | 44 ++++++++-------- web/src/components/MemoList.tsx | 79 ++++++++++------------------ web/src/pages/Explore.tsx | 92 ++++++++++++++++----------------- web/src/pages/Home.tsx | 6 +-- web/src/store/module/memo.ts | 10 +++- web/src/store/reducer/memo.ts | 12 ++++- 6 files changed, 114 insertions(+), 129 deletions(-) diff --git a/web/src/components/Memo.tsx b/web/src/components/Memo.tsx index b0f74ef3..7e90ac56 100644 --- a/web/src/components/Memo.tsx +++ b/web/src/components/Memo.tsx @@ -43,13 +43,20 @@ const Memo: React.FC = (props: Props) => { const [displayTime, setDisplayTime] = useState(getRelativeTimeString(memo.displayTs)); const memoContainerRef = useRef(null); const readonly = memo.creatorUsername !== user?.username; - const creator = userV1Store.getUserByUsername(memo.creatorUsername); + const [creator, setCreator] = useState(userV1Store.getUserByUsername(memo.creatorUsername)); const referenceRelations = memo.relationList.filter((relation) => relation.type === "REFERENCE"); const commentRelations = memo.relationList.filter((relation) => relation.relatedMemoId === memo.id && relation.type === "COMMENT"); // Prepare memo creator. useEffect(() => { - userV1Store.getOrFetchUserByUsername(memo.creatorUsername); + if (creator) return; + + const fn = async () => { + const user = await userV1Store.getOrFetchUserByUsername(memo.creatorUsername); + setCreator(user); + }; + + fn(); }, [memo.creatorUsername]); // Update display time string. @@ -68,30 +75,23 @@ const Memo: React.FC = (props: Props) => { // Lazy rendering. useEffect(() => { - if (shouldRender) { - return; - } + if (shouldRender) return; + if (!memoContainerRef.current) return; - const root = document.body.querySelector("#root"); - if (root) { - const checkShouldRender = () => { - if (root.scrollTop + window.innerHeight > (memoContainerRef.current?.offsetTop || 0)) { - setShouldRender(true); - root.removeEventListener("scroll", checkShouldRender); - return true; - } - }; + const observer = new IntersectionObserver(([entry]) => { + if (!entry.isIntersecting) return; + observer.disconnect(); - if (checkShouldRender()) { - return; - } - root.addEventListener("scroll", checkShouldRender); - } + setShouldRender(true); + }); + observer.observe(memoContainerRef.current); + + return () => observer.disconnect(); }, [lazyRendering, filterStore.state]); if (!shouldRender) { // Render a placeholder to occupy the space. - return
; + return
; } const handleGotoMemoDetailPage = (event: React.MouseEvent) => { @@ -299,7 +299,9 @@ const Memo: React.FC = (props: Props) => { - {creator.nickname} + + {creator.nickname || creator.username} + diff --git a/web/src/components/MemoList.tsx b/web/src/components/MemoList.tsx index 9d487a46..c99fc0b4 100644 --- a/web/src/components/MemoList.tsx +++ b/web/src/components/MemoList.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; import { toast } from "react-hot-toast"; import { useParams } from "react-router-dom"; +import MemoFilter from "@/components/MemoFilter"; import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts"; import { getTimeStampByDate } from "@/helpers/datetime"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -16,14 +17,14 @@ const MemoList: React.FC = () => { const memoStore = useMemoStore(); const filterStore = useFilterStore(); const filter = filterStore.state; - const { memos } = memoStore.state; - const [isFetching, setIsFetching] = useState(true); - const [isComplete, setIsComplete] = useState(false); + const { loadingStatus, memos } = memoStore.state; const user = useCurrentUser(); const { tag: tagQuery, duration, text: textQuery, visibility } = filter; const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || textQuery || visibility); const username = params.username || user?.username || ""; + const fetchMoreRef = useRef(null); + const shownMemos = ( showMemoFilter ? memos.filter((memo) => { @@ -74,74 +75,48 @@ const MemoList: React.FC = () => { const sortedMemos = pinnedMemos.concat(unpinnedMemos).filter((m) => m.rowStatus === "NORMAL"); useEffect(() => { - memoStore - .fetchMemos(username) - .then((fetchedMemos) => { - if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) { - setIsComplete(true); - } else { - setIsComplete(false); - } - setIsFetching(false); - }) - .catch((error) => { - console.error(error); - toast.error(error.response.data.message); - }); - }, [user?.username]); - - useEffect(() => { - const pageWrapper = document.body.querySelector(".page-wrapper"); - if (pageWrapper) { - pageWrapper.scrollTo(0, 0); + const root = document.body.querySelector("#root"); + if (root) { + root.scrollTo(0, 0); } }, [filter]); useEffect(() => { - if (isFetching || isComplete) { - return; - } - if (sortedMemos.length < DEFAULT_MEMO_LIMIT) { - handleFetchMoreClick(); - return; - } + if (!fetchMoreRef.current) return; + const observer = new IntersectionObserver(([entry]) => { - if (entry.isIntersecting) { - handleFetchMoreClick(); - observer.unobserve(entry.target); - } + if (!entry.isIntersecting) return; + observer.disconnect(); + handleFetchMoreClick(); }); - }, [isFetching, isComplete, filter, sortedMemos.length]); + observer.observe(fetchMoreRef.current); + + return () => observer.disconnect(); + }, [loadingStatus]); const handleFetchMoreClick = async () => { try { - setIsFetching(true); - const fetchedMemos = await memoStore.fetchMemos(username, DEFAULT_MEMO_LIMIT, memos.length); - if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) { - setIsComplete(true); - } else { - setIsComplete(false); - } - setIsFetching(false); + await memoStore.fetchMemos(username, DEFAULT_MEMO_LIMIT, memos.length); } catch (error: any) { - console.error(error); toast.error(error.response.data.message); } }; return (
+ {sortedMemos.map((memo) => ( - + ))} - {isFetching ? ( + + {loadingStatus === "fetching" ? (

{t("memo.fetching-data")}

) : (
- {isComplete ? ( + {loadingStatus === "complete" ? ( sortedMemos.length === 0 && (
@@ -149,11 +124,9 @@ const MemoList: React.FC = () => {
) ) : ( - <> - - {t("memo.fetch-more")} - - + + {t("memo.fetch-more")} + )}
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 22d15ffe..5efe822a 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -1,28 +1,23 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; import { toast } from "react-hot-toast"; -import { useLocation } from "react-router-dom"; import Empty from "@/components/Empty"; import Memo from "@/components/Memo"; import MemoFilter from "@/components/MemoFilter"; import MobileHeader from "@/components/MobileHeader"; import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts"; -import useLoading from "@/hooks/useLoading"; import { TAG_REG } from "@/labs/marked/parser"; import { useFilterStore, useMemoStore } from "@/store/module"; import { useTranslate } from "@/utils/i18n"; const Explore = () => { const t = useTranslate(); - const location = useLocation(); const filterStore = useFilterStore(); const memoStore = useMemoStore(); const filter = filterStore.state; - const { memos } = memoStore.state; - const [isComplete, setIsComplete] = useState(false); - const loadingState = useLoading(); - + const { loadingStatus, memos } = memoStore.state; const { tag: tagQuery, text: textQuery } = filter; const showMemoFilter = Boolean(tagQuery || textQuery); + const fetchMoreRef = useRef(null); const fetchedMemos = showMemoFilter ? memos.filter((memo) => { @@ -58,30 +53,22 @@ const Explore = () => { .sort((mi, mj) => mj.displayTs - mi.displayTs); useEffect(() => { - memoStore - .fetchAllMemos(DEFAULT_MEMO_LIMIT, 0) - .then((fetchedMemos) => { - if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) { - setIsComplete(true); - } - loadingState.setFinish(); - }) - .catch((error) => { - console.error(error); - toast.error(error.response.data.message); - }); - }, [location]); + if (!fetchMoreRef.current) return; + + const observer = new IntersectionObserver(([entry]) => { + if (!entry.isIntersecting) return; + observer.disconnect(); + handleFetchMoreClick(); + }); + observer.observe(fetchMoreRef.current); + + return () => observer.disconnect(); + }, [loadingStatus]); const handleFetchMoreClick = async () => { try { - const fetchedMemos = await memoStore.fetchAllMemos(DEFAULT_MEMO_LIMIT, memos.length); - if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) { - setIsComplete(true); - } else { - setIsComplete(false); - } + await memoStore.fetchAllMemos(DEFAULT_MEMO_LIMIT, memos.length); } catch (error: any) { - console.error(error); toast.error(error.response.data.message); } }; @@ -89,26 +76,35 @@ const Explore = () => { return (
- {!loadingState.isLoading && ( -
- - {sortedMemos.map((memo) => { - return ; - })} - {isComplete ? ( - sortedMemos.length === 0 && ( -
- -

{t("message.no-data")}

-
- ) - ) : ( -

- {t("memo.fetch-more")} -

- )} -
- )} +
+ + {sortedMemos.map((memo) => ( + + ))} + + {loadingStatus === "fetching" ? ( +
+

{t("memo.fetching-data")}

+
+ ) : ( +
+
+ {loadingStatus === "complete" ? ( + sortedMemos.length === 0 && ( +
+ +

{t("message.no-data")}

+
+ ) + ) : ( + + {t("memo.fetch-more")} + + )} +
+
+ )} +
); }; diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 1d8c29df..1547e695 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,6 +1,5 @@ import HomeSidebar from "@/components/HomeSidebar"; import MemoEditor from "@/components/MemoEditor"; -import MemoFilter from "@/components/MemoFilter"; import MemoList from "@/components/MemoList"; import MobileHeader from "@/components/MobileHeader"; @@ -9,10 +8,7 @@ const Home = () => {
-
- - -
+
diff --git a/web/src/store/module/memo.ts b/web/src/store/module/memo.ts index 664b0b62..19fa77fb 100644 --- a/web/src/store/module/memo.ts +++ b/web/src/store/module/memo.ts @@ -2,7 +2,7 @@ import { omit } from "lodash-es"; import * as api from "@/helpers/api"; import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts"; import store, { useAppSelector } from "../"; -import { createMemo, deleteMemo, patchMemo, upsertMemos } from "../reducer/memo"; +import { updateLoadingStatus, createMemo, deleteMemo, patchMemo, upsertMemos } from "../reducer/memo"; import { useMemoCacheStore } from "../v1"; export const convertResponseModelMemo = (memo: Memo): Memo => { @@ -40,12 +40,17 @@ export const useMemoStore = () => { if (username) { memoFind.creatorUsername = username; } + + store.dispatch(updateLoadingStatus("fetching")); const { data } = await api.getMemoList(memoFind); const fetchedMemos = data.map((m) => convertResponseModelMemo(m)); store.dispatch(upsertMemos(fetchedMemos)); + store.dispatch(updateLoadingStatus(fetchedMemos.length === limit ? "incomplete" : "complete")); + for (const m of fetchedMemos) { memoCacheStore.setMemoCache(m); } + return fetchedMemos; }, fetchAllMemos: async (limit = DEFAULT_MEMO_LIMIT, offset?: number) => { @@ -54,9 +59,12 @@ export const useMemoStore = () => { limit, offset, }; + + store.dispatch(updateLoadingStatus("fetching")); const { data } = await api.getAllMemos(memoFind); const fetchedMemos = data.map((m) => convertResponseModelMemo(m)); store.dispatch(upsertMemos(fetchedMemos)); + store.dispatch(updateLoadingStatus(fetchedMemos.length === limit ? "incomplete" : "complete")); for (const m of fetchedMemos) { memoCacheStore.setMemoCache(m); diff --git a/web/src/store/reducer/memo.ts b/web/src/store/reducer/memo.ts index 3f8718f0..2c9ef6a9 100644 --- a/web/src/store/reducer/memo.ts +++ b/web/src/store/reducer/memo.ts @@ -1,16 +1,26 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { uniqBy } from "lodash-es"; +type LoadingStatus = "incomplete" | "fetching" | "complete"; + interface State { + loadingStatus: LoadingStatus; memos: Memo[]; } const memoSlice = createSlice({ name: "memo", initialState: { + loadingStatus: "incomplete", memos: [], } as State, reducers: { + updateLoadingStatus: (state, action: PayloadAction) => { + return { + ...state, + loadingStatus: action.payload, + }; + }, upsertMemos: (state, action: PayloadAction) => { return { ...state, @@ -51,6 +61,6 @@ const memoSlice = createSlice({ }, }); -export const { upsertMemos, createMemo, patchMemo, deleteMemo } = memoSlice.actions; +export const { updateLoadingStatus, upsertMemos, createMemo, patchMemo, deleteMemo } = memoSlice.actions; export default memoSlice.reducer;