From 0da72f7f8d26559b48889f8fb4add4d07ff0e102 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:49:41 -0500 Subject: [PATCH] Various scrolling and swiping comment logic fixes (#48) * Various scrolling and swiping comment logic fixes - Add highlighted comment in a comment context chain scroll into view - Fix swipe to collapse not collapsing entire root comment chain - Add scroll to top of root comment chain upon swipe to collapse * Move root comment collapsing to sliding comment component --- src/features/auth/AppContext.tsx | 28 +++++++++++++++-- src/features/comment/Comment.tsx | 24 +++++++++++++-- src/features/comment/CommentTree.tsx | 4 +++ src/features/comment/Comments.tsx | 11 +++---- src/features/comment/commentSlice.ts | 5 ++++ src/features/feed/Feed.tsx | 10 ++----- .../sliding/SlidingNestedCommentVote.tsx | 30 +++++++++++++++---- src/pages/posts/CommunitiesPage.tsx | 10 ++----- src/pages/profile/ProfilePage.tsx | 8 ++--- src/pages/shared/UserPage.tsx | 10 ++----- 10 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/features/auth/AppContext.tsx b/src/features/auth/AppContext.tsx index 5fe5f4b259..3c06c17704 100644 --- a/src/features/auth/AppContext.tsx +++ b/src/features/auth/AppContext.tsx @@ -1,10 +1,19 @@ -import React, { RefObject, createContext, useState } from "react"; +import { useIonViewDidEnter } from "@ionic/react"; +import React, { + RefObject, + createContext, + useContext, + useEffect, + useState, +} from "react"; import { VirtuosoHandle } from "react-virtuoso"; +type Page = HTMLElement | RefObject; + interface IAppContext { // used for determining whether page needs to be scrolled up first - activePage: HTMLElement | RefObject | undefined; - setActivePage: (activePage: HTMLElement | RefObject) => void; + activePage: Page | undefined; + setActivePage: (activePage: Page) => void; } export const AppContext = createContext({ @@ -27,3 +36,16 @@ export function AppContextProvider({ ); } + +export function useSetActivePage(page?: Page) { + const { activePage, setActivePage } = useContext(AppContext); + + useIonViewDidEnter(() => { + if (page) setActivePage(page); + }); + + useEffect(() => { + if (!activePage && page) setActivePage(page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page]); +} diff --git a/src/features/comment/Comment.tsx b/src/features/comment/Comment.tsx index 94ecac4993..77fa4fc949 100644 --- a/src/features/comment/Comment.tsx +++ b/src/features/comment/Comment.tsx @@ -3,7 +3,7 @@ import { IonIcon, IonItem } from "@ionic/react"; import { chevronDownOutline } from "ionicons/icons"; import { CommentView } from "lemmy-js-client"; import { css } from "@emotion/react"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import Ago from "../labels/Ago"; import { maxWidthCss } from "../shared/AppContent"; import PersonLink from "../labels/links/PersonLink"; @@ -28,6 +28,8 @@ const rainbowColors = [ ]; const CustomIonItem = styled(IonItem)` + scroll-margin-bottom: 35vh; + --padding-start: 0; --inner-padding-end: 0; --border-style: none; @@ -166,6 +168,8 @@ interface CommentProps { context?: React.ReactNode; className?: string; + + rootIndex?: number; } export default function Comment({ @@ -179,15 +183,30 @@ export default function Comment({ context, routerLink, className, + rootIndex, }: CommentProps) { const keyPressed = useKeyPressed(); + // eslint-disable-next-line no-undef + const commentRef = useRef(null); + + useEffect(() => { + if (highlightedCommentId !== comment.comment.id) return; + + setTimeout(() => { + commentRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + }, 100); + }, [highlightedCommentId, comment]); return ( onClick?.()} + rootIndex={rootIndex} collapsed={!!collapsed} > { if (!keyPressed) onClick?.(); }} + ref={commentRef} > , ...comment.children.map((comment) => ( @@ -62,6 +65,7 @@ export default function CommentTree({ comment={comment} op={op} fullyCollapsed={collapsed || fullyCollapsed} + rootIndex={rootIndex} /> )), , diff --git a/src/features/comment/Comments.tsx b/src/features/comment/Comments.tsx index c96d6b78c9..c18faa412f 100644 --- a/src/features/comment/Comments.tsx +++ b/src/features/comment/Comments.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { buildCommentsTree } from "../../helpers/lemmy"; import CommentTree from "./CommentTree"; import { @@ -6,7 +6,6 @@ import { IonRefresherContent, IonSpinner, useIonToast, - useIonViewWillEnter, } from "@ionic/react"; import styled from "@emotion/styled"; import { css } from "@emotion/react"; @@ -18,7 +17,7 @@ import { receivedComments } from "./commentSlice"; import { RefresherCustomEvent } from "@ionic/core"; import { getPost } from "../post/postSlice"; import useClient from "../../helpers/useClient"; -import { AppContext } from "../auth/AppContext"; +import { useSetActivePage } from "../auth/AppContext"; import { PostContext } from "../post/detail/PostContext"; import { jwtSelector } from "../auth/authSlice"; @@ -85,12 +84,9 @@ export default function Comments({ : undefined; const commentId = commentPath ? +commentPath.split(".")[1] : undefined; - const { setActivePage } = useContext(AppContext); const virtuosoRef = useRef(null); - useIonViewWillEnter(() => { - setActivePage(virtuosoRef); - }); + useSetActivePage(virtuosoRef); useEffect(() => { fetchComments(true); @@ -199,6 +195,7 @@ export default function Comments({ key={comment.comment_view.comment.id} first={index === 0} op={op} + rootIndex={index + 1} /* Plus header index = 0 */ /> )); }, [commentTree, comments.length, highlightedCommentId, loading, op]); diff --git a/src/features/comment/commentSlice.ts b/src/features/comment/commentSlice.ts index 57c1188a99..b981e9c737 100644 --- a/src/features/comment/commentSlice.ts +++ b/src/features/comment/commentSlice.ts @@ -33,6 +33,10 @@ export const commentSlice = createSlice({ state.commentCollapsedById[action.payload.commentId] = action.payload.collapsed; }, + toggleCommentCollapseState: (state, action: PayloadAction) => { + state.commentCollapsedById[action.payload] = + !state.commentCollapsedById[action.payload]; + }, updateCommentVote: ( state, action: PayloadAction<{ commentId: number; vote: -1 | 1 | 0 | undefined }> @@ -47,6 +51,7 @@ export const commentSlice = createSlice({ export const { receivedComments, updateCommentCollapseState, + toggleCommentCollapseState, updateCommentVote, resetComments, } = commentSlice.actions; diff --git a/src/features/feed/Feed.tsx b/src/features/feed/Feed.tsx index 9d534d1857..11a5518d47 100644 --- a/src/features/feed/Feed.tsx +++ b/src/features/feed/Feed.tsx @@ -1,7 +1,6 @@ import React, { ComponentType, useCallback, - useContext, useEffect, useRef, useState, @@ -12,12 +11,11 @@ import { IonRefresherContent, RefresherCustomEvent, useIonToast, - useIonViewDidEnter, } from "@ionic/react"; import { LIMIT } from "../../services/lemmy"; import { CenteredSpinner } from "../post/detail/PostDetail"; import { pullAllBy } from "lodash"; -import { AppContext } from "../auth/AppContext"; +import { useSetActivePage } from "../auth/AppContext"; import EndPost from "./EndPost"; export type FetchFn = (page: number) => Promise; @@ -43,12 +41,9 @@ export default function Feed({ const [atEnd, setAtEnd] = useState(false); const [present] = useIonToast(); - const { setActivePage } = useContext(AppContext); const virtuosoRef = useRef(null); - useIonViewDidEnter(() => { - setActivePage(virtuosoRef); - }); + useSetActivePage(virtuosoRef); useEffect(() => { fetchMore(true); @@ -121,6 +116,7 @@ export default function Feed({ > + void; + rootIndex: number | undefined; collapsed: boolean; } @@ -23,12 +26,14 @@ export default function SlidingNestedCommentVote({ children, className, item, - collapse, + rootIndex, collapsed, }: SlidingVoteProps) { + const dispatch = useDispatch(); const { refreshPost } = useContext(PostContext); const pageContext = useContext(PageContext); const jwt = useAppSelector(jwtSelector); + const { activePage } = useContext(AppContext); const [login, onDismissLogin] = useIonModal(Login, { onDismiss: (data: string, role: string) => onDismissLogin(data, role), @@ -42,12 +47,27 @@ export default function SlidingNestedCommentVote({ item, }); + const collapseRootComment = useCallback(() => { + if (!rootIndex) return; + + const rootCommentId = +item.comment.path.split(".")[1]; + + dispatch(toggleCommentCollapseState(rootCommentId)); + + if (!activePage || !("current" in activePage)) return; + + activePage.current?.scrollToIndex({ + index: rootIndex, + behavior: "smooth", + }); + }, [activePage, dispatch, item.comment.path, rootIndex]); + const endActions: [SlidingItemAction, SlidingItemAction] = useMemo(() => { return [ { render: collapsed ? chevronExpand : chevronCollapse, trigger: () => { - collapse(); + collapseRootComment(); }, bgColor: "tertiary", }, @@ -61,7 +81,7 @@ export default function SlidingNestedCommentVote({ bgColor: "primary", }, ]; - }, [pageContext.page, reply, jwt, login, collapse, collapsed]); + }, [collapsed, collapseRootComment, jwt, login, pageContext.page, reply]); return ( diff --git a/src/pages/posts/CommunitiesPage.tsx b/src/pages/posts/CommunitiesPage.tsx index f74d805e2e..a62557fd23 100644 --- a/src/pages/posts/CommunitiesPage.tsx +++ b/src/pages/posts/CommunitiesPage.tsx @@ -9,7 +9,6 @@ import { IonPage, IonTitle, IonToolbar, - useIonViewWillEnter, } from "@ionic/react"; import AppContent from "../../features/shared/AppContent"; import { useParams } from "react-router"; @@ -19,8 +18,8 @@ import { home, library, people } from "ionicons/icons"; import styled from "@emotion/styled"; import { pullAllBy, sortBy, uniqBy } from "lodash"; import { notEmpty } from "../../helpers/array"; -import { useContext, useMemo, useRef } from "react"; -import { AppContext } from "../../features/auth/AppContext"; +import { useMemo, useRef } from "react"; +import { useSetActivePage } from "../../features/auth/AppContext"; import { useBuildGeneralBrowseLink } from "../../helpers/routes"; import ItemIcon from "../../features/labels/img/ItemIcon"; import { jwtSelector } from "../../features/auth/authSlice"; @@ -51,7 +50,6 @@ const Content = styled.div` export default function CommunitiesPage() { const buildGeneralBrowseLink = useBuildGeneralBrowseLink(); - const { setActivePage } = useContext(AppContext); const { actor } = useParams<{ actor: string }>(); const jwt = useAppSelector(jwtSelector); const pageRef = useRef(); @@ -62,9 +60,7 @@ export default function CommunitiesPage() { (state) => state.community.communityByHandle ); - useIonViewWillEnter(() => { - if (pageRef.current) setActivePage(pageRef.current); - }); + useSetActivePage(pageRef.current); const communities = useMemo(() => { const communities = uniqBy( diff --git a/src/pages/profile/ProfilePage.tsx b/src/pages/profile/ProfilePage.tsx index eed758dfbf..584dd7f368 100644 --- a/src/pages/profile/ProfilePage.tsx +++ b/src/pages/profile/ProfilePage.tsx @@ -10,7 +10,6 @@ import { IonTitle, IonToolbar, useIonModal, - useIonViewWillEnter, } from "@ionic/react"; import AppContent from "../../features/shared/AppContent"; import { @@ -25,7 +24,7 @@ import { InsetIonItem, SettingLabel } from "../../features/user/Profile"; import { ReactComponent as IncognitoSvg } from "../../features/user/incognito.svg"; import styled from "@emotion/styled"; import UserPage from "../shared/UserPage"; -import { AppContext } from "../../features/auth/AppContext"; +import { useSetActivePage } from "../../features/auth/AppContext"; import { swapHorizontalOutline } from "ionicons/icons"; import { css } from "@emotion/react"; import AccountSwitcher from "../../features/auth/AccountSwitcher"; @@ -43,7 +42,6 @@ const Incognito = styled(IncognitoSvg)` export default function ProfilePage() { const dispatch = useAppDispatch(); const pageRef = useRef(); - const { setActivePage } = useContext(AppContext); const connectedInstance = useAppSelector( (state) => state.auth.connectedInstance ); @@ -66,9 +64,7 @@ export default function ProfilePage() { } ); - useIonViewWillEnter(() => { - if (pageRef.current) setActivePage(pageRef.current); - }); + useSetActivePage(pageRef.current); if (jwt) return ( diff --git a/src/pages/shared/UserPage.tsx b/src/pages/shared/UserPage.tsx index 5a98f128cb..cd30df0e90 100644 --- a/src/pages/shared/UserPage.tsx +++ b/src/pages/shared/UserPage.tsx @@ -12,16 +12,15 @@ import { IonToolbar, useIonAlert, useIonRouter, - useIonViewWillEnter, } from "@ionic/react"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Profile from "../../features/user/Profile"; import { useParams } from "react-router"; import { GetPersonDetailsResponse } from "lemmy-js-client"; import styled from "@emotion/styled"; import { useAppDispatch } from "../../store"; import { getUser } from "../../features/user/userSlice"; -import { AppContext } from "../../features/auth/AppContext"; +import { useSetActivePage } from "../../features/auth/AppContext"; import { useBuildGeneralBrowseLink } from "../../helpers/routes"; export const PageContentIonSpinner = styled(IonSpinner)` @@ -42,7 +41,6 @@ export default function UserPage(props: UserPageProps) { const dispatch = useAppDispatch(); const [person, setPerson] = useState(); const pageRef = useRef(); - const { setActivePage } = useContext(AppContext); const router = useIonRouter(); const [present] = useIonAlert(); @@ -51,9 +49,7 @@ export default function UserPage(props: UserPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [handle]); - useIonViewWillEnter(() => { - if (pageRef.current) setActivePage(pageRef.current); - }); + useSetActivePage(pageRef.current); async function load() { let data;