From bbd47929f0dc3389dead8894a87972d382536f86 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Mon, 18 Mar 2024 21:22:43 -0500 Subject: [PATCH 01/11] Add long press tabs (#1341) --- src/core/App.tsx | 2 + src/core/listeners/statusTap.ts | 12 + .../community/titleSearch/TitleSearch.tsx | 24 +- src/features/shared/AppHeader.tsx | 53 +--- src/helpers/longPress.ts | 35 ++- src/routes/TabBar.tsx | 287 ++---------------- src/routes/pages/search/SearchPage.tsx | 23 +- .../pages/shared/InstanceSidebarPage.tsx | 32 +- src/routes/tabs/buttons/InboxTabButton.tsx | 26 ++ src/routes/tabs/buttons/PostsTabButton.tsx | 78 +++++ src/routes/tabs/buttons/ProfileTabButton.tsx | 79 +++++ src/routes/tabs/buttons/SearchTabButton.tsx | 34 +++ src/routes/tabs/buttons/SettingsTabButton.tsx | 39 +++ src/routes/tabs/buttons/shared.tsx | 131 ++++++++ 14 files changed, 529 insertions(+), 326 deletions(-) create mode 100644 src/routes/tabs/buttons/InboxTabButton.tsx create mode 100644 src/routes/tabs/buttons/PostsTabButton.tsx create mode 100644 src/routes/tabs/buttons/ProfileTabButton.tsx create mode 100644 src/routes/tabs/buttons/SearchTabButton.tsx create mode 100644 src/routes/tabs/buttons/SettingsTabButton.tsx create mode 100644 src/routes/tabs/buttons/shared.tsx diff --git a/src/core/App.tsx b/src/core/App.tsx index 16ea32d74f..716bf0c503 100644 --- a/src/core/App.tsx +++ b/src/core/App.tsx @@ -39,6 +39,7 @@ import "@ionic/react/css/display.css"; import "./listeners"; import AppUrlListener from "./listeners/AppUrlListener"; import OldInstanceWarning from "./OldInstanceWarning"; +import { ResetStatusTap } from "./listeners/statusTap"; // index.tsx ensures android nav mode resolves before app is rendered (async () => { @@ -72,6 +73,7 @@ export default function App() { + diff --git a/src/core/listeners/statusTap.ts b/src/core/listeners/statusTap.ts index b1cbc663fd..9334213ad1 100644 --- a/src/core/listeners/statusTap.ts +++ b/src/core/listeners/statusTap.ts @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import { findCurrentPage } from "../../helpers/ionic"; import { Browser } from "@capacitor/browser"; +import { useLocation } from "react-router"; let savedScrollTop = 0; @@ -49,3 +51,13 @@ window.addEventListener("statusTap", () => { export function resetSavedStatusTap() { savedScrollTop = 0; } + +export function ResetStatusTap() { + const location = useLocation(); + + useEffect(() => { + resetSavedStatusTap(); + }, [location]); + + return null; +} diff --git a/src/features/community/titleSearch/TitleSearch.tsx b/src/features/community/titleSearch/TitleSearch.tsx index 0981649cf8..5b42ddf7cc 100644 --- a/src/features/community/titleSearch/TitleSearch.tsx +++ b/src/features/community/titleSearch/TitleSearch.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useRef } from "react"; import { TitleSearchContext } from "./TitleSearchProvider"; import { styled } from "@linaria/react"; import { isIosTheme } from "../../../helpers/device"; +import { findCurrentPage } from "../../../helpers/ionic"; const TitleContents = styled.span` display: inline-flex; @@ -47,6 +48,15 @@ const StyledInput = styled.input` --background: none; `; +const TITLE_CLASS = "title-search-opener"; + +export function openTitleSearch() { + findCurrentPage() + ?.closest(".ion-page") + ?.querySelector(`.${TITLE_CLASS}`) + ?.click(); +} + interface TitleSearchProps { name: string; children: React.ReactNode; @@ -63,6 +73,18 @@ export default function TitleSearch({ name, children }: TitleSearchProps) { searchRef.current?.focus(); }, [searching]); + const titleRef = useRef(null); + + // Need to declare manually, otherwise it can't be triggered from TabBar :( + useEffect(() => { + const activate = () => setSearching(true); + const title = titleRef.current; + + title?.addEventListener("click", activate); + + return () => title?.removeEventListener("click", activate); + }, [searching, setSearching]); + if (searching) { return ( <> @@ -98,7 +120,7 @@ export default function TitleSearch({ name, children }: TitleSearchProps) { return ( <> - setSearching(true)}> + {name} diff --git a/src/features/shared/AppHeader.tsx b/src/features/shared/AppHeader.tsx index 9cd0531169..47cd3998e3 100644 --- a/src/features/shared/AppHeader.tsx +++ b/src/features/shared/AppHeader.tsx @@ -1,10 +1,11 @@ -import { ComponentProps, useCallback, useEffect, useRef } from "react"; +import { ComponentProps } from "react"; import { LongPressCallback, useLongPress } from "use-long-press"; import store from "../../store"; import { setUserDarkMode } from "../settings/settingsSlice"; // eslint-disable-next-line no-restricted-imports import { IonHeader } from "@ionic/react"; +import { onFinishStopClick } from "../../helpers/longPress"; export default function AppHeader(props: ComponentProps) { if (props.collapse) return ; @@ -13,48 +14,22 @@ export default function AppHeader(props: ComponentProps) { } function UncollapsedAppHeader(props: ComponentProps) { - const headerRef = useRef(null); - const cancelledTimeRef = useRef(0); - - const onLongPress: LongPressCallback = useCallback((e) => { - if (e.target instanceof HTMLElement && e.target.tagName === "INPUT") return; - - const { usingSystemDarkMode, userDarkMode, quickSwitch } = - store.getState().settings.appearance.dark; - - if (!quickSwitch) return; - if (usingSystemDarkMode) return; - - store.dispatch(setUserDarkMode(!userDarkMode)); - }, []); - - const onCancel = useCallback(() => { - cancelledTimeRef.current = Date.now(); - }, []); - - const bind = useLongPress(onLongPress, { + const bind = useLongPress(onLongPressHeader, { cancelOnMovement: 15, - onCancel, + onFinish: onFinishStopClick, }); - useEffect(() => { - const header = headerRef.current; - if (!header) return; - - const onClick = (e: MouseEvent) => { - // this isn't great, but I don't have a better solution atm - if (Date.now() - cancelledTimeRef.current < 150) return; + return ; +} - e.stopImmediatePropagation(); - }; +const onLongPressHeader: LongPressCallback = (e) => { + if (e.target instanceof HTMLElement && e.target.tagName === "INPUT") return; - // can't simply react onClick. Synthetic doesn't work properly (Ionic issue?) - header.addEventListener("click", onClick); + const { usingSystemDarkMode, userDarkMode, quickSwitch } = + store.getState().settings.appearance.dark; - return () => { - header.removeEventListener("click", onClick); - }; - }, []); + if (!quickSwitch) return; + if (usingSystemDarkMode) return; - return ; -} + store.dispatch(setUserDarkMode(!userDarkMode)); +}; diff --git a/src/helpers/longPress.ts b/src/helpers/longPress.ts index 550575e3dc..550249a885 100644 --- a/src/helpers/longPress.ts +++ b/src/helpers/longPress.ts @@ -1,4 +1,4 @@ -import { LongPressOptions } from "use-long-press"; +import { LongPressOptions, LongPressReactEvents } from "use-long-press"; import { isAppleDeviceInstallable } from "./device"; const filterDragScrollbar: LongPressOptions["filterEvents"] = (e) => { @@ -47,3 +47,36 @@ export const filterEvents: LongPressOptions["filterEvents"] = (e) => { return true; }; + +// prevent click events after long press +export const onFinishStopClick = (event: LongPressReactEvents) => { + let timeoutId: ReturnType | undefined; + + function clearTimeoutIfNeeded() { + if (typeof timeoutId !== "number") return; + clearTimeout(timeoutId); + timeoutId = undefined; + } + + function stopClick(event: MouseEvent) { + event.stopImmediatePropagation(); + clearTimeoutIfNeeded(); + } + + if (!(event.target instanceof HTMLElement)) return; + + event.target?.addEventListener("click", stopClick, { + capture: true, + once: true, + }); + + timeoutId = setTimeout(() => { + clearTimeoutIfNeeded(); + + if (!(event.target instanceof HTMLElement)) return; + + event.target.removeEventListener("click", stopClick, { + capture: true, + }); + }, 200); // iOS safari can delay +}; diff --git a/src/routes/TabBar.tsx b/src/routes/TabBar.tsx index 98b4da3a73..85ff37806c 100644 --- a/src/routes/TabBar.tsx +++ b/src/routes/TabBar.tsx @@ -1,52 +1,10 @@ -import { - personCircleOutline, - search, - fileTray, - telescope, - cog, -} from "ionicons/icons"; -import { - IonBadge, - IonIcon, - IonLabel, - IonTabBar, - IonTabButton, -} from "@ionic/react"; -import { totalUnreadSelector } from "../features/inbox/inboxSlice"; -import useShouldInstall from "../features/pwa/useShouldInstall"; -import { UpdateContext } from "./pages/settings/update/UpdateContext"; -import { scrollUpIfNeeded } from "../helpers/scrollUpIfNeeded"; -import { getProfileTabLabel } from "../features/settings/general/other/ProfileTabLabel"; -import { AppContext } from "../features/auth/AppContext"; -import { resetSavedStatusTap } from "../core/listeners/statusTap"; -import { useLocation } from "react-router"; -import { useAppSelector } from "../store"; -import { - userHandleSelector, - instanceSelector, - jwtSelector, - accountsListEmptySelector, -} from "../features/auth/authSelectors"; -import { forwardRef, useCallback, useContext, useEffect, useMemo } from "react"; -import { getDefaultServer } from "../services/app"; -import { focusSearchBar } from "./pages/search/SearchPage"; -import { useOptimizedIonRouter } from "../helpers/useOptimizedIonRouter"; -import { PageContext } from "../features/auth/PageContext"; -import { useLongPress } from "use-long-press"; -import { ImpactStyle } from "@capacitor/haptics"; -import useHapticFeedback from "../helpers/useHapticFeedback"; -import { css } from "@linaria/core"; -import { styled } from "@linaria/react"; - -const interceptorCss = css` - position: absolute; - inset: 0; - pointer-events: all; -`; - -const ProfileLabel = styled(IonLabel)` - max-width: 20vw; -`; +import { IonTabBar } from "@ionic/react"; +import { forwardRef, useRef } from "react"; +import PostsTabButton from "./tabs/buttons/PostsTabButton"; +import InboxTabButton from "./tabs/buttons/InboxTabButton"; +import ProfileTabButton from "./tabs/buttons/ProfileTabButton"; +import SearchTabButton from "./tabs/buttons/SearchTabButton"; +import SettingsTabButton from "./tabs/buttons/SettingsTabButton"; type CustomTabBarType = typeof IonTabBar & { /** @@ -56,220 +14,31 @@ type CustomTabBarType = typeof IonTabBar & { }; const TabBar: CustomTabBarType = forwardRef(function TabBar(props, ref) { - const location = useLocation(); - const router = useOptimizedIonRouter(); - const vibrate = useHapticFeedback(); - - const databaseError = useAppSelector((state) => state.settings.databaseError); - const selectedInstance = useAppSelector(instanceSelector); - - useEffect(() => { - resetSavedStatusTap(); - }, [location]); - - const { status: updateStatus } = useContext(UpdateContext); - const shouldInstall = useShouldInstall(); - - const { activePageRef } = useContext(AppContext); - const { presentAccountSwitcher, presentLoginIfNeeded } = - useContext(PageContext); - - const jwt = useAppSelector(jwtSelector); - const accountsListEmpty = useAppSelector(accountsListEmptySelector); - const totalUnread = useAppSelector(totalUnreadSelector); - - const settingsNotificationCount = - (shouldInstall ? 1 : 0) + (updateStatus === "outdated" ? 1 : 0); - - const connectedInstance = useAppSelector( - (state) => state.auth.connectedInstance, - ); - - const userHandle = useAppSelector(userHandleSelector); - const profileLabelType = useAppSelector( - (state) => state.settings.appearance.general.profileLabel, - ); - - const profileTabLabel = useMemo( - () => getProfileTabLabel(profileLabelType, userHandle, connectedInstance), - [profileLabelType, userHandle, connectedInstance], - ); - - const isPostsButtonDisabled = location.pathname.startsWith("/posts"); - const isInboxButtonDisabled = location.pathname.startsWith("/inbox"); - const isProfileButtonDisabled = location.pathname.startsWith("/profile"); - const isSearchButtonDisabled = location.pathname.startsWith("/search"); - const isSettingsButtonDisabled = location.pathname.startsWith("/settings"); - - const onPostsClick = useCallback(() => { - if (!isPostsButtonDisabled) return; - - if (scrollUpIfNeeded(activePageRef?.current)) return; - - const pathname = router.getRouteInfo()?.pathname; - if (!pathname) return; - - const actor = pathname.split("/")[2]; - - if (pathname.endsWith(jwt ? "/home" : "/all")) { - router.push( - `/posts/${actor ?? selectedInstance ?? getDefaultServer()}`, - "back", - ); - return; - } - - const communitiesPath = `/posts/${ - actor ?? selectedInstance ?? getDefaultServer() - }`; - if (pathname === communitiesPath || pathname === `${communitiesPath}/`) - return; - - if (router.canGoBack()) { - router.goBack(); - } else { - router.push( - `/posts/${actor ?? selectedInstance ?? getDefaultServer()}/${ - jwt ? "home" : "all" - }`, - "back", - ); - } - }, [activePageRef, isPostsButtonDisabled, jwt, router, selectedInstance]); - - const onInboxClick = useCallback(() => { - if (!isInboxButtonDisabled) return; - - const pathname = router.getRouteInfo()?.pathname; - if (!pathname) return; - - if ( - // Messages are in reverse order, so bail on scroll up - !pathname.startsWith("/inbox/messages/") && - scrollUpIfNeeded(activePageRef?.current) - ) - return; - - router.push(`/inbox`, "back"); - }, [activePageRef, isInboxButtonDisabled, router]); - - const onProfileClick = useCallback(() => { - if (!isProfileButtonDisabled) return; - - if (scrollUpIfNeeded(activePageRef?.current)) return; - - const pathname = router.getRouteInfo()?.pathname; - if (!pathname) return; - - // if the profile page is already open, show the account switcher - if (pathname === "/profile") { - if (!accountsListEmpty) { - presentAccountSwitcher(); - } else { - presentLoginIfNeeded(); - } - } - - router.push("/profile", "back"); - }, [ - accountsListEmpty, - activePageRef, - isProfileButtonDisabled, - presentAccountSwitcher, - presentLoginIfNeeded, - router, - ]); - - const onSearchClick = useCallback(() => { - if (!isSearchButtonDisabled) return; - - // if the search page is already open, focus the search bar - focusSearchBar(); - - if (scrollUpIfNeeded(activePageRef?.current)) return; - - router.push(`/search`, "back"); - }, [activePageRef, isSearchButtonDisabled, router]); - - const onSettingsClick = useCallback(() => { - if (!isSettingsButtonDisabled) return; - - if (scrollUpIfNeeded(activePageRef?.current)) return; - - router.push(`/settings`, "back"); - }, [activePageRef, isSettingsButtonDisabled, router]); - - const onPresentAccountSwitcher = useCallback(() => { - vibrate({ style: ImpactStyle.Light }); - - if (!accountsListEmpty) { - presentAccountSwitcher(); - } else { - presentLoginIfNeeded(); - } - }, [ - accountsListEmpty, - presentAccountSwitcher, - presentLoginIfNeeded, - vibrate, - ]); - - const presentAccountSwitcherBind = useLongPress(onPresentAccountSwitcher); + const longPressedRef = useRef(false); - const settingsBadge = (() => { - if (databaseError) return !; + const resetLongPress = () => { + longPressedRef.current = false; + }; - if (settingsNotificationCount) - return {settingsNotificationCount}; - })(); + const sharedTabProps = { + longPressedRef, + }; return ( - - -