From 876ecc808dd3b4b894570396b223f03457402771 Mon Sep 17 00:00:00 2001 From: Gigi Date: Tue, 14 Oct 2025 00:47:48 +0200 Subject: [PATCH] feat: add pull-to-refresh for mobile on all scrollable views - Create reusable usePullToRefresh hook with touch gesture detection - Add PullToRefreshIndicator component with visual feedback - Implement pull-to-refresh on HighlightsPanel (right sidebar) - Implement pull-to-refresh on Explore page - Implement pull-to-refresh on Me pages (all tabs) - Implement pull-to-refresh on BookmarkList (left sidebar) - Only activates on touch devices for mobile-first experience - Shows rotating arrow icon that becomes refresh spinner - Displays contextual messages (pull/release/refreshing) - Integrates with existing refresh handlers and loading states --- src/components/BookmarkList.tsx | 28 +++- src/components/Explore.tsx | 27 +++- src/components/HighlightsPanel.tsx | 27 +++- src/components/Me.tsx | 27 +++- src/components/PullToRefreshIndicator.tsx | 61 +++++++++ src/hooks/usePullToRefresh.ts | 153 ++++++++++++++++++++++ src/index.css | 1 + src/styles/components/pull-to-refresh.css | 53 ++++++++ 8 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 src/components/PullToRefreshIndicator.tsx create mode 100644 src/hooks/usePullToRefresh.ts create mode 100644 src/styles/components/pull-to-refresh.css diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 5cb96434..d2617460 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons' import { formatDistanceToNow } from 'date-fns' @@ -10,6 +10,8 @@ import IconButton from './IconButton' import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { UserSettings } from '../services/settingsService' +import { usePullToRefresh } from '../hooks/usePullToRefresh' +import PullToRefreshIndicator from './PullToRefreshIndicator' interface BookmarkListProps { bookmarks: Bookmark[] @@ -48,6 +50,19 @@ export const BookmarkList: React.FC = ({ settings, isMobile = false }) => { + const bookmarksListRef = useRef(null) + + // Pull-to-refresh for bookmarks + const pullToRefreshState = usePullToRefresh(bookmarksListRef, { + onRefresh: () => { + if (onRefresh) { + onRefresh() + } + }, + isRefreshing: isRefreshing || false, + disabled: !onRefresh + }) + // Helper to check if a bookmark has either content or a URL const hasContentOrUrl = (ib: IndividualBookmark) => { // Check if has content (text) @@ -124,7 +139,16 @@ export const BookmarkList: React.FC = ({ ) ) : ( -
+
+
{allIndividualBookmarks.map((individualBookmark, index) => = ({ relayPool }) => { const [blogPosts, setBlogPosts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const exploreContainerRef = useRef(null) + const [refreshTrigger, setRefreshTrigger] = useState(0) useEffect(() => { const loadBlogPosts = async () => { @@ -116,7 +120,15 @@ const Explore: React.FC = ({ relayPool }) => { } loadBlogPosts() - }, [relayPool, activeAccount, blogPosts.length]) + }, [relayPool, activeAccount, blogPosts.length, refreshTrigger]) + + // Pull-to-refresh + const pullToRefreshState = usePullToRefresh(exploreContainerRef, { + onRefresh: () => { + setRefreshTrigger(prev => prev + 1) + }, + isRefreshing: loading + }) const getPostUrl = (post: BlogPostPreview) => { // Get the d-tag identifier @@ -144,7 +156,16 @@ const Explore: React.FC = ({ relayPool }) => { } return ( -
+
+

diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 0029aece..df4064cd 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -1,11 +1,13 @@ -import React, { useState } from 'react' +import React, { useState, useRef } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { useFilteredHighlights } from '../hooks/useFilteredHighlights' +import { usePullToRefresh } from '../hooks/usePullToRefresh' import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed' import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader' +import PullToRefreshIndicator from './PullToRefreshIndicator' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { UserSettings } from '../services/settingsService' @@ -57,12 +59,24 @@ export const HighlightsPanel: React.FC = ({ }) => { const [showHighlights, setShowHighlights] = useState(true) const [localHighlights, setLocalHighlights] = useState(highlights) + const highlightsListRef = useRef(null) const handleToggleHighlights = () => { const newValue = !showHighlights setShowHighlights(newValue) onToggleHighlights?.(newValue) } + + // Pull-to-refresh for highlights + const pullToRefreshState = usePullToRefresh(highlightsListRef, { + onRefresh: () => { + if (onRefresh) { + onRefresh() + } + }, + isRefreshing: loading, + disabled: !onRefresh + }) // Keep track of highlight updates React.useEffect(() => { @@ -127,7 +141,16 @@ export const HighlightsPanel: React.FC = ({

) : ( -
+
+ {filteredHighlights.map((highlight) => ( = ({ relayPool, activeTab: propActiveTab }) => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('cards') + const meContainerRef = useRef(null) + const [refreshTrigger, setRefreshTrigger] = useState(0) // Update local state when prop changes useEffect(() => { @@ -102,7 +106,15 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab }) => { } loadData() - }, [relayPool, activeAccount]) + }, [relayPool, activeAccount, refreshTrigger]) + + // Pull-to-refresh + const pullToRefreshState = usePullToRefresh(meContainerRef, { + onRefresh: () => { + setRefreshTrigger(prev => prev + 1) + }, + isRefreshing: loading + }) const handleHighlightDelete = (highlightId: string) => { setHighlights(prev => { @@ -301,7 +313,16 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab }) => { } return ( -
+
+
{activeAccount && } diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx new file mode 100644 index 00000000..930eb9ef --- /dev/null +++ b/src/components/PullToRefreshIndicator.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons' + +interface PullToRefreshIndicatorProps { + isPulling: boolean + pullDistance: number + canRefresh: boolean + isRefreshing: boolean + threshold?: number +} + +const PullToRefreshIndicator: React.FC = ({ + isPulling, + pullDistance, + canRefresh, + isRefreshing, + threshold = 80 +}) => { + // Don't show if not pulling and not refreshing + if (!isPulling && !isRefreshing) return null + + const opacity = Math.min(pullDistance / threshold, 1) + const rotation = (pullDistance / threshold) * 180 + + return ( +
+
+ {isRefreshing ? ( + + ) : ( + + )} +
+
+ {isRefreshing + ? 'Refreshing...' + : canRefresh + ? 'Release to refresh' + : 'Pull to refresh'} +
+
+ ) +} + +export default PullToRefreshIndicator + diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 00000000..abd490d5 --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState, RefObject } from 'react' +import { useIsCoarsePointer } from './useMediaQuery' + +interface UsePullToRefreshOptions { + onRefresh: () => void | Promise + isRefreshing?: boolean + disabled?: boolean + threshold?: number // Distance in pixels to trigger refresh + resistance?: number // Resistance factor (higher = harder to pull) +} + +interface PullToRefreshState { + isPulling: boolean + pullDistance: number + canRefresh: boolean +} + +/** + * Hook to enable pull-to-refresh gesture on touch devices + * @param containerRef - Ref to the scrollable container element + * @param options - Configuration options + * @returns State of the pull gesture + */ +export function usePullToRefresh( + containerRef: RefObject, + options: UsePullToRefreshOptions +): PullToRefreshState { + const { + onRefresh, + isRefreshing = false, + disabled = false, + threshold = 80, + resistance = 2.5 + } = options + + const isTouch = useIsCoarsePointer() + const [pullState, setPullState] = useState({ + isPulling: false, + pullDistance: 0, + canRefresh: false + }) + + const touchStartY = useRef(0) + const startScrollTop = useRef(0) + const isDragging = useRef(false) + + useEffect(() => { + const container = containerRef.current + if (!container || !isTouch || disabled || isRefreshing) return + + const handleTouchStart = (e: TouchEvent) => { + // Only start if scrolled to top + const scrollTop = container.scrollTop + if (scrollTop <= 0) { + touchStartY.current = e.touches[0].clientY + startScrollTop.current = scrollTop + isDragging.current = true + } + } + + const handleTouchMove = (e: TouchEvent) => { + if (!isDragging.current) return + + const currentY = e.touches[0].clientY + const deltaY = currentY - touchStartY.current + const scrollTop = container.scrollTop + + // Only pull down when at top and pulling down + if (scrollTop <= 0 && deltaY > 0) { + // Prevent default scroll behavior + e.preventDefault() + + // Apply resistance to make pulling feel natural + const distance = Math.min(deltaY / resistance, threshold * 1.5) + const canRefresh = distance >= threshold + + setPullState({ + isPulling: true, + pullDistance: distance, + canRefresh + }) + } else { + // Reset if scrolled or pulling up + isDragging.current = false + setPullState({ + isPulling: false, + pullDistance: 0, + canRefresh: false + }) + } + } + + const handleTouchEnd = async () => { + if (!isDragging.current) return + + isDragging.current = false + + if (pullState.canRefresh && !isRefreshing) { + // Keep the indicator visible while refreshing + setPullState(prev => ({ + ...prev, + isPulling: false + })) + + // Trigger refresh + await onRefresh() + } + + // Reset state + setPullState({ + isPulling: false, + pullDistance: 0, + canRefresh: false + }) + } + + const handleTouchCancel = () => { + isDragging.current = false + setPullState({ + isPulling: false, + pullDistance: 0, + canRefresh: false + }) + } + + // Add event listeners with passive: false to allow preventDefault + container.addEventListener('touchstart', handleTouchStart, { passive: true }) + container.addEventListener('touchmove', handleTouchMove, { passive: false }) + container.addEventListener('touchend', handleTouchEnd, { passive: true }) + container.addEventListener('touchcancel', handleTouchCancel, { passive: true }) + + return () => { + container.removeEventListener('touchstart', handleTouchStart) + container.removeEventListener('touchmove', handleTouchMove) + container.removeEventListener('touchend', handleTouchEnd) + container.removeEventListener('touchcancel', handleTouchCancel) + } + }, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh]) + + // Reset pull state when refresh completes + useEffect(() => { + if (!isRefreshing && pullState.isPulling) { + setPullState({ + isPulling: false, + pullDistance: 0, + canRefresh: false + }) + } + }, [isRefreshing, pullState.isPulling]) + + return pullState +} + diff --git a/src/index.css b/src/index.css index f65b5317..3207addd 100644 --- a/src/index.css +++ b/src/index.css @@ -12,6 +12,7 @@ @import './styles/components/reader.css'; @import './styles/components/settings.css'; @import './styles/components/me.css'; +@import './styles/components/pull-to-refresh.css'; @import './styles/utils/animations.css'; @import './styles/utils/utilities.css'; @import './styles/utils/legacy.css'; diff --git a/src/styles/components/pull-to-refresh.css b/src/styles/components/pull-to-refresh.css new file mode 100644 index 00000000..f181f85f --- /dev/null +++ b/src/styles/components/pull-to-refresh.css @@ -0,0 +1,53 @@ +/* Pull-to-refresh indicator styles */ +.pull-to-refresh-indicator { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + z-index: 100; + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.pull-to-refresh-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-secondary); + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; + font-size: 1rem; + color: var(--text-secondary); +} + +.pull-to-refresh-text { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + white-space: nowrap; + font-weight: 500; + background: var(--background-secondary); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Container needs relative positioning for absolute indicator */ +.pull-to-refresh-container { + position: relative; +} + +/* Ensure smooth transitions during pull */ +.pull-to-refresh-container.is-pulling { + overflow: visible; +} +