diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index eef29da5..613f95e0 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -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(null) const userAggregationListRef = useRef(null) const topRef = useRef(null) + const containerRef = useRef(null) const showKindsFilter = useMemo(() => { return subRequests.every((req) => !req.filter.kinds?.length) }, [subRequests]) + // Touch swipe state for tab switching + const touchStartX = useRef(0) + const touchStartY = useRef(0) + const touchStartTime = useRef(0) + const currentTranslateX = useRef(0) + const isSwiping = useRef(false) + const isAnimating = useRef(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 ( <> { handleListModeChange(listMode as TNoteListMode) }} @@ -83,25 +231,27 @@ export default function NormalFeed({ } />
- {listMode === '24h' && !disable24hMode ? ( - - ) : ( - - )} +
+ {listMode === '24h' && !disable24hMode ? ( + + ) : ( + + )} +
) }