diff --git a/__tests__/components/header/HeaderSearchModal.test.tsx b/__tests__/components/header/HeaderSearchModal.test.tsx index 7e64672f89..7bc53509f6 100644 --- a/__tests__/components/header/HeaderSearchModal.test.tsx +++ b/__tests__/components/header/HeaderSearchModal.test.tsx @@ -39,6 +39,16 @@ jest.mock("react-use", () => { jest.mock("@tanstack/react-query", () => ({ useQuery: (...args: any[]) => useQueryMock(...args), + useInfiniteQuery: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + isFetchingNextPage: false, + }), + keepPreviousData: (prev: unknown) => prev, })); jest.mock("next/navigation", () => ({ useRouter: () => useRouter(), diff --git a/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx b/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx index 608161d1c3..e00b30f141 100644 --- a/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx +++ b/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx @@ -32,6 +32,16 @@ let escapeHandler: (() => void) | null = null; jest.mock("@tanstack/react-query", () => ({ useQuery: (...args: any[]) => useQueryMock(...args), + useInfiniteQuery: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + isFetchingNextPage: false, + }), + keepPreviousData: (prev: unknown) => prev, })); jest.mock("next/navigation", () => ({ diff --git a/__tests__/components/layout/AppLayout.test.tsx b/__tests__/components/layout/AppLayout.test.tsx index 90f26b674f..70211b8311 100644 --- a/__tests__/components/layout/AppLayout.test.tsx +++ b/__tests__/components/layout/AppLayout.test.tsx @@ -52,6 +52,10 @@ jest.mock("next/navigation", () => ({ usePathname: () => usePathname(), useSearchParams: () => useSearchParams(), })); +jest.mock("@/components/providers/PullToRefresh", () => ({ + __esModule: true, + default: () => null, +})); const AppLayout = require("@/components/layout/AppLayout").default; diff --git a/components/brain/my-stream/layout/LayoutContext.tsx b/components/brain/my-stream/layout/LayoutContext.tsx index 54a1b3b9e0..ccd550412a 100644 --- a/components/brain/my-stream/layout/LayoutContext.tsx +++ b/components/brain/my-stream/layout/LayoutContext.tsx @@ -371,7 +371,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ if (isAndroid) { capSpace = isKeyboardVisible ? 0 : 128; - } else if (isIos || isCapacitor) { + } else if (isIos) { capSpace = 20; } diff --git a/components/header/header-search/HeaderSearchModal.tsx b/components/header/header-search/HeaderSearchModal.tsx index 62f2117cc7..473231f5e2 100644 --- a/components/header/header-search/HeaderSearchModal.tsx +++ b/components/header/header-search/HeaderSearchModal.tsx @@ -26,7 +26,7 @@ import { useWaveDropsSearch } from "@/hooks/useWaveDropsSearch"; import { useWaveChatScrollOptional } from "@/contexts/wave/WaveChatScrollContext"; import { commonApiFetch } from "@/services/api/common-api"; import { ChevronLeftIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { FocusTrap } from "focus-trap-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -183,8 +183,6 @@ export default function HeaderSearchModal({ ); const [debouncedValue, setDebouncedValue] = useState(""); - const [allowProfileFetch, setAllowProfileFetch] = useState(false); - const [allowWaveFetch, setAllowWaveFetch] = useState(false); useDebounce( () => { setDebouncedValue(searchValue); @@ -321,7 +319,7 @@ export default function HeaderSearchModal({ }, []); const sharedQueryDefaults = { - keepPreviousData: false, + placeholderData: keepPreviousData, } as const; const { @@ -338,9 +336,7 @@ export default function HeaderSearchModal({ param: trimmedDebouncedValue, }, }), - enabled: - shouldSearchDefault && - (selectedCategory === CATEGORY.PROFILES || allowProfileFetch), + enabled: shouldSearchDefault, ...sharedQueryDefaults, }); @@ -370,15 +366,9 @@ export default function HeaderSearchModal({ refetch: refetchWaves, } = useWaves({ identity: null, - waveName: - shouldSearchDefault && - (selectedCategory === CATEGORY.WAVES || allowWaveFetch) - ? trimmedDebouncedValue - : null, + waveName: shouldSearchDefault ? trimmedDebouncedValue : null, limit: 20, - enabled: - shouldSearchDefault && - (selectedCategory === CATEGORY.WAVES || allowWaveFetch), + enabled: shouldSearchDefault, }); const pageResults = useMemo(() => { @@ -446,12 +436,8 @@ export default function HeaderSearchModal({ }, [shouldSearchPages, trimmedSearchValue, pageCatalog]); const profileResults: CommunityMemberMinimal[] = useMemo( - () => - shouldSearchDefault && - (selectedCategory === CATEGORY.PROFILES || allowProfileFetch) - ? (profiles ?? []) - : [], - [shouldSearchDefault, selectedCategory, allowProfileFetch, profiles] + () => (shouldSearchDefault ? (profiles ?? []) : []), + [shouldSearchDefault, profiles] ); const nftResults: NFTSearchResult[] = useMemo( @@ -460,81 +446,10 @@ export default function HeaderSearchModal({ ); const waveResults: ApiWave[] = useMemo( - () => - shouldSearchDefault && - (selectedCategory === CATEGORY.WAVES || allowWaveFetch) - ? (waves ?? []) - : [], - [shouldSearchDefault, selectedCategory, allowWaveFetch, waves] + () => (shouldSearchDefault ? (waves ?? []) : []), + [shouldSearchDefault, waves] ); - const nftsSettled = - shouldSearchNfts && - !isFetchingNfts && - (nfts !== undefined || Boolean(nftsError)); - - const profilesSettled = - shouldSearchDefault && - (selectedCategory === CATEGORY.PROFILES || allowProfileFetch) && - !isFetchingProfiles && - (profiles !== undefined || Boolean(profilesError)); - - useEffect(() => { - setAllowProfileFetch(false); - setAllowWaveFetch(false); - }, [debouncedValue, shouldSearchDefault]); - - useEffect(() => { - if (selectedCategory === CATEGORY.PROFILES) { - setAllowProfileFetch(true); - } - if (selectedCategory === CATEGORY.WAVES) { - setAllowWaveFetch(true); - } - }, [selectedCategory]); - - useEffect(() => { - if (!shouldSearchDefault) { - return; - } - - if (allowProfileFetch) { - return; - } - - if (!shouldSearchNfts) { - setAllowProfileFetch(true); - return; - } - - if (nftsSettled) { - setAllowProfileFetch(true); - } - }, [shouldSearchDefault, shouldSearchNfts, nftsSettled, allowProfileFetch]); - - useEffect(() => { - if (!shouldSearchDefault) { - return; - } - - if (allowWaveFetch) { - return; - } - - if (!allowProfileFetch && selectedCategory !== CATEGORY.WAVES) { - return; - } - - if (profilesSettled) { - setAllowWaveFetch(true); - } - }, [ - shouldSearchDefault, - allowProfileFetch, - profilesSettled, - allowWaveFetch, - selectedCategory, - ]); const charactersRemaining = Math.max( MIN_SEARCH_LENGTH - searchInputLength, @@ -632,9 +547,6 @@ export default function HeaderSearchModal({ setWaveSearchDebouncedValue(""); setSelectedCategory(CATEGORY.ALL); setSelectedItemIndex(0); - setAllowProfileFetch(false); - setAllowWaveFetch(false); - setState(STATE.INITIAL); setTimeout(() => { inputRef.current?.focus(); }, 0); @@ -669,7 +581,6 @@ export default function HeaderSearchModal({ }; const [selectedItemIndex, setSelectedItemIndex] = useState(0); - const [state, setState] = useState(STATE.INITIAL); const getCurrentItems = (): HeaderSearchModalItemType[] => flattenedItems; @@ -699,7 +610,7 @@ export default function HeaderSearchModal({ Object.hasOwn(item, "serial_no"); useKeyPressEvent("Enter", () => { - if (state !== STATE.SUCCESS) return; + if (derivedState !== STATE.SUCCESS) return; const items = getCurrentItems(); if (!items || items.length === 0) return; const item = items[selectedItemIndex]; @@ -741,46 +652,41 @@ export default function HeaderSearchModal({ } }); - useEffect(() => { - setSelectedItemIndex(0); - + const derivedState = useMemo(() => { if (!isSearching) { - setState(STATE.INITIAL); - return; + return STATE.INITIAL; } const hasResults = categoriesWithResults.length > 0; + + if (hasResults) { + return STATE.SUCCESS; + } + + if (isAwaitingDebouncedSearch) { + return STATE.LOADING; + } + const anyFetching = (shouldSearchDefault && (isFetchingProfiles || isFetchingWaves)) || (shouldSearchNfts && isFetchingNfts); + + if (anyFetching) { + return STATE.LOADING; + } + const anyError = (shouldSearchDefault && (Boolean(profilesError) || Boolean(wavesError))) || (shouldSearchNfts && Boolean(nftsError)); - if (!hasResults) { - if (isAwaitingDebouncedSearch) { - setState(STATE.LOADING); - return; - } - - if (anyError) { - setState(STATE.ERROR); - return; - } - - if (anyFetching) { - setState(STATE.LOADING); - return; - } - - setState(STATE.NO_RESULTS); - return; + if (anyError) { + return STATE.ERROR; } - setState(STATE.SUCCESS); + return STATE.NO_RESULTS; }, [ - categoriesWithResults, + categoriesWithResults.length, isFetchingProfiles, isFetchingNfts, isFetchingWaves, @@ -793,48 +699,36 @@ export default function HeaderSearchModal({ isAwaitingDebouncedSearch, ]); - const handleRetry = () => { - setState(STATE.LOADING); - - const refetchPromises: Promise[] = []; + useEffect(() => { + setSelectedItemIndex(0); + }, [trimmedDebouncedValue]); - const queueRefetch = (callback: () => Promise) => { - refetchPromises.push(callback()); - }; + const handleRetry = () => { + if (selectedCategory === CATEGORY.PAGES) { + return; + } if (selectedCategory === CATEGORY.ALL) { if (shouldSearchDefault) { - queueRefetch(refetchProfiles); - queueRefetch(refetchWaves); + refetchProfiles(); + refetchWaves(); } if (shouldSearchNfts) { - queueRefetch(refetchNfts); + refetchNfts(); } - } else if (selectedCategory === CATEGORY.PAGES) { - setState(pageResults.length > 0 ? STATE.SUCCESS : STATE.NO_RESULTS); - return; } else if (selectedCategory === CATEGORY.PROFILES) { if (shouldSearchDefault) { - queueRefetch(refetchProfiles); + refetchProfiles(); } } else if (selectedCategory === CATEGORY.NFTS) { if (shouldSearchNfts) { - queueRefetch(refetchNfts); + refetchNfts(); } } else if (selectedCategory === CATEGORY.WAVES) { if (shouldSearchDefault) { - queueRefetch(refetchWaves); + refetchWaves(); } } - - if (refetchPromises.length === 0) { - setState(STATE.NO_RESULTS); - return; - } - - Promise.all(refetchPromises).catch(() => { - setState(STATE.ERROR); - }); }; const activeElementRef = useRef(null); @@ -1172,7 +1066,7 @@ export default function HeaderSearchModal({ )} {/* Site-wide search results */} {searchMode === SEARCH_MODE.SITE && - state === STATE.SUCCESS && ( + derivedState === STATE.SUCCESS && (
)} {searchMode === SEARCH_MODE.SITE && - (state === STATE.LOADING || - (state === STATE.INITIAL && isSearching)) && ( + derivedState === STATE.LOADING && (
)} {searchMode === SEARCH_MODE.SITE && - state === STATE.NO_RESULTS && ( + derivedState === STATE.NO_RESULTS && (
)} - {searchMode === SEARCH_MODE.SITE && state === STATE.ERROR && ( + {searchMode === SEARCH_MODE.SITE && derivedState === STATE.ERROR && (
)} {searchMode === SEARCH_MODE.SITE && - state === STATE.INITIAL && - !isSearching && ( + derivedState === STATE.INITIAL && (
import("../header/AppHeader"), { ssr: false, @@ -30,6 +31,7 @@ export default function AppLayout({ children }: Props) { useDeepLinkNavigation(); const { registerRef } = useLayout(); const { setHeaderRef } = useHeaderContext(); + const headerRef = useRef(null); const { activeView, homeActiveTab } = useViewContext(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -53,6 +55,7 @@ export default function AppLayout({ children }: Props) { const headerWrapperRef = useCallback( (node: HTMLDivElement | null) => { + headerRef.current = node; registerRef("header", node); setHeaderRef(node); }, @@ -69,6 +72,7 @@ export default function AppLayout({ children }: Props) { isHomeFeedView ? "tw-overflow-hidden" : "tw-overflow-auto" }`} > +
diff --git a/components/providers/LayoutWrapper.tsx b/components/providers/LayoutWrapper.tsx index ab571bbc9c..cca39b192f 100644 --- a/components/providers/LayoutWrapper.tsx +++ b/components/providers/LayoutWrapper.tsx @@ -12,7 +12,6 @@ import useDeviceInfo from "@/hooks/useDeviceInfo"; import { usePathname } from "next/navigation"; import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; -// import PullToRefresh from "./PullToRefresh"; export default function LayoutWrapper({ children, @@ -88,8 +87,6 @@ export default function LayoutWrapper({ return ( - {/* Temporarily disabled - users report pull-to-refresh is too aggressive */} - {/* {isApp && } */} ; +} + +export default function PullToRefresh({ triggerZoneRef }: PullToRefreshProps) { const { invalidateAll } = useContext(ReactQueryWrapperContext); const { globalRefresh } = useGlobalRefresh(); const [pullDistance, setPullDistance] = useState(0); @@ -52,9 +61,8 @@ export default function PullToRefresh() { if (isRefreshingRef.current) return; const target = e.target as HTMLElement; - const scrollableParent = getScrollableParent(target); - - if (scrollableParent && scrollableParent.scrollTop > 0) { + const triggerZone = triggerZoneRef.current; + if (!triggerZone?.contains(target)) { return; } @@ -66,7 +74,7 @@ export default function PullToRefresh() { isPulling.current = true; contentRef.current = document.body; }, - [isAtTop, getScrollableParent] + [isAtTop, triggerZoneRef] ); const handleTouchMove = useCallback( @@ -173,8 +181,6 @@ export default function PullToRefresh() { }, []); useEffect(() => { - if (!isCapacitor) return; - document.addEventListener("touchstart", handleTouchStart, { passive: true, }); @@ -198,15 +204,9 @@ export default function PullToRefresh() { contentRef.current.style.transition = ""; } }; - }, [ - isCapacitor, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - handleTouchCancel, - ]); - - if (!isCapacitor || pullDistance === 0) return null; + }, [handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel]); + + if (pullDistance === 0) return null; const progress = Math.min(pullDistance / PULL_THRESHOLD, 1); const shouldTrigger = pullDistance >= PULL_THRESHOLD; diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 2f579944ad..fb07cc7721 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -1,76 +1,76 @@ "use client"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { $convertFromMarkdownString } from "@lexical/markdown"; import type { - InitialConfigType} from "@lexical/react/LexicalComposer"; + InitialConfigType +} from "@lexical/react/LexicalComposer"; import { LexicalComposer, } from "@lexical/react/LexicalComposer"; -import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; -import { $convertFromMarkdownString } from "@lexical/markdown"; -import type { - EditorState, - TextNode} from "lexical"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { + $createParagraphNode, + $createTextNode, $getRoot, + $isElementNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, - $createParagraphNode, - $createTextNode, - $isElementNode, + type EditorState, type LexicalNode, type RootNode, + type TextNode, } from "lexical"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; -import { ListNode, ListItemNode } from "@lexical/list"; -import { HeadingNode, QuoteNode } from "@lexical/rich-text"; -import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; -import { ListPlugin } from "@lexical/react/LexicalListPlugin"; -import { CodeHighlightNode, CodeNode, $isCodeNode } from "@lexical/code"; +import { $isCodeNode, CodeHighlightNode, CodeNode } from "@lexical/code"; import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import ExampleTheme from "@/components/drops/create/lexical/ExampleTheme"; +import { EmojiNode } from "@/components/drops/create/lexical/nodes/EmojiNode"; +import { HashtagNode } from "@/components/drops/create/lexical/nodes/HashtagNode"; import { - MentionNode, $createMentionNode, + MentionNode, } from "@/components/drops/create/lexical/nodes/MentionNode"; -import { HashtagNode } from "@/components/drops/create/lexical/nodes/HashtagNode"; -import { MENTION_TRANSFORMER } from "@/components/drops/create/lexical/transformers/MentionTransformer"; -import { HASHTAG_TRANSFORMER } from "@/components/drops/create/lexical/transformers/HastagTransformer"; -import ExampleTheme from "@/components/drops/create/lexical/ExampleTheme"; +import EmojiPlugin from "@/components/drops/create/lexical/plugins/emoji/EmojiPlugin"; import type { NewMentionsPluginHandles, } from "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin"; import NewMentionsPlugin from "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin"; +import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; +import { HASHTAG_TRANSFORMER } from "@/components/drops/create/lexical/transformers/HastagTransformer"; +import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/create/lexical/transformers/markdownTransformers"; +import { MENTION_TRANSFORMER } from "@/components/drops/create/lexical/transformers/MentionTransformer"; import type { MentionedUser } from "@/entities/IDrop"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; -import CreateDropEmojiPicker from "../CreateDropEmojiPicker"; import useDeviceInfo from "@/hooks/useDeviceInfo"; -import EmojiPlugin from "@/components/drops/create/lexical/plugins/emoji/EmojiPlugin"; -import { EmojiNode } from "@/components/drops/create/lexical/nodes/EmojiNode"; -import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/create/lexical/transformers/markdownTransformers"; -import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; -import { - normalizeDropMarkdown, - exportDropMarkdown, -} from "./normalizeDropMarkdown"; +import CreateDropEmojiPicker from "../CreateDropEmojiPicker"; import { addBlankLinePlaceholders, removeBlankLinePlaceholders, } from "./blankLinePlaceholders"; +import { + exportDropMarkdown, + normalizeDropMarkdown, +} from "./normalizeDropMarkdown"; interface EditDropLexicalProps { readonly initialContent: string; diff --git a/styles/globals.scss b/styles/globals.scss index 86a3499a1c..32d6dd80bc 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -212,6 +212,16 @@ body { } } +body.capacitor-native { + input, + textarea, + select, + [contenteditable="true"] { + font-size: max(1rem, 16px) !important; + touch-action: manipulation; + } +} + textarea, select, input,