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
This commit is contained in:
Gigi
2025-10-15 09:32:25 +02:00
parent 3091ad7fd4
commit 5b2ee94062
8 changed files with 100 additions and 237 deletions

View File

@@ -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.) 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.

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "boris", "name": "boris",
"version": "0.6.6", "version": "0.6.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "boris", "name": "boris",
"version": "0.6.6", "version": "0.6.9",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -33,7 +33,8 @@
"reading-time-estimator": "^1.14.0", "reading-time-estimator": "^1.14.0",
"rehype-prism-plus": "^2.0.1", "rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "^4.1.14",
@@ -11695,6 +11696,15 @@
"punycode": "^2.1.0" "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": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -36,7 +36,8 @@
"reading-time-estimator": "^1.14.0", "reading-time-estimator": "^1.14.0",
"rehype-prism-plus": "^2.0.1", "rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "^4.1.14",

View File

@@ -18,8 +18,8 @@ import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard' import BlogPostCard from './BlogPostCard'
import { HighlightItem } from './HighlightItem' import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from '../hooks/usePullToRefresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import PullToRefreshIndicator from './PullToRefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel' import { HighlightVisibility } from './HighlightsPanel'
@@ -41,7 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings) // Visibility filters (defaults from settings)
@@ -229,11 +228,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) }, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Pull-to-refresh // Pull-to-refresh
const pullToRefreshState = usePullToRefresh(exploreContainerRef, { const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => { onRefresh: () => {
setRefreshTrigger(prev => prev + 1) setRefreshTrigger(prev => prev + 1)
}, },
isRefreshing: loading maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !activeAccount
}) })
const getPostUrl = (post: BlogPostPreview) => { const getPostUrl = (post: BlogPostPreview) => {
@@ -393,15 +394,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} }
return ( return (
<div <div className="explore-container">
ref={exploreContainerRef} <RefreshIndicator
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`} isRefreshing={isRefreshing}
> pullPosition={pullPosition}
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/> />
<div className="explore-header"> <div className="explore-header">
<h1> <h1>

View File

@@ -22,8 +22,8 @@ import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache' import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from '../hooks/usePullToRefresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import PullToRefreshIndicator from './PullToRefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { getProfileUrl } from '../config/nostrGateways' import { getProfileUrl } from '../config/nostrGateways'
interface MeProps { interface MeProps {
@@ -49,7 +49,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('cards') const [viewMode, setViewMode] = useState<ViewMode>('cards')
const meContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
// Update local state when prop changes // Update local state when prop changes
@@ -125,11 +124,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh // Pull-to-refresh
const pullToRefreshState = usePullToRefresh(meContainerRef, { const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => { onRefresh: () => {
setRefreshTrigger(prev => prev + 1) setRefreshTrigger(prev => prev + 1)
}, },
isRefreshing: loading maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !viewingPubkey
}) })
const handleHighlightDelete = (highlightId: string) => { const handleHighlightDelete = (highlightId: string) => {
@@ -367,15 +368,10 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
} }
return ( return (
<div <div className="explore-container">
ref={meContainerRef} <RefreshIndicator
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`} isRefreshing={isRefreshing}
> pullPosition={pullPosition}
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/> />
<div className="explore-header"> <div className="explore-header">
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />} {viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}

View File

@@ -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<PullToRefreshIndicatorProps> = ({
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 (
<div
className="pull-to-refresh-indicator"
style={{
opacity,
transform: `translateY(${-20 + pullDistance / 2}px)`
}}
>
<div
className="pull-to-refresh-icon"
style={{
transform: `rotate(${rotation}deg)`
}}
>
<FontAwesomeIcon
icon={faArrowDown}
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
/>
</div>
<div className="pull-to-refresh-text">
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
</div>
</div>
)
}
export default PullToRefreshIndicator

View File

@@ -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<RefreshIndicatorProps> = ({
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 (
<div
style={{
position: 'fixed',
top: `${translateY}px`,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
opacity,
transition: isRefreshing ? 'opacity 0.2s' : 'none'
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: 'var(--surface-secondary, #ffffff)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<FontAwesomeIcon
icon={faArrowRotateRight}
style={{
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
color: 'var(--accent-color, #3b82f6)'
}}
className={isRefreshing ? 'fa-spin' : ''}
/>
</div>
</div>
)
}
export default RefreshIndicator

View File

@@ -1,153 +0,0 @@
import { useEffect, useRef, useState, RefObject } from 'react'
import { useIsCoarsePointer } from './useMediaQuery'
interface UsePullToRefreshOptions {
onRefresh: () => void | Promise<void>
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<HTMLElement>,
options: UsePullToRefreshOptions
): PullToRefreshState {
const {
onRefresh,
isRefreshing = false,
disabled = false,
threshold = 80,
resistance = 2.5
} = options
const isTouch = useIsCoarsePointer()
const [pullState, setPullState] = useState<PullToRefreshState>({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
const touchStartY = useRef<number>(0)
const startScrollTop = useRef<number>(0)
const isDragging = useRef<boolean>(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
}