From c6e6b7b93beb232cbe31be64a89fce7350b563c0 Mon Sep 17 00:00:00 2001
From: Brian <39704881+liltrendi@users.noreply.github.com>
Date: Sun, 25 May 2025 18:12:47 +0300
Subject: [PATCH] feat: add infinite scrolling for memos (#4715)

* feat(WIP): add prop-driven infinite scroll

* feat(WIP): add global toggle for infinite scrolling under settings

* feat: integrate newly added scroll-mode selection state with backend for persists across refreshes

* fix: default to infinite scrolling over load more button & remove redundant setting option

* fix: rectify linting issues

* Update web/src/components/PagedMemoList/PagedMemoList.tsx

---------

Co-authored-by: Johnny <yourselfhosted@gmail.com>
---
 .../PagedMemoList/PagedMemoList.tsx           |  20 ++-
 .../Settings/PreferencesSection.tsx           |  25 +---
 .../types/proto/google/protobuf/descriptor.ts | 139 +++++++++++++++---
 3 files changed, 140 insertions(+), 44 deletions(-)

diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx
index 2902d9375..211ffcb1c 100644
--- a/web/src/components/PagedMemoList/PagedMemoList.tsx
+++ b/web/src/components/PagedMemoList/PagedMemoList.tsx
@@ -1,5 +1,5 @@
 import { Button } from "@usememos/mui";
-import { ArrowDownIcon, ArrowUpIcon, LoaderIcon } from "lucide-react";
+import { ArrowUpIcon, LoaderIcon } from "lucide-react";
 import { observer } from "mobx-react-lite";
 import { useEffect, useState } from "react";
 import { matchPath } from "react-router-dom";
@@ -71,6 +71,18 @@ const PagedMemoList = observer((props: Props) => {
     refreshList();
   }, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]);
 
