122a46891a
* Integrate Sonner for toasts * Fix animation on iOS * Refactor API * Update e2e file * [APP-1318] Post composer: combine image & video buttons (#8710) * add: select media btn * udpate: compose post with combined image and video support * add: video combine button with edge cases * add select media btn * test: select media btn * add: media button update * remove unused files and update toast on android * update: make strings shorter * add: ValidatedVideoAsset type * update link comments and add toast support for native and web * rebase latest toast and update toast structure * remove unused prop * fix types * undo changes to yarn.lock * remove: support for mkv files * update: eslint and prettier (cherry picked from commit f69779ee130f07e1c49219b53117e3bdd1a9f81b) * Add missing props to launchImageLibraryAsync (cherry picked from commit 2e80ae561fd66850f787cac0aae0fa5a6980f8f5) * Rough out new approach (cherry picked from commit 9add225160e7e407befc73e9cdd9743a30cdf1cd) * Comments and cleanup (cherry picked from commit e69bd186e7335372f440c446ae6643ed0fb15db9) * Handle native case (cherry picked from commit 74e38acdfd9181d0557426691fcbcbf0800481ca) * Refactor (cherry picked from commit 68aea496db8df54dba5f58da267ad962c28ef995) * Rename (cherry picked from commit 8609e59ad14219e7378ee6cb9514d633ce7efc27) * Cleanup, comments (cherry picked from commit 6c9c98648e37257285a9c8caeb1eadcc56c81402) * Rename (cherry picked from commit 66e3db539d5baa41436c9e49af06e87a78e9e7e1) * Handle selectionLimit on Android (cherry picked from commit 251f06dd5e65a7083b810bad3d81114b2fe9ab39) * create composer images in parallel (cherry picked from commit 70ea79d9d76d99e9c99a7d2296caed84c718650e) * Update toast API usage (cherry picked from commit e370018b8ed8cdfd7675c9634058c72cb59d39de) * Ensure once one type of media is selected, you can only select more of that type (cherry picked from commit 1a9e6e0cdb5234667f08e3dd9107ae598941fc23) * Remove TODO and debug code * Add more descriptive a11y label to button Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Add back post success toast * Include mimeType in toast error * Remove unneeded toast * Clarify hint * Typo Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * allow gifs on native, just treat as images * disable haptic toast * allow gifs on native, treat as videos * only do keyboard dismiss on native * tweak pasting logic * hide web scrubber in certain situations * Update MaxImages translation Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Add plural formatting to a11y hint translation Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix suggestion * Protect against no valid assets selected * Handle conversion of too-big assets on web * Reorder * Bump expo-image-picker to include bug/perf improvements See https://github.com/expo/expo/blob/main/packages/expo-image-picker/CHANGELOG.md#1700--2025-08-13 * Handle edge case validations * Ok actually bump expo-image-picker * Comment * HEIC support Android * Fix handling for new picker version, improve size validation * Remove getVideoMetadata handling, no longer needed * Handle web video duration * Update src/view/com/composer/SelectMediaButton.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --------- Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
242 lines
6.6 KiB
TypeScript
242 lines
6.6 KiB
TypeScript
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
import {View} from 'react-native'
|
|
import {msg} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import type React from 'react'
|
|
|
|
import {isFirefox, isTouchDevice} from '#/lib/browser'
|
|
import {clamp} from '#/lib/numbers'
|
|
import {atoms as a, useTheme, web} from '#/alf'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {formatTime} from './utils'
|
|
|
|
export function Scrubber({
|
|
duration,
|
|
currentTime,
|
|
onSeek,
|
|
onSeekEnd,
|
|
onSeekStart,
|
|
seekLeft,
|
|
seekRight,
|
|
togglePlayPause,
|
|
drawFocus,
|
|
}: {
|
|
duration: number
|
|
currentTime: number
|
|
onSeek: (time: number) => void
|
|
onSeekEnd: () => void
|
|
onSeekStart: () => void
|
|
seekLeft: () => void
|
|
seekRight: () => void
|
|
togglePlayPause: () => void
|
|
drawFocus: () => void
|
|
}) {
|
|
const {_} = useLingui()
|
|
const t = useTheme()
|
|
const [scrubberActive, setScrubberActive] = useState(false)
|
|
const {
|
|
state: hovered,
|
|
onIn: onStartHover,
|
|
onOut: onEndHover,
|
|
} = useInteractionState()
|
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
const [seekPosition, setSeekPosition] = useState(0)
|
|
const isSeekingRef = useRef(false)
|
|
const barRef = useRef<HTMLDivElement>(null)
|
|
const circleRef = useRef<HTMLDivElement>(null)
|
|
|
|
const seek = useCallback(
|
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!barRef.current) return
|
|
const {left, width} = barRef.current.getBoundingClientRect()
|
|
const x = evt.clientX
|
|
const percent = clamp((x - left) / width, 0, 1) * duration
|
|
onSeek(percent)
|
|
setSeekPosition(percent)
|
|
},
|
|
[duration, onSeek],
|
|
)
|
|
|
|
const onPointerDown = useCallback(
|
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
const target = evt.target
|
|
if (target instanceof Element) {
|
|
evt.preventDefault()
|
|
target.setPointerCapture(evt.pointerId)
|
|
isSeekingRef.current = true
|
|
seek(evt)
|
|
setScrubberActive(true)
|
|
onSeekStart()
|
|
}
|
|
},
|
|
[seek, onSeekStart],
|
|
)
|
|
|
|
const onPointerMove = useCallback(
|
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
if (isSeekingRef.current) {
|
|
evt.preventDefault()
|
|
seek(evt)
|
|
}
|
|
},
|
|
[seek],
|
|
)
|
|
|
|
const onPointerUp = useCallback(
|
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
const target = evt.target
|
|
if (isSeekingRef.current && target instanceof Element) {
|
|
evt.preventDefault()
|
|
target.releasePointerCapture(evt.pointerId)
|
|
isSeekingRef.current = false
|
|
onSeekEnd()
|
|
setScrubberActive(false)
|
|
}
|
|
},
|
|
[onSeekEnd],
|
|
)
|
|
|
|
useEffect(() => {
|
|
// HACK: there's divergent browser behaviour about what to do when
|
|
// a pointerUp event is fired outside the element that captured the
|
|
// pointer. Firefox clicks on the element the mouse is over, so we have
|
|
// to make everything unclickable while seeking -sfn
|
|
if (isFirefox && scrubberActive) {
|
|
document.body.classList.add('force-no-clicks')
|
|
|
|
return () => {
|
|
document.body.classList.remove('force-no-clicks')
|
|
}
|
|
}
|
|
}, [scrubberActive, onSeekEnd])
|
|
|
|
useEffect(() => {
|
|
if (!circleRef.current) return
|
|
if (focused) {
|
|
const abortController = new AbortController()
|
|
const {signal} = abortController
|
|
circleRef.current.addEventListener(
|
|
'keydown',
|
|
evt => {
|
|
// space: play/pause
|
|
// arrow left: seek backward
|
|
// arrow right: seek forward
|
|
|
|
if (evt.key === ' ') {
|
|
evt.preventDefault()
|
|
drawFocus()
|
|
togglePlayPause()
|
|
} else if (evt.key === 'ArrowLeft') {
|
|
evt.preventDefault()
|
|
drawFocus()
|
|
seekLeft()
|
|
} else if (evt.key === 'ArrowRight') {
|
|
evt.preventDefault()
|
|
drawFocus()
|
|
seekRight()
|
|
}
|
|
},
|
|
{signal},
|
|
)
|
|
|
|
return () => abortController.abort()
|
|
}
|
|
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
|
|
|
const progress = scrubberActive ? seekPosition : currentTime
|
|
const progressPercent = (progress / duration) * 100
|
|
|
|
if (duration < 3) return null
|
|
|
|
return (
|
|
<View
|
|
testID="scrubber"
|
|
style={[
|
|
{height: isTouchDevice ? 32 : 18, width: '100%'},
|
|
a.flex_shrink_0,
|
|
a.px_xs,
|
|
]}
|
|
onPointerEnter={onStartHover}
|
|
onPointerLeave={onEndHover}>
|
|
<div
|
|
ref={barRef}
|
|
style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
position: 'relative',
|
|
cursor: scrubberActive ? 'grabbing' : 'grab',
|
|
padding: '4px 0',
|
|
}}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}>
|
|
<View
|
|
style={[
|
|
a.w_full,
|
|
a.rounded_full,
|
|
a.overflow_hidden,
|
|
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
|
{height: hovered || scrubberActive ? 6 : 3},
|
|
web({transition: 'height 0.1s ease'}),
|
|
]}>
|
|
{duration > 0 && (
|
|
<View
|
|
style={[
|
|
a.h_full,
|
|
{backgroundColor: t.palette.white},
|
|
{width: `${progressPercent}%`},
|
|
]}
|
|
/>
|
|
)}
|
|
</View>
|
|
<div
|
|
ref={circleRef}
|
|
aria-label={_(
|
|
msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`,
|
|
)}
|
|
role="slider"
|
|
aria-valuemax={duration}
|
|
aria-valuemin={0}
|
|
aria-valuenow={currentTime}
|
|
aria-valuetext={_(
|
|
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
|
)}
|
|
tabIndex={0}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
style={{
|
|
position: 'absolute',
|
|
height: 16,
|
|
width: 16,
|
|
left: `calc(${progressPercent}% - 8px)`,
|
|
borderRadius: 8,
|
|
pointerEvents: 'none',
|
|
}}>
|
|
<View
|
|
style={[
|
|
a.w_full,
|
|
a.h_full,
|
|
a.rounded_full,
|
|
{backgroundColor: t.palette.white},
|
|
{
|
|
transform: [
|
|
{
|
|
scale:
|
|
hovered || scrubberActive || focused
|
|
? scrubberActive
|
|
? 1
|
|
: 0.6
|
|
: 0,
|
|
},
|
|
],
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</View>
|
|
)
|
|
}
|