ultisuite-client/hooks/use-mail-list-pull-refresh.ts
2026-05-20 16:01:08 +02:00

134 lines
4.3 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
export const PULL_HOLD_HEIGHT = 48
export const PULL_REFRESH_THRESHOLD = 56
export const PULL_REFRESH_MAX = 112
export const PULL_SNAP_BACK_TRANSITION =
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)"
export const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]"
export const PULL_ICON_FADE_MS = 120
export const PULL_SPINNER_REVEAL_OFFSET = 26
export function computePullOffset(delta: number): number {
if (delta <= 0) return 0
const damped = delta * 0.48
const capped = Math.min(PULL_REFRESH_MAX, damped)
const ratio = capped / PULL_REFRESH_MAX
return capped * (1 - ratio * 0.12)
}
export function computeSpinnerRevealProgress(y: number): number {
if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0
const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET)
return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35)
}
export function useMailListPullRefresh(opts: {
enabled: boolean
isViewMode: boolean
onRefresh: () => void | Promise<void>
}) {
const { enabled, isViewMode, onRefresh } = opts
const [isRefreshing, setIsRefreshing] = useState(false)
const pullYRef = useRef(0)
const pullActiveRef = useRef(false)
const pullContentRef = useRef<HTMLDivElement>(null)
const pullIconRef = useRef<SVGSVGElement | null>(null)
const listViewportRef = useRef<HTMLDivElement>(null)
const applyPullVisual = useCallback((y: number, animate: boolean) => {
const el = pullContentRef.current
const icon = pullIconRef.current
if (!el) return
const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none"
el.style.transition = transition
el.style.transform = y > 0 ? `translateY(${y}px)` : ""
if (icon) {
const progress = computeSpinnerRevealProgress(y)
icon.style.opacity = String(progress)
icon.style.transform = `rotate(${progress * 360}deg)`
icon.style.transition = animate
? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out`
: "none"
}
}, [])
const resetPullVisual = useCallback(() => {
pullYRef.current = 0
applyPullVisual(0, true)
}, [applyPullVisual])
const releasePull = useCallback(async () => {
const offset = pullYRef.current
pullActiveRef.current = false
if (offset >= PULL_REFRESH_THRESHOLD) {
pullYRef.current = PULL_HOLD_HEIGHT
applyPullVisual(PULL_HOLD_HEIGHT, false)
setIsRefreshing(true)
try {
await onRefresh()
} finally {
setIsRefreshing(false)
pullYRef.current = 0
applyPullVisual(0, true)
}
} else {
resetPullVisual()
}
}, [applyPullVisual, onRefresh, resetPullVisual])
useEffect(() => {
if (isViewMode || !enabled || isRefreshing) return
const root = listViewportRef.current
if (!root) return
let startY = 0
const onTouchStart = (e: TouchEvent) => {
if (root.scrollTop > 0 || isRefreshing) return
startY = e.touches[0]?.clientY ?? 0
pullActiveRef.current = true
}
const onTouchMove = (e: TouchEvent) => {
if (!pullActiveRef.current || isRefreshing) return
const y = e.touches[0]?.clientY ?? 0
const delta = y - startY
if (delta <= 0) {
pullYRef.current = 0
applyPullVisual(0, false)
return
}
if (root.scrollTop > 0) return
e.preventDefault()
const offset = computePullOffset(delta)
pullYRef.current = offset
applyPullVisual(offset, false)
}
const onTouchEnd = () => {
if (!pullActiveRef.current) return
void releasePull()
}
root.addEventListener("touchstart", onTouchStart, { passive: true })
root.addEventListener("touchmove", onTouchMove, { passive: false })
root.addEventListener("touchend", onTouchEnd)
root.addEventListener("touchcancel", onTouchEnd)
return () => {
root.removeEventListener("touchstart", onTouchStart)
root.removeEventListener("touchmove", onTouchMove)
root.removeEventListener("touchend", onTouchEnd)
root.removeEventListener("touchcancel", onTouchEnd)
}
}, [enabled, isRefreshing, isViewMode, applyPullVisual, releasePull])
return {
isRefreshing,
setIsRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
resetPullVisual,
}
}