feat: swipe
This commit is contained in:
@@ -6,7 +6,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { TFeedSubRequest, TNoteListMode } from '@/types'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import KindFilter from '../KindFilter'
|
||||
import { RefreshButton } from '../RefreshButton'
|
||||
|
||||
@@ -31,10 +31,28 @@ export default function NormalFeed({
|
||||
const noteListRef = useRef<TNoteListRef>(null)
|
||||
const userAggregationListRef = useRef<TUserAggregationListRef>(null)
|
||||
const topRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const showKindsFilter = useMemo(() => {
|
||||
return subRequests.every((req) => !req.filter.kinds?.length)
|
||||
}, [subRequests])
|
||||
|
||||
// Touch swipe state for tab switching
|
||||
const touchStartX = useRef<number>(0)
|
||||
const touchStartY = useRef<number>(0)
|
||||
const touchStartTime = useRef<number>(0)
|
||||
const currentTranslateX = useRef<number>(0)
|
||||
const isSwiping = useRef<boolean>(false)
|
||||
const isAnimating = useRef<boolean>(false)
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ value: 'posts', label: 'Notes' },
|
||||
{ value: 'postsAndReplies', label: 'Replies' },
|
||||
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
|
||||
],
|
||||
[disable24hMode]
|
||||
)
|
||||
|
||||
const handleListModeChange = (mode: TNoteListMode) => {
|
||||
setListMode(mode)
|
||||
if (isMainFeed) {
|
||||
@@ -48,15 +66,145 @@ export default function NormalFeed({
|
||||
noteListRef.current?.scrollToTop()
|
||||
}
|
||||
|
||||
// Handle touch swipe for tab switching on content area with follow gesture
|
||||
useEffect(() => {
|
||||
if (!supportTouch || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const currentListMode = listMode === '24h' && disable24hMode ? 'posts' : listMode
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (isAnimating.current) return
|
||||
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
touchStartTime.current = Date.now()
|
||||
isSwiping.current = false
|
||||
currentTranslateX.current = 0
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isAnimating.current) return
|
||||
|
||||
const deltaX = e.touches[0].clientX - touchStartX.current
|
||||
const deltaY = Math.abs(e.touches[0].clientY - touchStartY.current)
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
|
||||
// Only start swiping if horizontal movement is greater than vertical
|
||||
if (!isSwiping.current && absDeltaX > 10) {
|
||||
if (absDeltaX > deltaY) {
|
||||
isSwiping.current = true
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isSwiping.current) {
|
||||
// Prevent scrolling when swiping
|
||||
e.preventDefault()
|
||||
|
||||
const currentIndex = tabs.findIndex((tab) => tab.value === currentListMode)
|
||||
|
||||
// Apply resistance at boundaries
|
||||
let translateX = deltaX
|
||||
if ((deltaX > 0 && currentIndex === 0) || (deltaX < 0 && currentIndex === tabs.length - 1)) {
|
||||
// Add resistance at boundaries (reduce movement to 30%)
|
||||
translateX = deltaX * 0.3
|
||||
}
|
||||
|
||||
currentTranslateX.current = translateX
|
||||
container.style.transition = 'none'
|
||||
container.style.transform = `translateX(${translateX}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (!isSwiping.current || isAnimating.current) {
|
||||
isSwiping.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX.current
|
||||
const deltaY = Math.abs(e.changedTouches[0].clientY - touchStartY.current)
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
const touchDuration = Date.now() - touchStartTime.current
|
||||
const velocity = absDeltaX / touchDuration // px per ms
|
||||
|
||||
const currentIndex = tabs.findIndex((tab) => tab.value === currentListMode)
|
||||
|
||||
// Determine if should switch tab
|
||||
// Switch if: moved > 100px OR (moved > 50px AND velocity > 0.3)
|
||||
const shouldSwitch = absDeltaX > 100 || (absDeltaX > 50 && velocity > 0.3)
|
||||
|
||||
if (shouldSwitch && absDeltaX > deltaY) {
|
||||
if (deltaX > 0 && currentIndex > 0) {
|
||||
// Swipe right - go to previous tab
|
||||
animateToTab(container, 'next', () => {
|
||||
handleListModeChange(tabs[currentIndex - 1].value as TNoteListMode)
|
||||
})
|
||||
} else if (deltaX < 0 && currentIndex < tabs.length - 1) {
|
||||
// Swipe left - go to next tab
|
||||
animateToTab(container, 'prev', () => {
|
||||
handleListModeChange(tabs[currentIndex + 1].value as TNoteListMode)
|
||||
})
|
||||
} else {
|
||||
// At boundary, bounce back
|
||||
animateToTab(container, 'cancel')
|
||||
}
|
||||
} else {
|
||||
// Not enough movement, bounce back
|
||||
animateToTab(container, 'cancel')
|
||||
}
|
||||
|
||||
isSwiping.current = false
|
||||
}
|
||||
|
||||
const animateToTab = (
|
||||
element: HTMLElement,
|
||||
direction: 'prev' | 'next' | 'cancel',
|
||||
callback?: () => void
|
||||
) => {
|
||||
isAnimating.current = true
|
||||
element.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
|
||||
if (direction === 'cancel') {
|
||||
// Bounce back to original position
|
||||
element.style.transform = 'translateX(0)'
|
||||
setTimeout(() => {
|
||||
isAnimating.current = false
|
||||
}, 300)
|
||||
} else {
|
||||
// Slide out to complete the transition
|
||||
const targetX = direction === 'next' ? window.innerWidth : -window.innerWidth
|
||||
element.style.transform = `translateX(${targetX}px)`
|
||||
|
||||
setTimeout(() => {
|
||||
if (callback) callback()
|
||||
element.style.transition = 'none'
|
||||
element.style.transform = 'translateX(0)'
|
||||
setTimeout(() => {
|
||||
isAnimating.current = false
|
||||
}, 50)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, [supportTouch, listMode, disable24hMode, tabs])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={listMode === '24h' && disable24hMode ? 'posts' : listMode}
|
||||
tabs={[
|
||||
{ value: 'posts', label: 'Notes' },
|
||||
{ value: 'postsAndReplies', label: 'Replies' },
|
||||
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
|
||||
]}
|
||||
tabs={tabs}
|
||||
onTabChange={(listMode) => {
|
||||
handleListModeChange(listMode as TNoteListMode)
|
||||
}}
|
||||
@@ -83,25 +231,27 @@ export default function NormalFeed({
|
||||
}
|
||||
/>
|
||||
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
|
||||
{listMode === '24h' && !disable24hMode ? (
|
||||
<UserAggregationList
|
||||
ref={userAggregationListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
subRequests={subRequests}
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
/>
|
||||
) : (
|
||||
<NoteList
|
||||
ref={noteListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
subRequests={subRequests}
|
||||
hideReplies={listMode === 'posts'}
|
||||
hideUntrustedNotes={hideUntrustedNotes}
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="overflow-hidden">
|
||||
{listMode === '24h' && !disable24hMode ? (
|
||||
<UserAggregationList
|
||||
ref={userAggregationListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
subRequests={subRequests}
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
/>
|
||||
) : (
|
||||
<NoteList
|
||||
ref={noteListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
subRequests={subRequests}
|
||||
hideReplies={listMode === 'posts'}
|
||||
hideUntrustedNotes={hideUntrustedNotes}
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user