From b576a2c7d9547a5fabdc1ab6924749a358df047d Mon Sep 17 00:00:00 2001
From: hyrious <hyrious@outlook.com>
Date: Tue, 24 Oct 2023 17:16:24 +0800
Subject: [PATCH 1/2] refactor(flat-stores): load more history rooms on scroll
 to bottom

---
 .../components/HomePage/RoomList/index.tsx    | 29 +++++-
 .../HomePage/MainRoomHistoryPanel/index.tsx   | 37 +++++---
 .../MainRoomListPanel/MainRoomList.tsx        | 14 +--
 .../src/HomePage/MainRoomListPanel/index.tsx  | 10 ++-
 packages/flat-stores/src/room-store.ts        | 88 ++++++++++++++++++-
 5 files changed, 153 insertions(+), 25 deletions(-)

diff --git a/packages/flat-components/src/components/HomePage/RoomList/index.tsx b/packages/flat-components/src/components/HomePage/RoomList/index.tsx
index 71d200efffd..0ea04f2e438 100644
--- a/packages/flat-components/src/components/HomePage/RoomList/index.tsx
+++ b/packages/flat-components/src/components/HomePage/RoomList/index.tsx
@@ -1,6 +1,6 @@
 import "./style.less";
 
-import React, { PropsWithChildren, ReactElement, useMemo } from "react";
+import React, { PropsWithChildren, ReactElement, useCallback, useMemo, useRef } from "react";
 import { Dropdown, Menu } from "antd";
 import { SVGDown } from "../../FlatIcons";
 
