Compare commits
10 Commits
bf15fad240
...
7462f796a4
Author | SHA1 | Date |
---|---|---|
Ducky | 7462f796a4 | |
Samuel Newman | 0e1de19903 | |
Samuel Newman | f9d736653c | |
Samuel Newman | 0469ca6cb4 | |
Fabio Nobre | f3f7dfc3e6 | |
Hailey | 4abcd65ccf | |
Mary | 05ac76fc89 | |
Hailey | 1225e84485 | |
Samuel Newman | eb868a042a | |
Samuel Newman | 17d82a64a6 |
|
@ -22,7 +22,7 @@
|
|||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.0.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.2.8",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
|||
import logo from '../../assets/logo.svg'
|
||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import {getRkey, niceDate} from '../utils'
|
||||
import {getRkey, niceDate, prettyNumber} from '../utils'
|
||||
import {Container} from './container'
|
||||
import {Embed} from './embed'
|
||||
import {Link} from './link'
|
||||
|
@ -78,7 +78,7 @@ export function Post({thread}: Props) {
|
|||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={likeIcon} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.likeCount}
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -86,7 +86,7 @@ export function Post({thread}: Props) {
|
|||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={repostIcon} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.repostCount}
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -97,7 +97,7 @@ export function Post({thread}: Props) {
|
|||
<div className="flex-1" />
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||
{post.replyCount
|
||||
? `Read ${post.replyCount} ${
|
||||
? `Read ${prettyNumber(post.replyCount)} ${
|
||||
post.replyCount > 1 ? 'replies' : 'reply'
|
||||
} on Bluesky`
|
||||
: `View on Bluesky`}
|
||||
|
|
|
@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
|
|||
const at = new AtUri(uri)
|
||||
return at.rkey
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
|
||||
export function prettyNumber(number: number) {
|
||||
return formatter.format(number)
|
||||
}
|
||||
|
|
|
@ -20,5 +20,5 @@
|
|||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["snippet"],
|
||||
"include": ["snippet"]
|
||||
}
|
||||
|
|
|
@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
|
|||
is-typed-array "^1.1.13"
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
typescript@^4.0.5:
|
||||
version "4.9.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
typescript@^5.5.4:
|
||||
version "5.5.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
||||
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
||||
|
||||
uint8arrays@3.0.0:
|
||||
version "3.0.0"
|
||||
|
|
|
@ -2,7 +2,6 @@ const path = require('path')
|
|||
const fs = require('fs')
|
||||
|
||||
const projectRoot = path.join(__dirname, '..')
|
||||
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
|
||||
const templateFile = path.join(
|
||||
projectRoot,
|
||||
'bskyweb',
|
||||
|
@ -10,18 +9,18 @@ const templateFile = path.join(
|
|||
'scripts.html',
|
||||
)
|
||||
|
||||
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
|
||||
jsFiles.sort((a, b) => {
|
||||
// make sure main is written last
|
||||
if (a.startsWith('main')) return 1
|
||||
if (b.startsWith('main')) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const {entrypoints} = require(path.join(
|
||||
projectRoot,
|
||||
'web-build/asset-manifest.json',
|
||||
))
|
||||
|
||||
console.log(`Found ${jsFiles.length} js files in web-build`)
|
||||
console.log(`Found ${entrypoints.length} entrypoints`)
|
||||
console.log(`Writing ${templateFile}`)
|
||||
|
||||
const outputFile = jsFiles
|
||||
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
|
||||
const outputFile = entrypoints
|
||||
.map(name => {
|
||||
const file = path.basename(name)
|
||||
return `<script defer="defer" src="/static/js/${file}"></script>`
|
||||
})
|
||||
.join('\n')
|
||||
fs.writeFileSync(templateFile, outputFile)
|
||||
|
|
|
@ -3,9 +3,9 @@ import {AppBskyActorDefs} from '@atproto/api'
|
|||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||
export const STAGING_SERVICE = 'https://zio.blue
|
||||
export const BSKY_SERVICE = 'https://zio.blue'
|
||||
export const PUBLIC_BSKY_SERVICE = 'https://zio.blue'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
LayoutAnimationConfig,
|
||||
useReducedMotion,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {decideShouldRoll} from 'lib/custom-animations/util'
|
||||
import {s} from 'lib/styles'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}
|
||||
|
||||
function EnteringUp() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(1, animationConfig),
|
||||
transform: [{translateY: withTiming(0, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 0,
|
||||
transform: [{translateY: 18}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function EnteringDown() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(1, animationConfig),
|
||||
transform: [{translateY: withTiming(0, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 0,
|
||||
transform: [{translateY: -18}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function ExitingUp() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(0, animationConfig),
|
||||
transform: [
|
||||
{
|
||||
translateY: withTiming(-18, animationConfig),
|
||||
},
|
||||
],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{translateY: 0}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function ExitingDown() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(0, animationConfig),
|
||||
transform: [{translateY: withTiming(18, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{translateY: 0}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
export function CountWheel({
|
||||
likeCount,
|
||||
big,
|
||||
isLiked,
|
||||
}: {
|
||||
likeCount: number
|
||||
big?: boolean
|
||||
isLiked: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const shouldAnimate = !useReducedMotion()
|
||||
const shouldRoll = decideShouldRoll(isLiked, likeCount)
|
||||
|
||||
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
|
||||
// animation
|
||||
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
|
||||
// be unnecessary
|
||||
const [key, setKey] = React.useState(0)
|
||||
const [prevCount, setPrevCount] = React.useState(likeCount)
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
const formattedCount = formatCount(i18n, likeCount)
|
||||
const formattedPrevCount = formatCount(i18n, prevCount)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLiked === prevIsLiked.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
|
||||
setKey(prev => prev + 1)
|
||||
setPrevCount(newPrevCount)
|
||||
prevIsLiked.current = isLiked
|
||||
}, [isLiked, likeCount])
|
||||
|
||||
const enteringAnimation =
|
||||
shouldAnimate && shouldRoll
|
||||
? isLiked
|
||||
? EnteringUp
|
||||
: EnteringDown
|
||||
: undefined
|
||||
const exitingAnimation =
|
||||
shouldAnimate && shouldRoll
|
||||
? isLiked
|
||||
? ExitingUp
|
||||
: ExitingDown
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{likeCount > 0 ? (
|
||||
<View style={[a.justify_center]}>
|
||||
<Animated.View entering={enteringAnimation} key={key}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedCount}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
|
||||
<Animated.View
|
||||
entering={exitingAnimation}
|
||||
// Add 2 to the key so there are never duplicates
|
||||
key={key + 2}
|
||||
style={[a.absolute, {width: 50, opacity: 0}]}
|
||||
aria-disabled={true}>
|
||||
<Text
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedPrevCount}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useReducedMotion} from 'react-native-reanimated'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {decideShouldRoll} from 'lib/custom-animations/util'
|
||||
import {s} from 'lib/styles'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fill: 'forwards' as FillMode,
|
||||
}
|
||||
|
||||
const enteringUpKeyframe = [
|
||||
{opacity: 0, transform: 'translateY(18px)'},
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
]
|
||||
|
||||
const enteringDownKeyframe = [
|
||||
{opacity: 0, transform: 'translateY(-18px)'},
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
]
|
||||
|
||||
const exitingUpKeyframe = [
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
{opacity: 0, transform: 'translateY(-18px)'},
|
||||
]
|
||||
|
||||
const exitingDownKeyframe = [
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
{opacity: 0, transform: 'translateY(18px)'},
|
||||
]
|
||||
|
||||
export function CountWheel({
|
||||
likeCount,
|
||||
big,
|
||||
isLiked,
|
||||
}: {
|
||||
likeCount: number
|
||||
big?: boolean
|
||||
isLiked: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const shouldAnimate = !useReducedMotion()
|
||||
const shouldRoll = decideShouldRoll(isLiked, likeCount)
|
||||
|
||||
const countView = React.useRef<HTMLDivElement>(null)
|
||||
const prevCountView = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [prevCount, setPrevCount] = React.useState(likeCount)
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
const formattedCount = formatCount(i18n, likeCount)
|
||||
const formattedPrevCount = formatCount(i18n, prevCount)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLiked === prevIsLiked.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
|
||||
if (shouldAnimate && shouldRoll) {
|
||||
countView.current?.animate?.(
|
||||
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
|
||||
animationConfig,
|
||||
)
|
||||
prevCountView.current?.animate?.(
|
||||
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
|
||||
animationConfig,
|
||||
)
|
||||
setPrevCount(newPrevCount)
|
||||
}
|
||||
prevIsLiked.current = isLiked
|
||||
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
|
||||
|
||||
if (likeCount < 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={countView}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedCount}
|
||||
</Text>
|
||||
</View>
|
||||
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
|
||||
<View
|
||||
style={{position: 'absolute', opacity: 0}}
|
||||
aria-disabled={true}
|
||||
// @ts-expect-error is div
|
||||
ref={prevCountView}>
|
||||
<Text
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedPrevCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Keyframe,
|
||||
LayoutAnimationConfig,
|
||||
useReducedMotion,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme} from '#/alf'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
|
||||
const keyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{scale: 1}],
|
||||
},
|
||||
10: {
|
||||
transform: [{scale: 0.7}],
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 1.2}],
|
||||
},
|
||||
100: {
|
||||
transform: [{scale: 1}],
|
||||
},
|
||||
})
|
||||
|
||||
const circle1Keyframe = new Keyframe({
|
||||
0: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
10: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
95: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
100: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
})
|
||||
|
||||
const circle2Keyframe = new Keyframe({
|
||||
0: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
10: {
|
||||
opacity: 1,
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
95: {
|
||||
opacity: 1,
|
||||
},
|
||||
100: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
})
|
||||
|
||||
export function AnimatedLikeIcon({
|
||||
isLiked,
|
||||
big,
|
||||
}: {
|
||||
isLiked: boolean
|
||||
big?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const size = big ? 22 : 18
|
||||
const shouldAnimate = !useReducedMotion()
|
||||
|
||||
return (
|
||||
<View>
|
||||
<LayoutAnimationConfig skipEntering>
|
||||
{isLiked ? (
|
||||
<Animated.View
|
||||
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
|
||||
<HeartIconFilled style={s.likeColor} width={size} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<HeartIconOutline
|
||||
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
|
||||
width={size}
|
||||
/>
|
||||
)}
|
||||
{isLiked ? (
|
||||
<>
|
||||
<Animated.View
|
||||
entering={
|
||||
shouldAnimate ? circle1Keyframe.duration(300) : undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: s.likeColor.color,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
entering={
|
||||
shouldAnimate ? circle2Keyframe.duration(300) : undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useReducedMotion} from 'react-native-reanimated'
|
||||
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme} from '#/alf'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fill: 'forwards' as FillMode,
|
||||
}
|
||||
|
||||
const keyframe = [
|
||||
{transform: 'scale(1)'},
|
||||
{transform: 'scale(0.7)'},
|
||||
{transform: 'scale(1.2)'},
|
||||
{transform: 'scale(1)'},
|
||||
]
|
||||
|
||||
const circle1Keyframe = [
|
||||
{opacity: 0, transform: 'scale(0)'},
|
||||
{opacity: 0.4},
|
||||
{transform: 'scale(1.5)'},
|
||||
{opacity: 0.4},
|
||||
{opacity: 0, transform: 'scale(1.5)'},
|
||||
]
|
||||
|
||||
const circle2Keyframe = [
|
||||
{opacity: 0, transform: 'scale(0)'},
|
||||
{opacity: 1},
|
||||
{transform: 'scale(0)'},
|
||||
{opacity: 1},
|
||||
{opacity: 0, transform: 'scale(1.5)'},
|
||||
]
|
||||
|
||||
export function AnimatedLikeIcon({
|
||||
isLiked,
|
||||
big,
|
||||
}: {
|
||||
isLiked: boolean
|
||||
big?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const size = big ? 22 : 18
|
||||
const shouldAnimate = !useReducedMotion()
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
|
||||
const likeIconRef = React.useRef<HTMLDivElement>(null)
|
||||
const circle1Ref = React.useRef<HTMLDivElement>(null)
|
||||
const circle2Ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevIsLiked.current === isLiked) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldAnimate && isLiked) {
|
||||
likeIconRef.current?.animate?.(keyframe, animationConfig)
|
||||
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
|
||||
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
|
||||
}
|
||||
prevIsLiked.current = isLiked
|
||||
}, [shouldAnimate, isLiked])
|
||||
|
||||
return (
|
||||
<View>
|
||||
{isLiked ? (
|
||||
// @ts-expect-error is div
|
||||
<View ref={likeIconRef}>
|
||||
<HeartIconFilled style={s.likeColor} width={size} />
|
||||
</View>
|
||||
) : (
|
||||
<HeartIconOutline
|
||||
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
|
||||
width={size}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={circle1Ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: s.likeColor.color,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={circle2Ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
// It should roll when:
|
||||
// - We're going from 1 to 0 (roll backwards)
|
||||
// - The count is anywhere between 1 and 999
|
||||
// - The count is going up and is a multiple of 100
|
||||
// - The count is going down and is 1 less than a multiple of 100
|
||||
export function decideShouldRoll(isSet: boolean, count: number) {
|
||||
let shouldRoll = false
|
||||
if (!isSet && count === 0) {
|
||||
shouldRoll = true
|
||||
} else if (count > 0 && count < 1000) {
|
||||
shouldRoll = true
|
||||
} else if (count > 0) {
|
||||
const mod = count % 100
|
||||
if (isSet && mod === 0) {
|
||||
shouldRoll = true
|
||||
} else if (!isSet && mod === 99) {
|
||||
shouldRoll = true
|
||||
}
|
||||
}
|
||||
return shouldRoll
|
||||
}
|
|
@ -29,5 +29,6 @@ export async function compressVideo(
|
|||
)
|
||||
|
||||
const info = await getVideoMetaData(compressed)
|
||||
return {uri: compressed, size: info.size}
|
||||
|
||||
return {uri: compressed, size: info.size, mimeType: `video/${info.extension}`}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ export async function compressVideo(
|
|||
size: blob.size,
|
||||
uri,
|
||||
bytes: await blob.arrayBuffer(),
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
|
|||
this.name = 'VideoTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ServerError'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export type CompressedVideo = {
|
||||
uri: string
|
||||
mimeType: string
|
||||
size: number
|
||||
// web only, can fall back to uri if missing
|
||||
bytes?: ArrayBuffer
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -24,3 +24,16 @@ export function useVideoAgent() {
|
|||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function mimeToExt(mimeType: string) {
|
||||
switch (mimeType) {
|
||||
case 'video/mp4':
|
||||
return 'mp4'
|
||||
case 'video/webm':
|
||||
return 'webm'
|
||||
case 'video/mpeg':
|
||||
return 'mpeg'
|
||||
default:
|
||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
|
@ -22,13 +25,14 @@ export const useUploadVideoMutation = ({
|
|||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`,
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||
|
@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
|
|||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-type': video.mimeType,
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
|
@ -65,6 +69,13 @@ export const useUploadVideoMutation = ({
|
|||
}
|
||||
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
|
||||
if (!responseBody.jobId) {
|
||||
throw new ServerError(
|
||||
responseBody.error || _(msg`Failed to upload video`),
|
||||
)
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}, signal),
|
||||
onError,
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
|
@ -21,13 +24,14 @@ export const useUploadVideoMutation = ({
|
|||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||
|
@ -63,23 +67,24 @@ export const useUploadVideoMutation = ({
|
|||
xhr.responseText,
|
||||
) as AppBskyVideoDefs.JobStatus
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.jobId) {
|
||||
throw new ServerError(res.error || _(msg`Failed to upload video`))
|
||||
}
|
||||
|
||||
return res
|
||||
}, signal),
|
||||
onError,
|
||||
|
|
|
@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
|
|||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {CompressedVideo} from 'lib/media/video/types'
|
||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||
import {useVideoAgent} from 'state/queries/video/util'
|
||||
|
@ -58,7 +59,12 @@ function reducer(queryClient: QueryClient) {
|
|||
abortController: new AbortController(),
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {...state, asset: action.asset}
|
||||
updatedState = {
|
||||
...state,
|
||||
asset: action.asset,
|
||||
status: 'compressing',
|
||||
error: undefined,
|
||||
}
|
||||
} else if (action.type === 'SetDimensions') {
|
||||
updatedState = {
|
||||
...state,
|
||||
|
@ -67,11 +73,11 @@ function reducer(queryClient: QueryClient) {
|
|||
: undefined,
|
||||
}
|
||||
} else if (action.type === 'SetVideo') {
|
||||
updatedState = {...state, video: action.video}
|
||||
updatedState = {...state, video: action.video, status: 'uploading'}
|
||||
} else if (action.type === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
} else if (action.type === 'SetBlobRef') {
|
||||
updatedState = {...state, blobRef: action.blobRef}
|
||||
updatedState = {...state, blobRef: action.blobRef, status: 'done'}
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
|
@ -108,10 +114,6 @@ export function useUploadVideo({
|
|||
type: 'SetBlobRef',
|
||||
blobRef,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'idle',
|
||||
})
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
@ -125,10 +127,17 @@ export function useUploadVideo({
|
|||
setJobId(response.jobId)
|
||||
},
|
||||
onError: e => {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`An error occurred while uploading the video.`),
|
||||
})
|
||||
if (e instanceof ServerError) {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: e.message,
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`An error occurred while uploading the video.`),
|
||||
})
|
||||
}
|
||||
logger.error('Error uploading video', {safeMessage: e})
|
||||
},
|
||||
setProgress: p => {
|
||||
|
@ -141,6 +150,13 @@ export function useUploadVideo({
|
|||
onProgress: p => {
|
||||
dispatch({type: 'SetProgress', progress: p})
|
||||
},
|
||||
onSuccess: (video: CompressedVideo) => {
|
||||
dispatch({
|
||||
type: 'SetVideo',
|
||||
video,
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
onError: e => {
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
dispatch({
|
||||
|
@ -150,36 +166,28 @@ export function useUploadVideo({
|
|||
} else {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
// @TODO better error message from server, left untranslated on purpose
|
||||
error: 'An error occurred while compressing the video.',
|
||||
error: _(msg`An error occurred while compressing the video.`),
|
||||
})
|
||||
logger.error('Error compressing video', {safeMessage: e})
|
||||
}
|
||||
},
|
||||
onSuccess: (video: CompressedVideo) => {
|
||||
dispatch({
|
||||
type: 'SetVideo',
|
||||
video,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'uploading',
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'compressing',
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
switch (getMimeType(asset)) {
|
||||
case 'video/mp4':
|
||||
case 'video/mpeg':
|
||||
case 'video/webm':
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
break
|
||||
default:
|
||||
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
|
||||
}
|
||||
}
|
||||
|
||||
const clearVideo = () => {
|
||||
|
@ -241,6 +249,21 @@ const useUploadStatusQuery = ({
|
|||
isError,
|
||||
setJobId: (_jobId: string) => {
|
||||
setJobId(_jobId)
|
||||
setEnabled(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeType(asset: ImagePickerAsset) {
|
||||
if (isWeb) {
|
||||
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
|
||||
if (!mimeType) {
|
||||
throw new Error('Could not determine mime type')
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
if (!asset.mimeType) {
|
||||
throw new Error('Could not determine mime type')
|
||||
}
|
||||
return asset.mimeType
|
||||
}
|
||||
|
|
|
@ -86,9 +86,9 @@ export function ServerInputDialog({
|
|||
label="Preferences"
|
||||
values={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Zio`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
{_(msg`Bluesky`)}
|
||||
{_(msg`Zio`)}
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
|
@ -152,9 +152,24 @@ export function ServerInputDialog({
|
|||
a.flex_1,
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your hosting
|
||||
provider. Custom hosting is now available in beta for
|
||||
developers.
|
||||
A small
|
||||
<InlineLinkText
|
||||
label={_(msg`PDS instance`)}
|
||||
to="https://github.com/bluesky-social/pds"
|
||||
style={[!gtMobile && a.text_md]}>
|
||||
<Trans>
|
||||
PDS instance
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
ran by
|
||||
<InlineLinkText
|
||||
label={_(msg`Zio`)}
|
||||
to="https://zio.sh"
|
||||
style={[!gtMobile && a.text_md]}>
|
||||
<Trans>
|
||||
Zio
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</P>
|
||||
</View>
|
||||
|
|
|
@ -181,6 +181,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
clearVideo,
|
||||
state: videoUploadState,
|
||||
updateVideoDimensions,
|
||||
dispatch: videoUploadDispatch,
|
||||
} = useUploadVideo({
|
||||
setStatus: setProcessingState,
|
||||
onSuccess: () => {
|
||||
|
@ -313,8 +314,8 @@ export const ComposePost = observer(function ComposePost({
|
|||
|
||||
if (
|
||||
!finishedUploading &&
|
||||
videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.asset
|
||||
videoUploadState.asset &&
|
||||
videoUploadState.status !== 'done'
|
||||
) {
|
||||
setPublishOnUpload(true)
|
||||
return
|
||||
|
@ -607,7 +608,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{error !== '' && (
|
||||
{(error !== '' || videoUploadState.error) && (
|
||||
<View style={[a.px_lg, a.pb_sm]}>
|
||||
<View
|
||||
style={[
|
||||
|
@ -623,7 +624,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
]}>
|
||||
<CircleInfo fill={t.palette.negative_400} />
|
||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||
{error}
|
||||
{error || videoUploadState.error}
|
||||
</NewText>
|
||||
<Button
|
||||
label={_(msg`Dismiss error`)}
|
||||
|
@ -638,7 +639,10 @@ export const ComposePost = observer(function ComposePost({
|
|||
right: a.px_md.paddingRight,
|
||||
},
|
||||
]}
|
||||
onPress={() => setError('')}>
|
||||
onPress={() => {
|
||||
if (error) setError('')
|
||||
else videoUploadDispatch({type: 'Reset'})
|
||||
}}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
|
@ -755,7 +759,8 @@ export const ComposePost = observer(function ComposePost({
|
|||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
{videoUploadState.status !== 'idle' ? (
|
||||
{videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
|
@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
|
@ -1032,15 +1038,33 @@ function ToolbarWrapper({
|
|||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
let text = ''
|
||||
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
text = _('Compressing video...')
|
||||
break
|
||||
case 'uploading':
|
||||
text = _('Uploading video...')
|
||||
break
|
||||
case 'processing':
|
||||
text = _('Processing video...')
|
||||
break
|
||||
case 'done':
|
||||
text = _('Video uploaded')
|
||||
break
|
||||
}
|
||||
|
||||
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
|
||||
const progress =
|
||||
state.status === 'compressing' || state.status === 'uploading'
|
||||
? state.progress
|
||||
: state.jobStatus?.progress ?? 100
|
||||
: 100
|
||||
|
||||
return (
|
||||
<ToolbarWrapper
|
||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
|
@ -1048,7 +1072,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
|||
color={t.palette.primary_500}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>{state.status}</Text>
|
||||
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,9 +19,10 @@ const VIDEO_MAX_DURATION = 90
|
|||
type Props = {
|
||||
onSelectVideo: (video: ImagePickerAsset) => void
|
||||
disabled?: boolean
|
||||
setError: (error: string) => void
|
||||
}
|
||||
|
||||
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||
export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
|
@ -41,9 +42,17 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
|||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
onSelectVideo(response.assets[0])
|
||||
try {
|
||||
onSelectVideo(response.assets[0])
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(_(msg`An error occurred while selecting the video`))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onSelectVideo, requestVideoAccessIfNeeded])
|
||||
}, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -43,7 +43,7 @@ export function VideoPreview({
|
|||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: t.palette.black},
|
||||
{backgroundColor: 'black'},
|
||||
]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {ImagePickerAsset} from 'expo-image-picker'
|
|||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoPreview({
|
||||
asset,
|
||||
|
@ -18,7 +18,6 @@ export function VideoPreview({
|
|||
setDimensions: (width: number, height: number) => void
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -54,13 +53,13 @@ export function VideoPreview({
|
|||
a.rounded_sm,
|
||||
{aspectRatio},
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: t.palette.black},
|
||||
{backgroundColor: 'black'},
|
||||
]}>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
<video
|
||||
ref={ref}
|
||||
src={video.uri}
|
||||
style={a.flex_1}
|
||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
|
|
|
@ -6,14 +6,6 @@ import {
|
|||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
|
@ -31,8 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
|
|||
import {shareUrl} from '#/lib/sharing'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {
|
||||
|
@ -45,16 +35,13 @@ import {
|
|||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {CountWheel} from 'lib/custom-animations/CountWheel'
|
||||
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||
import {formatCount} from '../numeric/format'
|
||||
import {Text} from '../text/Text'
|
||||
|
@ -120,17 +107,7 @@ let PostCtrls = ({
|
|||
) as StyleProp<ViewStyle>
|
||||
|
||||
const likeValue = post.viewer?.like ? 1 : 0
|
||||
const likeIconAnimValue = useSharedValue(likeValue)
|
||||
const likeTextAnimValue = useSharedValue(likeValue)
|
||||
const nextExpectedLikeValue = React.useRef(likeValue)
|
||||
React.useEffect(() => {
|
||||
// Catch nonlocal changes (e.g. shadow update) and always reflect them.
|
||||
if (likeValue !== nextExpectedLikeValue.current) {
|
||||
nextExpectedLikeValue.current = likeValue
|
||||
likeIconAnimValue.value = likeValue
|
||||
likeTextAnimValue.value = likeValue
|
||||
}
|
||||
}, [likeValue, likeIconAnimValue, likeTextAnimValue])
|
||||
|
||||
const onPressToggleLike = React.useCallback(async () => {
|
||||
if (isBlocked) {
|
||||
|
@ -144,19 +121,6 @@ let PostCtrls = ({
|
|||
try {
|
||||
if (!post.viewer?.like) {
|
||||
nextExpectedLikeValue.current = 1
|
||||
if (PlatformInfo.getIsReducedMotionEnabled()) {
|
||||
likeIconAnimValue.value = 1
|
||||
likeTextAnimValue.value = 1
|
||||
} else {
|
||||
likeIconAnimValue.value = withTiming(1, {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
likeTextAnimValue.value = withTiming(1, {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
}
|
||||
playHaptic()
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
|
@ -167,15 +131,6 @@ let PostCtrls = ({
|
|||
await queueLike()
|
||||
} else {
|
||||
nextExpectedLikeValue.current = 0
|
||||
likeIconAnimValue.value = 0 // Intentionally not animated
|
||||
if (PlatformInfo.getIsReducedMotionEnabled()) {
|
||||
likeTextAnimValue.value = 0
|
||||
} else {
|
||||
likeTextAnimValue.value = withTiming(0, {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
}
|
||||
await queueUnlike()
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
@ -185,8 +140,6 @@ let PostCtrls = ({
|
|||
}
|
||||
}, [
|
||||
_,
|
||||
likeIconAnimValue,
|
||||
likeTextAnimValue,
|
||||
playHaptic,
|
||||
post.uri,
|
||||
post.viewer?.like,
|
||||
|
@ -291,8 +244,8 @@ let PostCtrls = ({
|
|||
a.gap_xs,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{padding: 5},
|
||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||
],
|
||||
|
@ -364,13 +317,11 @@ let PostCtrls = ({
|
|||
}
|
||||
accessibilityHint=""
|
||||
hitSlop={POST_CTRL_HITSLOP}>
|
||||
<AnimatedLikeIcon
|
||||
big={big ?? false}
|
||||
likeIconAnimValue={likeIconAnimValue}
|
||||
likeTextAnimValue={likeTextAnimValue}
|
||||
defaultCtrlColor={defaultCtrlColor}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
|
||||
<CountWheel
|
||||
likeCount={post.likeCount ?? 0}
|
||||
big={big}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
@ -450,194 +401,3 @@ let PostCtrls = ({
|
|||
}
|
||||
PostCtrls = memo(PostCtrls)
|
||||
export {PostCtrls}
|
||||
|
||||
function AnimatedLikeIcon({
|
||||
big,
|
||||
likeIconAnimValue,
|
||||
likeTextAnimValue,
|
||||
defaultCtrlColor,
|
||||
isLiked,
|
||||
likeCount,
|
||||
}: {
|
||||
big: boolean
|
||||
likeIconAnimValue: SharedValue<number>
|
||||
likeTextAnimValue: SharedValue<number>
|
||||
defaultCtrlColor: StyleProp<ViewStyle>
|
||||
isLiked: boolean
|
||||
likeCount: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {i18n} = useLingui()
|
||||
const likeStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(
|
||||
likeIconAnimValue.value,
|
||||
[0, 0.1, 0.4, 1],
|
||||
[1, 0.7, 1.2, 1],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
const circle1Style = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
likeIconAnimValue.value,
|
||||
[0, 0.1, 0.95, 1],
|
||||
[0, 0.4, 0.4, 0],
|
||||
'clamp',
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(
|
||||
likeIconAnimValue.value,
|
||||
[0, 0.4, 1],
|
||||
[0, 1.5, 1.5],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
const circle2Style = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
likeIconAnimValue.value,
|
||||
[0, 0.1, 0.95, 1],
|
||||
[0, 1, 1, 0],
|
||||
'clamp',
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(
|
||||
likeIconAnimValue.value,
|
||||
[0, 0.4, 1],
|
||||
[0, 0, 1.5],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
const countStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
likeTextAnimValue.value,
|
||||
[0, 1],
|
||||
[0, big ? -22 : -18],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const prevFormattedCount = formatCount(
|
||||
i18n,
|
||||
isLiked ? likeCount - 1 : likeCount,
|
||||
)
|
||||
const nextFormattedCount = formatCount(
|
||||
i18n,
|
||||
isLiked ? likeCount : likeCount + 1,
|
||||
)
|
||||
const shouldRollLike =
|
||||
prevFormattedCount !== nextFormattedCount && prevFormattedCount !== '0'
|
||||
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
backgroundColor: s.likeColor.color,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: big ? 22 : 18,
|
||||
height: big ? 22 : 18,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: (big ? 22 : 18) / 2,
|
||||
},
|
||||
circle1Style,
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
backgroundColor: isWeb
|
||||
? t.atoms.bg_contrast_25.backgroundColor
|
||||
: t.atoms.bg.backgroundColor,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: big ? 22 : 18,
|
||||
height: big ? 22 : 18,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: (big ? 22 : 18) / 2,
|
||||
},
|
||||
circle2Style,
|
||||
]}
|
||||
/>
|
||||
<Animated.View style={likeStyle}>
|
||||
{isLiked ? (
|
||||
<HeartIconFilled style={s.likeColor} width={big ? 22 : 18} />
|
||||
) : (
|
||||
<HeartIconOutline
|
||||
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
|
||||
width={big ? 22 : 18}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
<View style={{overflow: 'hidden'}}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
|
||||
{opacity: shouldRollLike ? 0 : 1},
|
||||
],
|
||||
]}>
|
||||
{likeCount > 0 ? formatCount(i18n, likeCount) : ''}
|
||||
</Text>
|
||||
<Animated.View
|
||||
aria-hidden={true}
|
||||
style={[
|
||||
countStyle,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
opacity: shouldRollLike ? 1 : 0,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
|
||||
{height: big ? 22 : 18},
|
||||
],
|
||||
]}>
|
||||
{prevFormattedCount}
|
||||
</Text>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
|
||||
{height: big ? 22 : 18},
|
||||
],
|
||||
]}>
|
||||
{nextFormattedCount}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
{backgroundColor: 'black'},
|
||||
a.my_xs,
|
||||
]}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
|
|
|
@ -9,13 +9,12 @@ import {
|
|||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const gate = useGate()
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
|
@ -64,7 +63,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||
style={[
|
||||
a.w_full,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
{backgroundColor: 'black'},
|
||||
a.relative,
|
||||
a.rounded_sm,
|
||||
a.my_xs,
|
||||
|
|
|
@ -29,9 +29,9 @@ export function TimeIndicator({time}: {time: number}) {
|
|||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
minHeight: 20,
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
minHeight: 21,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
]}>
|
||||
|
|
|
@ -167,17 +167,20 @@ function VideoControls({
|
|||
/>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 4,
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
minHeight: 21,
|
||||
minWidth: 21,
|
||||
},
|
||||
]}>
|
||||
<Pressable
|
||||
onPress={toggleMuted}
|
||||
style={a.flex_1}
|
||||
|
@ -186,9 +189,9 @@ function VideoControls({
|
|||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{isMuted ? (
|
||||
<MuteIcon width={14} fill={t.palette.white} />
|
||||
<MuteIcon width={13} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
<UnmuteIcon width={13} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
|
|
@ -78,8 +78,8 @@ export function Controls({
|
|||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
onIn: onHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
@ -220,6 +220,25 @@ export function Controls({
|
|||
onSeek(clamp(currentTime + 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const onPointerMoveEmptySpace = useCallback(() => {
|
||||
setShowCursor(true)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
cursorTimeoutRef.current = setTimeout(() => {
|
||||
setShowCursor(false)
|
||||
onEndHover()
|
||||
}, 2000)
|
||||
}, [onEndHover])
|
||||
const onPointerLeaveEmptySpace = useCallback(() => {
|
||||
setShowCursor(false)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showControls =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
|
@ -236,13 +255,17 @@ export function Controls({
|
|||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onPointerEnter={onHover}
|
||||
onPointerMove={onHover}
|
||||
onPointerLeave={onEndHover}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityHint={_(
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
|
@ -250,10 +273,10 @@ export function Controls({
|
|||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={a.flex_1}
|
||||
style={[a.flex_1, web({cursor: showCursor ? 'pointer' : 'none'})]}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{active && !showControls && !focused && (
|
||||
{!showControls && !focused && duration > 0 && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
|
@ -407,8 +430,8 @@ function Scrubber({
|
|||
const [scrubberActive, setScrubberActive] = useState(false)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
onIn: onStartHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [seekPosition, setSeekPosition] = useState(0)
|
||||
|
@ -475,21 +498,8 @@ function Scrubber({
|
|||
if (isFirefox && scrubberActive) {
|
||||
document.body.classList.add('force-no-clicks')
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
document.documentElement.addEventListener(
|
||||
'mouseleave',
|
||||
() => {
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
}, [scrubberActive, onSeekEnd])
|
||||
|
@ -534,9 +544,8 @@ function Scrubber({
|
|||
<View
|
||||
testID="scrubber"
|
||||
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||
// @ts-expect-error web only -sfn
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}>
|
||||
onPointerEnter={onStartHover}
|
||||
onPointerLeave={onEndHover}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
|
@ -548,7 +557,8 @@ function Scrubber({
|
|||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}>
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -9918,14 +9918,14 @@ caniuse-api@^3.0.0:
|
|||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
|
||||
version "1.0.30001596"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
|
||||
integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
|
||||
version "1.0.30001655"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
||||
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
||||
|
||||
caniuse-lite@^1.0.30001587:
|
||||
version "1.0.30001620"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4"
|
||||
integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==
|
||||
version "1.0.30001655"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
||||
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
||||
|
||||
case-anything@^2.1.13:
|
||||
version "2.1.13"
|
||||
|
|
Loading…
Reference in New Issue