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",
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^4.0.5",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"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 logo from '../../assets/logo.svg'
|
||||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||||
import {CONTENT_LABELS} from '../labels'
|
import {CONTENT_LABELS} from '../labels'
|
||||||
import {getRkey, niceDate} from '../utils'
|
import {getRkey, niceDate, prettyNumber} from '../utils'
|
||||||
import {Container} from './container'
|
import {Container} from './container'
|
||||||
import {Embed} from './embed'
|
import {Embed} from './embed'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
|
@ -78,7 +78,7 @@ export function Post({thread}: Props) {
|
||||||
<div className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
<img src={likeIcon} className="w-5 h-5" />
|
<img src={likeIcon} className="w-5 h-5" />
|
||||||
<p className="font-bold text-neutral-500 mb-px">
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
{post.likeCount}
|
{prettyNumber(post.likeCount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -86,7 +86,7 @@ export function Post({thread}: Props) {
|
||||||
<div className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
<img src={repostIcon} className="w-5 h-5" />
|
<img src={repostIcon} className="w-5 h-5" />
|
||||||
<p className="font-bold text-neutral-500 mb-px">
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
{post.repostCount}
|
{prettyNumber(post.repostCount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -97,7 +97,7 @@ export function Post({thread}: Props) {
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||||
{post.replyCount
|
{post.replyCount
|
||||||
? `Read ${post.replyCount} ${
|
? `Read ${prettyNumber(post.replyCount)} ${
|
||||||
post.replyCount > 1 ? 'replies' : 'reply'
|
post.replyCount > 1 ? 'replies' : 'reply'
|
||||||
} on Bluesky`
|
} on Bluesky`
|
||||||
: `View on Bluesky`}
|
: `View on Bluesky`}
|
||||||
|
|
|
@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
|
||||||
const at = new AtUri(uri)
|
const at = new AtUri(uri)
|
||||||
return at.rkey
|
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",
|
"jsxFragmentFactory": "Fragment",
|
||||||
"downlevelIteration": true
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,5 +6,5 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["snippet"],
|
"include": ["snippet"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
|
||||||
is-typed-array "^1.1.13"
|
is-typed-array "^1.1.13"
|
||||||
possible-typed-array-names "^1.0.0"
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
typescript@^4.0.5:
|
typescript@^5.5.4:
|
||||||
version "4.9.5"
|
version "5.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
||||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
||||||
|
|
||||||
uint8arrays@3.0.0:
|
uint8arrays@3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
|
|
|
@ -2,7 +2,6 @@ const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
const projectRoot = path.join(__dirname, '..')
|
const projectRoot = path.join(__dirname, '..')
|
||||||
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
|
|
||||||
const templateFile = path.join(
|
const templateFile = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'bskyweb',
|
'bskyweb',
|
||||||
|
@ -10,18 +9,18 @@ const templateFile = path.join(
|
||||||
'scripts.html',
|
'scripts.html',
|
||||||
)
|
)
|
||||||
|
|
||||||
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
|
const {entrypoints} = require(path.join(
|
||||||
jsFiles.sort((a, b) => {
|
projectRoot,
|
||||||
// make sure main is written last
|
'web-build/asset-manifest.json',
|
||||||
if (a.startsWith('main')) return 1
|
))
|
||||||
if (b.startsWith('main')) return -1
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Found ${jsFiles.length} js files in web-build`)
|
console.log(`Found ${entrypoints.length} entrypoints`)
|
||||||
console.log(`Writing ${templateFile}`)
|
console.log(`Writing ${templateFile}`)
|
||||||
|
|
||||||
const outputFile = jsFiles
|
const outputFile = entrypoints
|
||||||
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
|
.map(name => {
|
||||||
|
const file = path.basename(name)
|
||||||
|
return `<script defer="defer" src="/static/js/${file}"></script>`
|
||||||
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
fs.writeFileSync(templateFile, outputFile)
|
fs.writeFileSync(templateFile, outputFile)
|
||||||
|
|
|
@ -3,9 +3,9 @@ import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
export const LOCAL_DEV_SERVICE =
|
export const LOCAL_DEV_SERVICE =
|
||||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
export const STAGING_SERVICE = 'https://zio.blue
|
||||||
export const BSKY_SERVICE = 'https://bsky.social'
|
export const BSKY_SERVICE = 'https://zio.blue'
|
||||||
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
export const PUBLIC_BSKY_SERVICE = 'https://zio.blue'
|
||||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||||
const HELP_DESK_LANG = 'en-us'
|
const HELP_DESK_LANG = 'en-us'
|
||||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
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)
|
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,
|
size: blob.size,
|
||||||
uri,
|
uri,
|
||||||
bytes: await blob.arrayBuffer(),
|
bytes: await blob.arrayBuffer(),
|
||||||
|
mimeType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
|
||||||
this.name = 'VideoTooLargeError'
|
this.name = 'VideoTooLargeError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ServerError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ServerError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export type CompressedVideo = {
|
export type CompressedVideo = {
|
||||||
uri: string
|
uri: string
|
||||||
|
mimeType: string
|
||||||
size: number
|
size: number
|
||||||
// web only, can fall back to uri if missing
|
// web only, can fall back to uri if missing
|
||||||
bytes?: ArrayBuffer
|
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 {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||||
import {AppBskyVideoDefs} from '@atproto/api'
|
import {AppBskyVideoDefs} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {cancelable} from '#/lib/async/cancelable'
|
import {cancelable} from '#/lib/async/cancelable'
|
||||||
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
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 {useAgent, useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||||
|
|
||||||
|
@ -22,13 +25,14 @@ export const useUploadVideoMutation = ({
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.mp4`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
|
||||||
video.uri,
|
video.uri,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'video/mp4',
|
'content-type': video.mimeType,
|
||||||
Authorization: `Bearer ${serviceAuth.token}`,
|
Authorization: `Bearer ${serviceAuth.token}`,
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
|
@ -65,6 +69,13 @@ export const useUploadVideoMutation = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||||
|
|
||||||
|
if (!responseBody.jobId) {
|
||||||
|
throw new ServerError(
|
||||||
|
responseBody.error || _(msg`Failed to upload video`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return responseBody
|
return responseBody
|
||||||
}, signal),
|
}, signal),
|
||||||
onError,
|
onError,
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import {AppBskyVideoDefs} from '@atproto/api'
|
import {AppBskyVideoDefs} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {cancelable} from '#/lib/async/cancelable'
|
import {cancelable} from '#/lib/async/cancelable'
|
||||||
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
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 {useAgent, useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||||
|
|
||||||
|
@ -21,13 +24,14 @@ export const useUploadVideoMutation = ({
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
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)
|
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
@ -63,23 +67,24 @@ export const useUploadVideoMutation = ({
|
||||||
xhr.responseText,
|
xhr.responseText,
|
||||||
) as AppBskyVideoDefs.JobStatus
|
) as AppBskyVideoDefs.JobStatus
|
||||||
resolve(uploadRes)
|
resolve(uploadRes)
|
||||||
onSuccess(uploadRes)
|
|
||||||
} else {
|
} else {
|
||||||
reject()
|
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||||
onError(new Error('Failed to upload video'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
reject()
|
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||||
onError(new Error('Failed to upload video'))
|
|
||||||
}
|
}
|
||||||
xhr.open('POST', uri)
|
xhr.open('POST', uri)
|
||||||
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||||
xhr.send(bytes)
|
xhr.send(bytes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!res.jobId) {
|
||||||
|
throw new ServerError(res.error || _(msg`Failed to upload video`))
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}, signal),
|
}, signal),
|
||||||
onError,
|
onError,
|
||||||
|
|
|
@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
|
||||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
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 {CompressedVideo} from 'lib/media/video/types'
|
||||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||||
import {useVideoAgent} from 'state/queries/video/util'
|
import {useVideoAgent} from 'state/queries/video/util'
|
||||||
|
@ -58,7 +59,12 @@ function reducer(queryClient: QueryClient) {
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
}
|
}
|
||||||
} else if (action.type === 'SetAsset') {
|
} else if (action.type === 'SetAsset') {
|
||||||
updatedState = {...state, asset: action.asset}
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
asset: action.asset,
|
||||||
|
status: 'compressing',
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
} else if (action.type === 'SetDimensions') {
|
} else if (action.type === 'SetDimensions') {
|
||||||
updatedState = {
|
updatedState = {
|
||||||
...state,
|
...state,
|
||||||
|
@ -67,11 +73,11 @@ function reducer(queryClient: QueryClient) {
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
} else if (action.type === 'SetVideo') {
|
} else if (action.type === 'SetVideo') {
|
||||||
updatedState = {...state, video: action.video}
|
updatedState = {...state, video: action.video, status: 'uploading'}
|
||||||
} else if (action.type === 'SetJobStatus') {
|
} else if (action.type === 'SetJobStatus') {
|
||||||
updatedState = {...state, jobStatus: action.jobStatus}
|
updatedState = {...state, jobStatus: action.jobStatus}
|
||||||
} else if (action.type === 'SetBlobRef') {
|
} else if (action.type === 'SetBlobRef') {
|
||||||
updatedState = {...state, blobRef: action.blobRef}
|
updatedState = {...state, blobRef: action.blobRef, status: 'done'}
|
||||||
}
|
}
|
||||||
return updatedState
|
return updatedState
|
||||||
}
|
}
|
||||||
|
@ -108,10 +114,6 @@ export function useUploadVideo({
|
||||||
type: 'SetBlobRef',
|
type: 'SetBlobRef',
|
||||||
blobRef,
|
blobRef,
|
||||||
})
|
})
|
||||||
dispatch({
|
|
||||||
type: 'SetStatus',
|
|
||||||
status: 'idle',
|
|
||||||
})
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -125,10 +127,17 @@ export function useUploadVideo({
|
||||||
setJobId(response.jobId)
|
setJobId(response.jobId)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
|
if (e instanceof ServerError) {
|
||||||
|
dispatch({
|
||||||
|
type: 'SetError',
|
||||||
|
error: e.message,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: _(msg`An error occurred while uploading the video.`),
|
error: _(msg`An error occurred while uploading the video.`),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
logger.error('Error uploading video', {safeMessage: e})
|
logger.error('Error uploading video', {safeMessage: e})
|
||||||
},
|
},
|
||||||
setProgress: p => {
|
setProgress: p => {
|
||||||
|
@ -141,6 +150,13 @@ export function useUploadVideo({
|
||||||
onProgress: p => {
|
onProgress: p => {
|
||||||
dispatch({type: 'SetProgress', progress: p})
|
dispatch({type: 'SetProgress', progress: p})
|
||||||
},
|
},
|
||||||
|
onSuccess: (video: CompressedVideo) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'SetVideo',
|
||||||
|
video,
|
||||||
|
})
|
||||||
|
onVideoCompressed(video)
|
||||||
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
if (e instanceof VideoTooLargeError) {
|
if (e instanceof VideoTooLargeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -150,36 +166,28 @@ export function useUploadVideo({
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
// @TODO better error message from server, left untranslated on purpose
|
error: _(msg`An error occurred while compressing the video.`),
|
||||||
error: 'An error occurred while compressing the video.',
|
|
||||||
})
|
})
|
||||||
logger.error('Error compressing video', {safeMessage: e})
|
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,
|
signal: state.abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectVideo = (asset: ImagePickerAsset) => {
|
const selectVideo = (asset: ImagePickerAsset) => {
|
||||||
|
switch (getMimeType(asset)) {
|
||||||
|
case 'video/mp4':
|
||||||
|
case 'video/mpeg':
|
||||||
|
case 'video/webm':
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetAsset',
|
type: 'SetAsset',
|
||||||
asset,
|
asset,
|
||||||
})
|
})
|
||||||
dispatch({
|
|
||||||
type: 'SetStatus',
|
|
||||||
status: 'compressing',
|
|
||||||
})
|
|
||||||
onSelectVideo(asset)
|
onSelectVideo(asset)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearVideo = () => {
|
const clearVideo = () => {
|
||||||
|
@ -241,6 +249,21 @@ const useUploadStatusQuery = ({
|
||||||
isError,
|
isError,
|
||||||
setJobId: (_jobId: string) => {
|
setJobId: (_jobId: string) => {
|
||||||
setJobId(_jobId)
|
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"
|
label="Preferences"
|
||||||
values={fixedOption}
|
values={fixedOption}
|
||||||
onChange={setFixedOption}>
|
onChange={setFixedOption}>
|
||||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Zio`)}>
|
||||||
<ToggleButton.ButtonText>
|
<ToggleButton.ButtonText>
|
||||||
{_(msg`Bluesky`)}
|
{_(msg`Zio`)}
|
||||||
</ToggleButton.ButtonText>
|
</ToggleButton.ButtonText>
|
||||||
</ToggleButton.Button>
|
</ToggleButton.Button>
|
||||||
<ToggleButton.Button
|
<ToggleButton.Button
|
||||||
|
@ -152,9 +152,24 @@ export function ServerInputDialog({
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
]}>
|
]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Bluesky is an open network where you can choose your hosting
|
A small
|
||||||
provider. Custom hosting is now available in beta for
|
<InlineLinkText
|
||||||
developers.
|
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>
|
</Trans>
|
||||||
</P>
|
</P>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -181,6 +181,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
clearVideo,
|
clearVideo,
|
||||||
state: videoUploadState,
|
state: videoUploadState,
|
||||||
updateVideoDimensions,
|
updateVideoDimensions,
|
||||||
|
dispatch: videoUploadDispatch,
|
||||||
} = useUploadVideo({
|
} = useUploadVideo({
|
||||||
setStatus: setProcessingState,
|
setStatus: setProcessingState,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
@ -313,8 +314,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!finishedUploading &&
|
!finishedUploading &&
|
||||||
videoUploadState.status !== 'idle' &&
|
videoUploadState.asset &&
|
||||||
videoUploadState.asset
|
videoUploadState.status !== 'done'
|
||||||
) {
|
) {
|
||||||
setPublishOnUpload(true)
|
setPublishOnUpload(true)
|
||||||
return
|
return
|
||||||
|
@ -607,7 +608,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{error !== '' && (
|
{(error !== '' || videoUploadState.error) && (
|
||||||
<View style={[a.px_lg, a.pb_sm]}>
|
<View style={[a.px_lg, a.pb_sm]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -623,7 +624,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
]}>
|
]}>
|
||||||
<CircleInfo fill={t.palette.negative_400} />
|
<CircleInfo fill={t.palette.negative_400} />
|
||||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||||
{error}
|
{error || videoUploadState.error}
|
||||||
</NewText>
|
</NewText>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Dismiss error`)}
|
label={_(msg`Dismiss error`)}
|
||||||
|
@ -638,7 +639,10 @@ export const ComposePost = observer(function ComposePost({
|
||||||
right: a.px_md.paddingRight,
|
right: a.px_md.paddingRight,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() => setError('')}>
|
onPress={() => {
|
||||||
|
if (error) setError('')
|
||||||
|
else videoUploadDispatch({type: 'Reset'})
|
||||||
|
}}>
|
||||||
<ButtonIcon icon={X} />
|
<ButtonIcon icon={X} />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
@ -755,7 +759,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
styles.bottomBar,
|
styles.bottomBar,
|
||||||
]}>
|
]}>
|
||||||
{videoUploadState.status !== 'idle' ? (
|
{videoUploadState.status !== 'idle' &&
|
||||||
|
videoUploadState.status !== 'done' ? (
|
||||||
<VideoUploadToolbar state={videoUploadState} />
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
) : (
|
) : (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
<SelectVideoBtn
|
<SelectVideoBtn
|
||||||
onSelectVideo={selectVideo}
|
onSelectVideo={selectVideo}
|
||||||
disabled={!canSelectImages}
|
disabled={!canSelectImages}
|
||||||
|
setError={setError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
|
@ -1032,15 +1038,33 @@ function ToolbarWrapper({
|
||||||
|
|
||||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||||
const t = useTheme()
|
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 =
|
const progress =
|
||||||
state.status === 'compressing' || state.status === 'uploading'
|
state.status === 'compressing' || state.status === 'uploading'
|
||||||
? state.progress
|
? state.progress
|
||||||
: state.jobStatus?.progress ?? 100
|
: 100
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarWrapper
|
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={30}
|
size={30}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
|
@ -1048,7 +1072,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||||
color={t.palette.primary_500}
|
color={t.palette.primary_500}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
<Text>{state.status}</Text>
|
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,9 +19,10 @@ const VIDEO_MAX_DURATION = 90
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectVideo: (video: ImagePickerAsset) => void
|
onSelectVideo: (video: ImagePickerAsset) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
setError: (error: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
|
@ -41,9 +42,17 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||||
})
|
})
|
||||||
if (response.assets && response.assets.length > 0) {
|
if (response.assets && response.assets.length > 0) {
|
||||||
|
try {
|
||||||
onSelectVideo(response.assets[0])
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -43,7 +43,7 @@ export function VideoPreview({
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.border,
|
a.border,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{backgroundColor: t.palette.black},
|
{backgroundColor: 'black'},
|
||||||
]}>
|
]}>
|
||||||
<VideoView
|
<VideoView
|
||||||
player={player}
|
player={player}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
|
@ -18,7 +18,6 @@ export function VideoPreview({
|
||||||
setDimensions: (width: number, height: number) => void
|
setDimensions: (width: number, height: number) => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -54,13 +53,13 @@ export function VideoPreview({
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{backgroundColor: t.palette.black},
|
{backgroundColor: 'black'},
|
||||||
]}>
|
]}>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={video.uri}
|
src={video.uri}
|
||||||
style={a.flex_1}
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
|
|
|
@ -6,14 +6,6 @@ import {
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from 'react-native-reanimated'
|
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
@ -31,8 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {s} from '#/lib/styles'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {
|
import {
|
||||||
|
@ -45,16 +35,13 @@ import {
|
||||||
ProgressGuideAction,
|
ProgressGuideAction,
|
||||||
useProgressGuideControls,
|
useProgressGuideControls,
|
||||||
} from '#/state/shell/progress-guide'
|
} 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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
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 * as Prompt from '#/components/Prompt'
|
||||||
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
|
|
||||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||||
import {formatCount} from '../numeric/format'
|
import {formatCount} from '../numeric/format'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
|
@ -120,17 +107,7 @@ let PostCtrls = ({
|
||||||
) as StyleProp<ViewStyle>
|
) as StyleProp<ViewStyle>
|
||||||
|
|
||||||
const likeValue = post.viewer?.like ? 1 : 0
|
const likeValue = post.viewer?.like ? 1 : 0
|
||||||
const likeIconAnimValue = useSharedValue(likeValue)
|
|
||||||
const likeTextAnimValue = useSharedValue(likeValue)
|
|
||||||
const nextExpectedLikeValue = React.useRef(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 () => {
|
const onPressToggleLike = React.useCallback(async () => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
|
@ -144,19 +121,6 @@ let PostCtrls = ({
|
||||||
try {
|
try {
|
||||||
if (!post.viewer?.like) {
|
if (!post.viewer?.like) {
|
||||||
nextExpectedLikeValue.current = 1
|
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()
|
playHaptic()
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
|
@ -167,15 +131,6 @@ let PostCtrls = ({
|
||||||
await queueLike()
|
await queueLike()
|
||||||
} else {
|
} else {
|
||||||
nextExpectedLikeValue.current = 0
|
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()
|
await queueUnlike()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
@ -185,8 +140,6 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
_,
|
_,
|
||||||
likeIconAnimValue,
|
|
||||||
likeTextAnimValue,
|
|
||||||
playHaptic,
|
playHaptic,
|
||||||
post.uri,
|
post.uri,
|
||||||
post.viewer?.like,
|
post.viewer?.like,
|
||||||
|
@ -291,8 +244,8 @@ let PostCtrls = ({
|
||||||
a.gap_xs,
|
a.gap_xs,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
{padding: 5},
|
{padding: 5},
|
||||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||||
],
|
],
|
||||||
|
@ -364,13 +317,11 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
hitSlop={POST_CTRL_HITSLOP}>
|
hitSlop={POST_CTRL_HITSLOP}>
|
||||||
<AnimatedLikeIcon
|
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
|
||||||
big={big ?? false}
|
<CountWheel
|
||||||
likeIconAnimValue={likeIconAnimValue}
|
|
||||||
likeTextAnimValue={likeTextAnimValue}
|
|
||||||
defaultCtrlColor={defaultCtrlColor}
|
|
||||||
isLiked={Boolean(post.viewer?.like)}
|
|
||||||
likeCount={post.likeCount ?? 0}
|
likeCount={post.likeCount ?? 0}
|
||||||
|
big={big}
|
||||||
|
isLiked={Boolean(post.viewer?.like)}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
@ -450,194 +401,3 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
PostCtrls = memo(PostCtrls)
|
PostCtrls = memo(PostCtrls)
|
||||||
export {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.rounded_sm,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
{backgroundColor: t.palette.black},
|
{backgroundColor: 'black'},
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
<ErrorBoundary renderError={renderError} key={key}>
|
<ErrorBoundary renderError={renderError} key={key}>
|
||||||
|
|
|
@ -9,13 +9,12 @@ import {
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoEmbedInnerWeb,
|
VideoEmbedInnerWeb,
|
||||||
} from '#/view/com/util/post-embeds/VideoEmbedInner/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 {ErrorBoundary} from '../ErrorBoundary'
|
||||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
|
|
||||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||||
const t = useTheme()
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
const {active, setActive, sendPosition, currentActiveView} =
|
const {active, setActive, sendPosition, currentActiveView} =
|
||||||
|
@ -64,7 +63,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
{backgroundColor: t.palette.black},
|
{backgroundColor: 'black'},
|
||||||
a.relative,
|
a.relative,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
|
|
|
@ -29,9 +29,9 @@ export function TimeIndicator({time}: {time: number}) {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 5,
|
left: 6,
|
||||||
bottom: 5,
|
bottom: 6,
|
||||||
minHeight: 20,
|
minHeight: 21,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
|
|
|
@ -167,17 +167,20 @@ function VideoControls({
|
||||||
/>
|
/>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(300)}
|
entering={FadeInDown.duration(300)}
|
||||||
style={{
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
borderRadius: 6,
|
paddingHorizontal: 4,
|
||||||
paddingHorizontal: 6,
|
paddingVertical: 4,
|
||||||
paddingVertical: 3,
|
bottom: 6,
|
||||||
position: 'absolute',
|
right: 6,
|
||||||
bottom: 5,
|
minHeight: 21,
|
||||||
right: 5,
|
minWidth: 21,
|
||||||
minHeight: 20,
|
},
|
||||||
justifyContent: 'center',
|
]}>
|
||||||
}}>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={toggleMuted}
|
onPress={toggleMuted}
|
||||||
style={a.flex_1}
|
style={a.flex_1}
|
||||||
|
@ -186,9 +189,9 @@ function VideoControls({
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
hitSlop={HITSLOP_30}>
|
hitSlop={HITSLOP_30}>
|
||||||
{isMuted ? (
|
{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>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
|
@ -78,8 +78,8 @@ export function Controls({
|
||||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||||
const {
|
const {
|
||||||
state: hovered,
|
state: hovered,
|
||||||
onIn: onMouseEnter,
|
onIn: onHover,
|
||||||
onOut: onMouseLeave,
|
onOut: onEndHover,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
@ -220,6 +220,25 @@ export function Controls({
|
||||||
onSeek(clamp(currentTime + 5, 0, duration))
|
onSeek(clamp(currentTime + 5, 0, duration))
|
||||||
}, [onSeek, videoRef])
|
}, [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 =
|
const showControls =
|
||||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||||
|
|
||||||
|
@ -236,13 +255,17 @@ export function Controls({
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
setInteractingViaKeypress(false)
|
setInteractingViaKeypress(false)
|
||||||
}}
|
}}
|
||||||
onMouseEnter={onMouseEnter}
|
onPointerEnter={onHover}
|
||||||
onMouseLeave={onMouseLeave}
|
onPointerMove={onHover}
|
||||||
|
onPointerLeave={onEndHover}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}>
|
onKeyDown={onKeyDown}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
onPointerEnter={onPointerMoveEmptySpace}
|
||||||
|
onPointerMove={onPointerMoveEmptySpace}
|
||||||
|
onPointerLeave={onPointerLeaveEmptySpace}
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
!focused
|
!focused
|
||||||
? msg`Unmute video`
|
? msg`Unmute video`
|
||||||
|
@ -250,10 +273,10 @@ export function Controls({
|
||||||
? msg`Pause video`
|
? msg`Pause video`
|
||||||
: msg`Play video`,
|
: msg`Play video`,
|
||||||
)}
|
)}
|
||||||
style={a.flex_1}
|
style={[a.flex_1, web({cursor: showCursor ? 'pointer' : 'none'})]}
|
||||||
onPress={onPressEmptySpace}
|
onPress={onPressEmptySpace}
|
||||||
/>
|
/>
|
||||||
{active && !showControls && !focused && (
|
{!showControls && !focused && duration > 0 && (
|
||||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
|
@ -407,8 +430,8 @@ function Scrubber({
|
||||||
const [scrubberActive, setScrubberActive] = useState(false)
|
const [scrubberActive, setScrubberActive] = useState(false)
|
||||||
const {
|
const {
|
||||||
state: hovered,
|
state: hovered,
|
||||||
onIn: onMouseEnter,
|
onIn: onStartHover,
|
||||||
onOut: onMouseLeave,
|
onOut: onEndHover,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
const [seekPosition, setSeekPosition] = useState(0)
|
const [seekPosition, setSeekPosition] = useState(0)
|
||||||
|
@ -475,21 +498,8 @@ function Scrubber({
|
||||||
if (isFirefox && scrubberActive) {
|
if (isFirefox && scrubberActive) {
|
||||||
document.body.classList.add('force-no-clicks')
|
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 () => {
|
return () => {
|
||||||
document.body.classList.remove('force-no-clicks')
|
document.body.classList.remove('force-no-clicks')
|
||||||
abortController.abort()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scrubberActive, onSeekEnd])
|
}, [scrubberActive, onSeekEnd])
|
||||||
|
@ -534,9 +544,8 @@ function Scrubber({
|
||||||
<View
|
<View
|
||||||
testID="scrubber"
|
testID="scrubber"
|
||||||
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||||
// @ts-expect-error web only -sfn
|
onPointerEnter={onStartHover}
|
||||||
onMouseEnter={onMouseEnter}
|
onPointerLeave={onEndHover}>
|
||||||
onMouseLeave={onMouseLeave}>
|
|
||||||
<div
|
<div
|
||||||
ref={barRef}
|
ref={barRef}
|
||||||
style={{
|
style={{
|
||||||
|
@ -548,7 +557,8 @@ function Scrubber({
|
||||||
}}
|
}}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}>
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -9918,14 +9918,14 @@ caniuse-api@^3.0.0:
|
||||||
lodash.uniq "^4.5.0"
|
lodash.uniq "^4.5.0"
|
||||||
|
|
||||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
|
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
|
||||||
version "1.0.30001596"
|
version "1.0.30001655"
|
||||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
||||||
integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
|
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
||||||
|
|
||||||
caniuse-lite@^1.0.30001587:
|
caniuse-lite@^1.0.30001587:
|
||||||
version "1.0.30001620"
|
version "1.0.30001655"
|
||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
||||||
integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==
|
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
||||||
|
|
||||||
case-anything@^2.1.13:
|
case-anything@^2.1.13:
|
||||||
version "2.1.13"
|
version "2.1.13"
|
||||||
|
|
Loading…
Reference in New Issue