@@ -20,6 +20,7 @@ export interface RoomListProps<T extends string> {
     activeTab?: T;
     onTabActive?: (key: T) => void;
     style?: React.CSSProperties;
+    onScrollToBottom?: () => void;
 }
 
 export function RoomList<T extends string>({
@@ -29,12 +30,30 @@ export function RoomList<T extends string>({
     onTabActive,
     children,
     style,
+    onScrollToBottom,
 }: PropsWithChildren<RoomListProps<T>>): ReactElement {
     const activeTabTitle = useMemo(
         () => filters?.find(tab => tab.key === activeTab)?.title,
         [filters, activeTab],
     );
 
+    const isAtTheBottomRef = useRef(false);
+    const roomListContainerRef = useRef<HTMLDivElement>(null);
+
+    const onScroll = useCallback((): void => {
+        if (roomListContainerRef.current) {
+            const { scrollTop, clientHeight, scrollHeight } = roomListContainerRef.current;
+            const threshold = scrollHeight - 30;
+            const isAtTheBottom = scrollTop + clientHeight >= threshold;
+            if (isAtTheBottomRef.current !== isAtTheBottom) {
+                isAtTheBottomRef.current = isAtTheBottom;
+                if (isAtTheBottom && onScrollToBottom) {
+                    onScrollToBottom();
+                }
+            }
+        }
+    }, [onScrollToBottom]);
+
     return (
         <div className="room-list" style={style}>
             <div className="room-list-header">
@@ -57,7 +76,13 @@ export function RoomList<T extends string>({
                     </Dropdown>
                 )}
             </div>
-            <div className="room-list-body fancy-scrollbar">{children}</div>
+            <div
+                ref={roomListContainerRef}
+                className="room-list-body fancy-scrollbar"
+                onScroll={onScroll}
+            >
+                {children}
+            </div>
         </div>
     );
 }
diff --git a/packages/flat-pages/src/HomePage/MainRoomHistoryPanel/index.tsx b/packages/flat-pages/src/HomePage/MainRoomHistoryPanel/index.tsx
index 5c94cc32529..d257193e4d0 100644
--- a/packages/flat-pages/src/HomePage/MainRoomHistoryPanel/index.tsx
+++ b/packages/flat-pages/src/HomePage/MainRoomHistoryPanel/index.tsx
@@ -1,21 +1,36 @@
 // import "../MainRoomListPanel/MainRoomList.less";
 
-import React from "react";
+import React, { useCallback, useContext } from "react";
 import { observer } from "mobx-react-lite";
 import { MainRoomList } from "../MainRoomListPanel/MainRoomList";
 import { ListRoomsType } from "@netless/flat-server-api";
 import { RoomList } from "flat-components";
 import { useTranslate } from "@netless/flat-i18n";
+import { RoomStoreContext } from "../../components/StoreProvider";
 
-export const MainRoomHistoryPanel = observer<{ isLogin: boolean }>(function MainRoomHistoryPanel({
-    isLogin,
-}) {
-    const t = useTranslate();
-    return (
-        <RoomList title={t("history")}>
-            <MainRoomList isLogin={isLogin} listRoomsType={ListRoomsType.History} />
-        </RoomList>
-    );
-});
+interface MainRoomHistoryPanelProps {
+    isLogin: boolean;
+}
+
+export const MainRoomHistoryPanel = observer<MainRoomHistoryPanelProps>(
+    function MainRoomHistoryPanel({ isLogin }) {
+        const t = useTranslate();
+        const roomStore = useContext(RoomStoreContext);
+
+        const onScrollToBottom = useCallback((): void => {
+            void roomStore.fetchMoreRooms(ListRoomsType.History);
+        }, [roomStore]);
+
+        return (
+            <RoomList title={t("history")} onScrollToBottom={onScrollToBottom}>
+                <MainRoomList
+                    isLogin={isLogin}
+                    listRoomsType={ListRoomsType.History}
+                    roomStore={roomStore}
+                />
+            </RoomList>
+        );
+    },
+);
 
 export default MainRoomHistoryPanel;
diff --git a/packages/flat-pages/src/HomePage/MainRoomListPanel/MainRoomList.tsx b/packages/flat-pages/src/HomePage/MainRoomListPanel/MainRoomList.tsx
index 3282887b86c..e2bc37b72d4 100644
--- a/packages/flat-pages/src/HomePage/MainRoomListPanel/MainRoomList.tsx
+++ b/packages/flat-pages/src/HomePage/MainRoomListPanel/MainRoomList.tsx
@@ -15,8 +15,8 @@ import {
     errorTips,
 } from "flat-components";
 import { ListRoomsType, RoomStatus, RoomType, stopClass } from "@netless/flat-server-api";
-import { GlobalStoreContext, RoomStoreContext } from "../../components/StoreProvider";
-import { RoomItem } from "@netless/flat-stores";
+import { GlobalStoreContext } from "../../components/StoreProvider";
+import { RoomItem, RoomStore } from "@netless/flat-stores";
 import { useSafePromise } from "../../utils/hooks/lifecycle";
 import { RouteNameType, usePushHistory } from "../../utils/routes";
 import { joinRoomHandler } from "../../utils/join-room-handler";
@@ -25,18 +25,18 @@ import { FLAT_WEB_BASE_URL } from "../../constants/process";
 import { generateAvatar } from "../../utils/generate-avatar";
 
 export interface MainRoomListProps {
+    roomStore: RoomStore;
     listRoomsType: ListRoomsType;
     isLogin: boolean;
 }
 
 export const MainRoomList = observer<MainRoomListProps>(function MainRoomList({
+    roomStore,
     listRoomsType,
     isLogin,
 }) {
     const t = useTranslate();
-    const roomStore = useContext(RoomStoreContext);
     const [skeletonsVisible, setSkeletonsVisible] = useState(false);
-    const [roomUUIDs, setRoomUUIDs] = useState<string[]>();
     const [cancelModalVisible, setCancelModalVisible] = useState(false);
     const [stopModalVisible, setStopModalVisible] = useState(false);
     const [inviteModalVisible, setInviteModalVisible] = useState(false);
@@ -57,10 +57,8 @@ export const MainRoomList = observer<MainRoomListProps>(function MainRoomList({
     const refreshRooms = useCallback(
         async function refreshRooms(): Promise<void> {
             try {
-                const roomUUIDs = await sp(roomStore.listRooms(listRoomsType, { page: 1 }));
-                setRoomUUIDs(roomUUIDs);
+                await sp(roomStore.listRooms(listRoomsType));
             } catch (e) {
-                setRoomUUIDs([]);
                 errorTips(e);
             }
         },
@@ -81,6 +79,8 @@ export const MainRoomList = observer<MainRoomListProps>(function MainRoomList({
         };
     }, [refreshRooms, isLogin]);
 
+    const roomUUIDs = roomStore.roomUUIDs[listRoomsType];
+
     if (!roomUUIDs) {
         return skeletonsVisible ? <RoomListSkeletons /> : null;
     }
diff --git a/packages/flat-pages/src/HomePage/MainRoomListPanel/index.tsx b/packages/flat-pages/src/HomePage/MainRoomListPanel/index.tsx
index eb9e98e892d..d09718e97cb 100644
--- a/packages/flat-pages/src/HomePage/MainRoomListPanel/index.tsx
+++ b/packages/flat-pages/src/HomePage/MainRoomListPanel/index.tsx
@@ -1,14 +1,16 @@
-import React, { useMemo, useState } from "react";
+import React, { useContext, useMemo, useState } from "react";
 import { observer } from "mobx-react-lite";
 import { RoomList } from "flat-components";
 import { MainRoomList } from "./MainRoomList";
 import { ListRoomsType } from "@netless/flat-server-api";
 import { useTranslate } from "@netless/flat-i18n";
+import { RoomStoreContext } from "../../components/StoreProvider";
 
 export const MainRoomListPanel = observer<{ isLogin: boolean }>(function MainRoomListPanel({
     isLogin,
 }) {
     const t = useTranslate();
+    const roomStore = useContext(RoomStoreContext);
     const [activeTab, setActiveTab] = useState<"all" | "today" | "periodic">("all");
     const filters = useMemo<Array<{ key: "all" | "today" | "periodic"; title: string }>>(
         () => [
@@ -35,7 +37,11 @@ export const MainRoomListPanel = observer<{ isLogin: boolean }>(function MainRoo
             title={t("room-list")}
             onTabActive={setActiveTab}
         >
-            <MainRoomList isLogin={isLogin} listRoomsType={activeTab as ListRoomsType} />
+            <MainRoomList
+                isLogin={isLogin}
+                listRoomsType={activeTab as ListRoomsType}
+                roomStore={roomStore}
+            />
         </RoomList>
     );
 });
diff --git a/packages/flat-stores/src/room-store.ts b/packages/flat-stores/src/room-store.ts
index aad3ce5fea9..aad18e6c272 100644
--- a/packages/flat-stores/src/room-store.ts
+++ b/packages/flat-stores/src/room-store.ts
@@ -11,7 +11,6 @@ import {
     joinRoom,
     JoinRoomResult,
     listRooms,
-    ListRoomsPayload,
     ListRoomsType,
     ordinaryRoomInfo,
     periodicRoomInfo,
@@ -24,6 +23,7 @@ import {
 } from "@netless/flat-server-api";
 import { globalStore } from "./global-store";
 import { preferencesStore } from "./preferences-store";
+import { isToday } from "date-fns";
 
 export interface RoomRecording {
     beginTime: number;
@@ -66,9 +66,48 @@ export interface PeriodicRoomItem {
  * This should be the only central store for all the room info.
  */
 export class RoomStore {
+    public readonly singlePageSize = 50;
+
     public rooms = observable.map<string, RoomItem>();
     public periodicRooms = observable.map<string, PeriodicRoomItem>();
 
+    /** If `fetchMoreRooms()` returns 0 rooms, stop fetching it */
+    public hasMoreRooms: Record<ListRoomsType, boolean> = {
+        all: true,
+        periodic: true,
+        history: true,
+        today: true,
+    };
+
+    /** Don't invoke `fetchMoreRooms()` too many times */
+    public isFetchingRooms = false;
+
+    public get roomUUIDs(): Record<ListRoomsType, string[]> {
+        const roomUUIDs: Record<ListRoomsType, string[]> = {
+            all: [],
+            history: [],
+            periodic: [],
+            today: [],
+        };
+        for (const room of this.rooms.values()) {
+            const beginTime = room.beginTime ?? Date.now();
+            const isHistory = room.roomStatus === RoomStatus.Stopped;
+            const isPeriodic = Boolean(room.periodicUUID);
+            if (isHistory) {
+                roomUUIDs.history.push(room.roomUUID);
+            } else {
+                roomUUIDs.all.push(room.roomUUID);
+            }
+            if (isPeriodic) {
+                roomUUIDs.periodic.push(room.roomUUID);
+            }
+            if (isToday(beginTime)) {
+                roomUUIDs.today.push(room.roomUUID);
+            }
+        }
+        return roomUUIDs;
+    }
+
     public constructor() {
         makeAutoObservable(this);
     }
@@ -111,8 +150,8 @@ export class RoomStore {
     /**
      * @returns a list of room uuids
      */
-    public async listRooms(type: ListRoomsType, payload: ListRoomsPayload): Promise<string[]> {
-        const rooms = await listRooms(type, payload);
+    public async listRooms(type: ListRoomsType): Promise<string[]> {
+        const rooms = await listRooms(type, { page: 1 });
         const roomUUIDs: string[] = [];
         runInAction(() => {
             for (const room of rooms) {
@@ -126,6 +165,45 @@ export class RoomStore {
         return roomUUIDs;
     }
 
+    public async fetchMoreRooms(type: ListRoomsType): Promise<void> {
+        if (this.isFetchingRooms) {
+            return;
+        }
+
+        const counts = this.roomUUIDs;
+
+        const page = Math.ceil(counts[type].length / this.singlePageSize);
+        const fullPageSize = page * this.singlePageSize;
+        if (counts[type].length >= fullPageSize && this.hasMoreRooms[type]) {
+            runInAction(() => {
+                this.isFetchingRooms = true;
+            });
+
+            try {
+                const rooms = await listRooms(type, {
+                    page: page + 1,
+                });
+
+                this.hasMoreRooms[type] = rooms.length > 0;
+
+                runInAction(() => {
+                    this.isFetchingRooms = false;
+
+                    for (const room of rooms) {
+                        this.updateRoom(room.roomUUID, room.ownerUUID, {
+                            ...room,
+                            periodicUUID: room.periodicUUID || void 0,
+                        });
+                    }
+                });
+            } catch {
+                runInAction(() => {
+                    this.isFetchingRooms = false;
+                });
+            }
+        }
+    }
+
     public async cancelRoom(payload: CancelRoomPayload): Promise<void> {
         await cancelRoom(payload);
     }
@@ -218,3 +296,7 @@ export class RoomStore {
 }
 
 export const roomStore = new RoomStore();
+
+if (process.env.DEV) {
+    (window as any).roomStore = roomStore;
+}

From 7daf289697b5611e68fc3be137652b79fa655768 Mon Sep 17 00:00:00 2001
From: hyrious <hyrious@outlook.com>
Date: Tue, 24 Oct 2023 17:38:16 +0800
Subject: [PATCH 2/2] perf(flat-components): stop animation when invisible

---
 .../src/components/ClassroomPage/RaiseHand/style.less          | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less b/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less
index cc3293410f5..24e0a0ec0ee 100644
--- a/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less
+++ b/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less
@@ -23,7 +23,7 @@
         left: 50%;
         transform: translate(-50%, -50%);
         background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 50 50'%3E%3Cg filter='url(%23a)'%3E%3Cpath fill='%233381FF' fill-rule='evenodd' d='M2 25c0 12.703 10.297 23 23 23v2C11.193 50 0 38.807 0 25h2Zm46 0C48 12.297 37.703 2 25 2V0c13.807 0 25 11.193 25 25h-2Z' clip-rule='evenodd'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='a' width='60.873' height='60.873' x='-5.437' y='-5.437' color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeGaussianBlur in='BackgroundImageFix' stdDeviation='2.718'/%3E%3CfeComposite in2='SourceAlpha' operator='in' result='effect1_backgroundBlur_4735_3980'/%3E%3CfeBlend in='SourceGraphic' in2='effect1_backgroundBlur_4735_3980' result='shape'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E"); // cspell:disable-line
-        animation: loading 1.5s infinite linear;
+        animation: none;
         opacity: 0;
         transition: opacity 0.3s ease-in-out;
     }
@@ -38,6 +38,7 @@
     }
 
     &.is-active::after {
+        animation: loading 1.5s infinite linear;
         opacity: 1;
     }
 }