From 5b2ee94062ecfa46fc4d58e49c25c0069b590d1f Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:32:25 +0200 Subject: [PATCH] feat(ui): replace custom pull-to-refresh with use-pull-to-refresh library for simplicity - Remove custom usePullToRefresh hook and PullToRefreshIndicator - Add use-pull-to-refresh library dependency - Create simple RefreshIndicator component - Apply pull-to-refresh to Explore and Me screens - Simplify implementation while maintaining functionality --- .cursor/rules/mobile-first-ui-ux.mdc | 2 + package-lock.json | 16 ++- package.json | 3 +- src/components/Explore.tsx | 24 ++-- src/components/Me.tsx | 24 ++-- src/components/PullToRefreshIndicator.tsx | 52 -------- src/components/RefreshIndicator.tsx | 63 +++++++++ src/hooks/usePullToRefresh.ts | 153 ---------------------- 8 files changed, 100 insertions(+), 237 deletions(-) delete mode 100644 src/components/PullToRefreshIndicator.tsx create mode 100644 src/components/RefreshIndicator.tsx delete mode 100644 src/hooks/usePullToRefresh.ts diff --git a/.cursor/rules/mobile-first-ui-ux.mdc b/.cursor/rules/mobile-first-ui-ux.mdc index cc64f96a..6834bebb 100644 --- a/.cursor/rules/mobile-first-ui-ux.mdc +++ b/.cursor/rules/mobile-first-ui-ux.mdc @@ -4,3 +4,5 @@ alwaysApply: false --- This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.) + +Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic. diff --git a/package-lock.json b/package-lock.json index 8aa64c08..ba5f7236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -33,7 +33,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", @@ -11695,6 +11696,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-pull-to-refresh": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz", + "integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==", + "license": "MIT", + "peerDependencies": { + "react": "18.x || 19.x" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 84dc1a0f..3d5b8fa9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index f23b10a1..c0936554 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -18,8 +18,8 @@ import { UserSettings } from '../services/settingsService' import BlogPostCard from './BlogPostCard' import { HighlightItem } from './HighlightItem' import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' @@ -41,7 +41,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const exploreContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Visibility filters (defaults from settings) @@ -229,11 +228,13 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(exploreContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !activeAccount }) const getPostUrl = (post: BlogPostPreview) => { @@ -393,15 +394,10 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } return ( -
- +

diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 37cfea95..95b537ef 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -22,8 +22,8 @@ import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache' import { faBooks } from '../icons/customIcons' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { getProfileUrl } from '../config/nostrGateways' interface MeProps { @@ -49,7 +49,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr 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 @@ -125,11 +124,13 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(meContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !viewingPubkey }) const handleHighlightDelete = (highlightId: string) => { @@ -367,15 +368,10 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } return ( -
- +
{viewingPubkey && } diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx deleted file mode 100644 index 1af1b149..00000000 --- a/src/components/PullToRefreshIndicator.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faArrowDown } 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, - threshold = 80 -}) => { - // Only show when actively pulling, not when refreshing - if (!isPulling) return null - - const opacity = Math.min(pullDistance / threshold, 1) - const rotation = (pullDistance / threshold) * 180 - - return ( -
-
- -
-
- {canRefresh ? 'Release to refresh' : 'Pull to refresh'} -
-
- ) -} - -export default PullToRefreshIndicator - diff --git a/src/components/RefreshIndicator.tsx b/src/components/RefreshIndicator.tsx new file mode 100644 index 00000000..0cd0184d --- /dev/null +++ b/src/components/RefreshIndicator.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons' + +interface RefreshIndicatorProps { + isRefreshing: boolean + pullPosition: number +} + +const THRESHOLD = 80 + +/** + * Simple pull-to-refresh visual indicator + */ +const RefreshIndicator: React.FC = ({ + isRefreshing, + pullPosition +}) => { + const isVisible = isRefreshing || pullPosition > 0 + if (!isVisible) return null + + const opacity = Math.min(pullPosition / THRESHOLD, 1) + const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3 + + return ( +
+
+ +
+
+ ) +} + +export default RefreshIndicator + diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts deleted file mode 100644 index abd490d5..00000000 --- a/src/hooks/usePullToRefresh.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 -} -