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; }, };