+  useEffect(() => {
+    if (!state.nextPageToken) return;
+    const handleScroll = () => {
+      const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
+      if (nearBottom && !state.isRequesting) {
+        fetchMoreMemos(state.nextPageToken);
+      }
+    };
+    window.addEventListener("scroll", handleScroll);
+    return () => window.removeEventListener("scroll", handleScroll);
+  }, [state.nextPageToken, state.isRequesting]);
+
   const children = (
     <div className="flex flex-col justify-start items-start w-full max-w-full">
       <MasonryView
@@ -93,12 +105,6 @@ const PagedMemoList = observer((props: Props) => {
             </div>
           ) : (
             <div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
-              {state.nextPageToken && (
-                <Button variant="plain" onClick={() => fetchMoreMemos(state.nextPageToken)}>
-                  {t("memo.load-more")}
-                  <ArrowDownIcon className="ml-1 w-4 h-auto" />
-                </Button>
-              )}
               <BackToTop />
             </div>
           )}
diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx
index 2ac169d52..b9df6a019 100644
--- a/web/src/components/Settings/PreferencesSection.tsx
+++ b/web/src/components/Settings/PreferencesSection.tsx
@@ -15,44 +15,33 @@ const PreferencesSection = observer(() => {
   const setting = userStore.state.userSetting as UserSetting;
 
   const handleLocaleSelectChange = async (locale: Locale) => {
-    await userStore.updateUserSetting(
-      {
-        locale,
-      },
-      ["locale"],
-    );
+    await userStore.updateUserSetting({ locale }, ["locale"]);
   };
 
   const handleAppearanceSelectChange = async (appearance: Appearance) => {
-    await userStore.updateUserSetting(
-      {
-        appearance,
-      },
-      ["appearance"],
-    );
+    await userStore.updateUserSetting({ appearance }, ["appearance"]);
   };
 
   const handleDefaultMemoVisibilityChanged = async (value: string) => {
-    await userStore.updateUserSetting(
-      {
-        memoVisibility: value,
-      },
-      ["memo_visibility"],
-    );
+    await userStore.updateUserSetting({ memoVisibility: value }, ["memo_visibility"]);
   };
 
   return (
     <div className="w-full flex flex-col gap-2 pt-2 pb-4">
       <p className="font-medium text-gray-700 dark:text-gray-500">{t("common.basic")}</p>
+
       <div className="w-full flex flex-row justify-between items-center">
         <span>{t("common.language")}</span>
         <LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
       </div>
+
       <div className="w-full flex flex-row justify-between items-center">
         <span>{t("setting.preference-section.theme")}</span>
         <AppearanceSelect value={setting.appearance as Appearance} onChange={handleAppearanceSelectChange} />
       </div>
+
       <p className="font-medium text-gray-700 dark:text-gray-500">{t("setting.preference")}</p>
+
       <div className="w-full flex flex-row justify-between items-center">
         <span className="truncate">{t("setting.preference-section.default-memo-visibility")}</span>
         <Select
diff --git a/web/src/types/proto/google/protobuf/descriptor.ts b/web/src/types/proto/google/protobuf/descriptor.ts
index 9f55f0344..89514564e 100644
--- a/web/src/types/proto/google/protobuf/descriptor.ts
+++ b/web/src/types/proto/google/protobuf/descriptor.ts
@@ -35,7 +35,7 @@ export enum Edition {
   EDITION_2024 = "EDITION_2024",
   /**
    * EDITION_1_TEST_ONLY - Placeholder editions for testing feature resolution.  These should not be
-   * used or relyed on outside of tests.
+   * used or relied on outside of tests.
    */
   EDITION_1_TEST_ONLY = "EDITION_1_TEST_ONLY",
   EDITION_2_TEST_ONLY = "EDITION_2_TEST_ONLY",
@@ -177,11 +177,19 @@ export interface FileDescriptorProto {
    * The supported values are "proto2", "proto3", and "editions".
    *
    * If `edition` is present, this value must be "editions".
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
    */
   syntax?:
     | string
     | undefined;
-  /** The edition of the proto file. */
+  /**
+   * The edition of the proto file.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   edition?: Edition | undefined;
 }
 
@@ -828,7 +836,12 @@ export interface FileOptions {
   rubyPackage?:
     | string
     | undefined;
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -966,7 +979,12 @@ export interface MessageOptions {
   deprecatedLegacyJsonFieldConflicts?:
     | boolean
     | undefined;
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -976,12 +994,13 @@ export interface MessageOptions {
 
 export interface FieldOptions {
   /**
+   * NOTE: ctype is deprecated. Use `features.(pb.cpp).string_type` instead.
    * The ctype option instructs the C++ code generator to use a different
    * representation of the field than it normally would.  See the specific
    * options below.  This option is only implemented to support use of
    * [ctype=CORD] and [ctype=STRING] (the default) on non-repeated fields of
-   * type "bytes" in the open source release -- sorry, we'll try to include
-   * other types in a future version!
+   * type "bytes" in the open source release.
+   * TODO: make ctype actually deprecated.
    */
   ctype?:
     | FieldOptions_CType
@@ -1070,7 +1089,12 @@ export interface FieldOptions {
   retention?: FieldOptions_OptionRetention | undefined;
   targets: FieldOptions_OptionTargetType[];
   editionDefaults: FieldOptions_EditionDefault[];
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?: FeatureSet | undefined;
   featureSupport?:
     | FieldOptions_FeatureSupport
@@ -1169,11 +1193,7 @@ export function fieldOptions_JSTypeToNumber(object: FieldOptions_JSType): number
   }
 }
 
-/**
- * If set to RETENTION_SOURCE, the option will be omitted from the binary.
- * Note: as of January 2023, support for this is in progress and does not yet
- * have an effect (b/264593489).
- */
+/** If set to RETENTION_SOURCE, the option will be omitted from the binary. */
 export enum FieldOptions_OptionRetention {
   RETENTION_UNKNOWN = "RETENTION_UNKNOWN",
   RETENTION_RUNTIME = "RETENTION_RUNTIME",
@@ -1216,8 +1236,7 @@ export function fieldOptions_OptionRetentionToNumber(object: FieldOptions_Option
 /**
  * This indicates the types of entities that the field may apply to when used
  * as an option. If it is unset, then the field may be freely used as an
- * option on any kind of entity. Note: as of January 2023, support for this is
- * in progress and does not yet have an effect (b/264593489).
+ * option on any kind of entity.
  */
 export enum FieldOptions_OptionTargetType {
   TARGET_TYPE_UNKNOWN = "TARGET_TYPE_UNKNOWN",
@@ -1341,7 +1360,12 @@ export interface FieldOptions_FeatureSupport {
 }
 
 export interface OneofOptions {
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -1379,7 +1403,12 @@ export interface EnumOptions {
   deprecatedLegacyJsonFieldConflicts?:
     | boolean
     | undefined;
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -1397,7 +1426,12 @@ export interface EnumValueOptions {
   deprecated?:
     | boolean
     | undefined;
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -1418,7 +1452,12 @@ export interface EnumValueOptions {
 }
 
 export interface ServiceOptions {
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -1446,7 +1485,12 @@ export interface MethodOptions {
   idempotencyLevel?:
     | MethodOptions_IdempotencyLevel
     | undefined;
-  /** Any features defined in the specific edition. */
+  /**
+   * Any features defined in the specific edition.
+   * WARNING: This field should only be used by protobuf plugins or special
+   * cases like the proto compiler. Other uses are discouraged and
+   * developers should rely on the protoreflect APIs for their client language.
+   */
   features?:
     | FeatureSet
     | undefined;
@@ -1549,6 +1593,7 @@ export interface FeatureSet {
   utf8Validation?: FeatureSet_Utf8Validation | undefined;
   messageEncoding?: FeatureSet_MessageEncoding | undefined;
   jsonFormat?: FeatureSet_JsonFormat | undefined;
+  enforceNamingStyle?: FeatureSet_EnforceNamingStyle | undefined;
 }
 
 export enum FeatureSet_FieldPresence {
@@ -1791,6 +1836,45 @@ export function featureSet_JsonFormatToNumber(object: FeatureSet_JsonFormat): nu
   }
 }
 
+export enum FeatureSet_EnforceNamingStyle {
+  ENFORCE_NAMING_STYLE_UNKNOWN = "ENFORCE_NAMING_STYLE_UNKNOWN",
+  STYLE2024 = "STYLE2024",
+  STYLE_LEGACY = "STYLE_LEGACY",
+  UNRECOGNIZED = "UNRECOGNIZED",
+}
+
+export function featureSet_EnforceNamingStyleFromJSON(object: any): FeatureSet_EnforceNamingStyle {
+  switch (object) {
+    case 0:
+    case "ENFORCE_NAMING_STYLE_UNKNOWN":
+      return FeatureSet_EnforceNamingStyle.ENFORCE_NAMING_STYLE_UNKNOWN;
+    case 1:
+    case "STYLE2024":
+      return FeatureSet_EnforceNamingStyle.STYLE2024;
+    case 2:
+    case "STYLE_LEGACY":
+      return FeatureSet_EnforceNamingStyle.STYLE_LEGACY;
+    case -1:
+    case "UNRECOGNIZED":
+    default:
+      return FeatureSet_EnforceNamingStyle.UNRECOGNIZED;
+  }
+}
+
+export function featureSet_EnforceNamingStyleToNumber(object: FeatureSet_EnforceNamingStyle): number {
+  switch (object) {
+    case FeatureSet_EnforceNamingStyle.ENFORCE_NAMING_STYLE_UNKNOWN:
+      return 0;
+    case FeatureSet_EnforceNamingStyle.STYLE2024:
+      return 1;
+    case FeatureSet_EnforceNamingStyle.STYLE_LEGACY:
+      return 2;
+    case FeatureSet_EnforceNamingStyle.UNRECOGNIZED:
+    default:
+      return -1;
+  }
+}
+
 /**
  * A compiled specification for the defaults of a set of features.  These
  * messages are generated from FeatureSet extensions and can be used to seed
@@ -4914,6 +4998,7 @@ function createBaseFeatureSet(): FeatureSet {
     utf8Validation: FeatureSet_Utf8Validation.UTF8_VALIDATION_UNKNOWN,
     messageEncoding: FeatureSet_MessageEncoding.MESSAGE_ENCODING_UNKNOWN,
     jsonFormat: FeatureSet_JsonFormat.JSON_FORMAT_UNKNOWN,
+    enforceNamingStyle: FeatureSet_EnforceNamingStyle.ENFORCE_NAMING_STYLE_UNKNOWN,
   };
 }
 
@@ -4948,6 +5033,12 @@ export const FeatureSet: MessageFns<FeatureSet> = {
     if (message.jsonFormat !== undefined && message.jsonFormat !== FeatureSet_JsonFormat.JSON_FORMAT_UNKNOWN) {
       writer.uint32(48).int32(featureSet_JsonFormatToNumber(message.jsonFormat));
     }
+    if (
+      message.enforceNamingStyle !== undefined &&
+      message.enforceNamingStyle !== FeatureSet_EnforceNamingStyle.ENFORCE_NAMING_STYLE_UNKNOWN
+    ) {
+      writer.uint32(56).int32(featureSet_EnforceNamingStyleToNumber(message.enforceNamingStyle));
+    }
     return writer;
   },
 
@@ -5006,6 +5097,14 @@ export const FeatureSet: MessageFns<FeatureSet> = {
           message.jsonFormat = featureSet_JsonFormatFromJSON(reader.int32());
           continue;
         }
+        case 7: {
+          if (tag !== 56) {
+            break;
+          }
+
+          message.enforceNamingStyle = featureSet_EnforceNamingStyleFromJSON(reader.int32());
+          continue;
+        }
       }
       if ((tag & 7) === 4 || tag === 0) {
         break;
@@ -5027,6 +5126,8 @@ export const FeatureSet: MessageFns<FeatureSet> = {
     message.utf8Validation = object.utf8Validation ?? FeatureSet_Utf8Validation.UTF8_VALIDATION_UNKNOWN;
     message.messageEncoding = object.messageEncoding ?? FeatureSet_MessageEncoding.MESSAGE_ENCODING_UNKNOWN;
     message.jsonFormat = object.jsonFormat ?? FeatureSet_JsonFormat.JSON_FORMAT_UNKNOWN;
+    message.enforceNamingStyle = object.enforceNamingStyle ??
+      FeatureSet_EnforceNamingStyle.ENFORCE_NAMING_STYLE_UNKNOWN;
     return message;
   },
 };