Compare commits

..

No commits in common. "7462f796a44a272567a4b733f118f6d4516a27e1" and "bf15fad240f601d3e0331c2012d2921441f51485" have entirely different histories.

34 changed files with 773 additions and 1233 deletions

View File

@ -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": "^5.5.4", "typescript": "^4.0.5",
"vite": "^5.2.8", "vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }

View File

@ -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, prettyNumber} from '../utils' import {getRkey, niceDate} 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">
{prettyNumber(post.likeCount)} {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">
{prettyNumber(post.repostCount)} {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 ${prettyNumber(post.replyCount)} ${ ? `Read ${post.replyCount} ${
post.replyCount > 1 ? 'replies' : 'reply' post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky` } on Bluesky`
: `View on Bluesky`} : `View on Bluesky`}

View File

@ -16,13 +16,3 @@ 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)
}

View File

@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment", "jsxFragmentFactory": "Fragment",
"downlevelIteration": true "downlevelIteration": true
}, },
"include": ["src", "vite.config.ts"] "include": ["src"]
} }

View File

@ -6,5 +6,5 @@
"strict": true, "strict": true,
"outDir": "dist" "outDir": "dist"
}, },
"include": ["snippet"] "include": ["snippet"],
} }

View File

@ -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@^5.5.4: typescript@^4.0.5:
version "5.5.4" version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
uint8arrays@3.0.0: uint8arrays@3.0.0:
version "3.0.0" version "3.0.0"

View File

@ -2,6 +2,7 @@ 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',
@ -9,18 +10,18 @@ const templateFile = path.join(
'scripts.html', 'scripts.html',
) )
const {entrypoints} = require(path.join( const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
projectRoot, jsFiles.sort((a, b) => {
'web-build/asset-manifest.json', // make sure main is written last
)) if (a.startsWith('main')) return 1
if (b.startsWith('main')) return -1
return a.localeCompare(b)
})
console.log(`Found ${entrypoints.length} entrypoints`) console.log(`Found ${jsFiles.length} js files in web-build`)
console.log(`Writing ${templateFile}`) console.log(`Writing ${templateFile}`)
const outputFile = entrypoints const outputFile = jsFiles
.map(name => { .map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
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)

View File

@ -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://zio.blue export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const BSKY_SERVICE = 'https://zio.blue' export const BSKY_SERVICE = 'https://bsky.social'
export const PUBLIC_BSKY_SERVICE = 'https://zio.blue' export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
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}`

View File

@ -1,177 +0,0 @@
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>
)
}

View File

@ -1,120 +0,0 @@
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>
)
}

View File

@ -1,135 +0,0 @@
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>
)
}

View File

@ -1,117 +0,0 @@
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>
)
}

View File

@ -1,21 +0,0 @@
// 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
}

View File

@ -29,6 +29,5 @@ 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}`}
} }

View File

@ -23,7 +23,6 @@ export async function compressVideo(
size: blob.size, size: blob.size,
uri, uri,
bytes: await blob.arrayBuffer(), bytes: await blob.arrayBuffer(),
mimeType,
} }
} }

View File

@ -4,10 +4,3 @@ export class VideoTooLargeError extends Error {
this.name = 'VideoTooLargeError' this.name = 'VideoTooLargeError'
} }
} }
export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}

View File

@ -1,6 +1,5 @@
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

View File

@ -24,16 +24,3 @@ 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}`)
}
}

View File

@ -1,14 +1,11 @@
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, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl} 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'
@ -25,14 +22,13 @@ 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)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.mp4`,
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -54,7 +50,7 @@ export const useUploadVideoMutation = ({
video.uri, video.uri,
{ {
headers: { headers: {
'content-type': video.mimeType, 'content-type': 'video/mp4',
Authorization: `Bearer ${serviceAuth.token}`, Authorization: `Bearer ${serviceAuth.token}`,
}, },
httpMethod: 'POST', httpMethod: 'POST',
@ -69,13 +65,6 @@ 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,

View File

@ -1,13 +1,10 @@
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, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl} 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'
@ -24,14 +21,13 @@ 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)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -67,24 +63,23 @@ export const useUploadVideoMutation = ({
xhr.responseText, xhr.responseText,
) as AppBskyVideoDefs.JobStatus ) as AppBskyVideoDefs.JobStatus
resolve(uploadRes) resolve(uploadRes)
onSuccess(uploadRes)
} else { } else {
reject(new ServerError(_(msg`Failed to upload video`))) reject()
onError(new Error('Failed to upload video'))
} }
} }
xhr.onerror = () => { xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`))) reject()
onError(new Error('Failed to upload video'))
} }
xhr.open('POST', uri) xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType) xhr.setRequestHeader('Content-Type', 'video/mp4')
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,

View File

@ -6,8 +6,7 @@ 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 {isWeb} from '#/platform/detection' import {VideoTooLargeError} from 'lib/media/video/errors'
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'
@ -59,12 +58,7 @@ function reducer(queryClient: QueryClient) {
abortController: new AbortController(), abortController: new AbortController(),
} }
} else if (action.type === 'SetAsset') { } else if (action.type === 'SetAsset') {
updatedState = { updatedState = {...state, asset: action.asset}
...state,
asset: action.asset,
status: 'compressing',
error: undefined,
}
} else if (action.type === 'SetDimensions') { } else if (action.type === 'SetDimensions') {
updatedState = { updatedState = {
...state, ...state,
@ -73,11 +67,11 @@ function reducer(queryClient: QueryClient) {
: undefined, : undefined,
} }
} else if (action.type === 'SetVideo') { } else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video, status: 'uploading'} updatedState = {...state, video: action.video}
} 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, status: 'done'} updatedState = {...state, blobRef: action.blobRef}
} }
return updatedState return updatedState
} }
@ -114,6 +108,10 @@ export function useUploadVideo({
type: 'SetBlobRef', type: 'SetBlobRef',
blobRef, blobRef,
}) })
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess() onSuccess()
}, },
}) })
@ -127,17 +125,10 @@ export function useUploadVideo({
setJobId(response.jobId) setJobId(response.jobId)
}, },
onError: e => { onError: e => {
if (e instanceof ServerError) { dispatch({
dispatch({ type: 'SetError',
type: 'SetError', error: _(msg`An error occurred while uploading the video.`),
error: e.message, })
})
} else {
dispatch({
type: 'SetError',
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 => {
@ -150,13 +141,6 @@ 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({
@ -166,28 +150,36 @@ export function useUploadVideo({
} else { } else {
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
error: _(msg`An error occurred while compressing the video.`), // @TODO better error message from server, left untranslated on purpose
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)) { dispatch({
case 'video/mp4': type: 'SetAsset',
case 'video/mpeg': asset,
case 'video/webm': })
dispatch({ dispatch({
type: 'SetAsset', type: 'SetStatus',
asset, status: 'compressing',
}) })
onSelectVideo(asset) onSelectVideo(asset)
break
default:
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
}
} }
const clearVideo = () => { const clearVideo = () => {
@ -249,21 +241,6 @@ 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
}

View File

@ -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`Zio`)}> <ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
<ToggleButton.ButtonText> <ToggleButton.ButtonText>
{_(msg`Zio`)} {_(msg`Bluesky`)}
</ToggleButton.ButtonText> </ToggleButton.ButtonText>
</ToggleButton.Button> </ToggleButton.Button>
<ToggleButton.Button <ToggleButton.Button
@ -152,24 +152,9 @@ export function ServerInputDialog({
a.flex_1, a.flex_1,
]}> ]}>
<Trans> <Trans>
A small Bluesky is an open network where you can choose your hosting
<InlineLinkText provider. Custom hosting is now available in beta for
label={_(msg`PDS instance`)} developers.
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>

View File

@ -181,7 +181,6 @@ export const ComposePost = observer(function ComposePost({
clearVideo, clearVideo,
state: videoUploadState, state: videoUploadState,
updateVideoDimensions, updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({ } = useUploadVideo({
setStatus: setProcessingState, setStatus: setProcessingState,
onSuccess: () => { onSuccess: () => {
@ -314,8 +313,8 @@ export const ComposePost = observer(function ComposePost({
if ( if (
!finishedUploading && !finishedUploading &&
videoUploadState.asset && videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' videoUploadState.asset
) { ) {
setPublishOnUpload(true) setPublishOnUpload(true)
return return
@ -608,7 +607,7 @@ export const ComposePost = observer(function ComposePost({
</Text> </Text>
</View> </View>
)} )}
{(error !== '' || videoUploadState.error) && ( {error !== '' && (
<View style={[a.px_lg, a.pb_sm]}> <View style={[a.px_lg, a.pb_sm]}>
<View <View
style={[ style={[
@ -624,7 +623,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 || videoUploadState.error} {error}
</NewText> </NewText>
<Button <Button
label={_(msg`Dismiss error`)} label={_(msg`Dismiss error`)}
@ -639,10 +638,7 @@ export const ComposePost = observer(function ComposePost({
right: a.px_md.paddingRight, right: a.px_md.paddingRight,
}, },
]} ]}
onPress={() => { onPress={() => setError('')}>
if (error) setError('')
else videoUploadDispatch({type: 'Reset'})
}}>
<ButtonIcon icon={X} /> <ButtonIcon icon={X} />
</Button> </Button>
</View> </View>
@ -759,8 +755,7 @@ 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]}>
@ -769,7 +764,6 @@ 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} />
@ -1038,33 +1032,15 @@ 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
: 100 : state.jobStatus?.progress ?? 100
return ( return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> <ToolbarWrapper
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle <ProgressCircle
size={30} size={30}
borderWidth={1} borderWidth={1}
@ -1072,7 +1048,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
color={t.palette.primary_500} color={t.palette.primary_500}
progress={progress} progress={progress}
/> />
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText> <Text>{state.status}</Text>
</ToolbarWrapper> </ToolbarWrapper>
) )
} }

View File

@ -19,10 +19,9 @@ 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, setError}: Props) { export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
@ -42,17 +41,9 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: 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, setError, _]) }, [onSelectVideo, requestVideoAccessIfNeeded])
return ( return (
<> <>

View File

@ -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: 'black'}, {backgroundColor: t.palette.black},
]}> ]}>
<VideoView <VideoView
player={player} player={player}

View File

@ -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} from '#/alf' import {atoms as a, useTheme} from '#/alf'
export function VideoPreview({ export function VideoPreview({
asset, asset,
@ -18,6 +18,7 @@ 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(() => {
@ -53,13 +54,13 @@ export function VideoPreview({
a.rounded_sm, a.rounded_sm,
{aspectRatio}, {aspectRatio},
a.overflow_hidden, a.overflow_hidden,
{backgroundColor: 'black'}, {backgroundColor: t.palette.black},
]}> ]}>
<ExternalEmbedRemoveBtn onRemove={clear} /> <ExternalEmbedRemoveBtn onRemove={clear} />
<video <video
ref={ref} ref={ref}
src={video.uri} src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}} style={a.flex_1}
autoPlay autoPlay
loop loop
muted muted

View File

@ -6,6 +6,14 @@ 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,
@ -23,6 +31,8 @@ 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 {
@ -35,13 +45,16 @@ 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'
@ -107,7 +120,17 @@ 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) {
@ -121,6 +144,19 @@ 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,
@ -131,6 +167,15 @@ 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) {
@ -140,6 +185,8 @@ let PostCtrls = ({
} }
}, [ }, [
_, _,
likeIconAnimValue,
likeTextAnimValue,
playHaptic, playHaptic,
post.uri, post.uri,
post.viewer?.like, post.viewer?.like,
@ -244,8 +291,8 @@ let PostCtrls = ({
a.gap_xs, a.gap_xs,
a.rounded_full, a.rounded_full,
a.flex_row, a.flex_row,
a.justify_center,
a.align_center, a.align_center,
a.justify_center,
{padding: 5}, {padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25, (pressed || hovered) && t.atoms.bg_contrast_25,
], ],
@ -317,11 +364,13 @@ let PostCtrls = ({
} }
accessibilityHint="" accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}> hitSlop={POST_CTRL_HITSLOP}>
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} /> <AnimatedLikeIcon
<CountWheel big={big ?? false}
likeCount={post.likeCount ?? 0} likeIconAnimValue={likeIconAnimValue}
big={big} likeTextAnimValue={likeTextAnimValue}
defaultCtrlColor={defaultCtrlColor}
isLiked={Boolean(post.viewer?.like)} isLiked={Boolean(post.viewer?.like)}
likeCount={post.likeCount ?? 0}
/> />
</Pressable> </Pressable>
</View> </View>
@ -401,3 +450,194 @@ 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>
</>
)
}

View File

@ -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: 'black'}, {backgroundColor: t.palette.black},
a.my_xs, a.my_xs,
]}> ]}>
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>

View File

@ -9,12 +9,13 @@ 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} from '#/alf' import {atoms as a, useTheme} 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} =
@ -63,7 +64,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
style={[ style={[
a.w_full, a.w_full,
{aspectRatio}, {aspectRatio},
{backgroundColor: 'black'}, {backgroundColor: t.palette.black},
a.relative, a.relative,
a.rounded_sm, a.rounded_sm,
a.my_xs, a.my_xs,

View File

@ -29,9 +29,9 @@ export function TimeIndicator({time}: {time: number}) {
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 6, left: 5,
bottom: 6, bottom: 5,
minHeight: 21, minHeight: 20,
justifyContent: 'center', justifyContent: 'center',
}, },
]}> ]}>

View File

@ -167,20 +167,17 @@ function VideoControls({
/> />
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
style={[ style={{
a.absolute, backgroundColor: 'rgba(0, 0, 0, 0.5)',
a.rounded_full, borderRadius: 6,
a.justify_center, paddingHorizontal: 6,
{ paddingVertical: 3,
backgroundColor: 'rgba(0, 0, 0, 0.5)', position: 'absolute',
paddingHorizontal: 4, bottom: 5,
paddingVertical: 4, right: 5,
bottom: 6, minHeight: 20,
right: 6, justifyContent: 'center',
minHeight: 21, }}>
minWidth: 21,
},
]}>
<Pressable <Pressable
onPress={toggleMuted} onPress={toggleMuted}
style={a.flex_1} style={a.flex_1}
@ -189,9 +186,9 @@ function VideoControls({
accessibilityRole="button" accessibilityRole="button"
hitSlop={HITSLOP_30}> hitSlop={HITSLOP_30}>
{isMuted ? ( {isMuted ? (
<MuteIcon width={13} fill={t.palette.white} /> <MuteIcon width={14} fill={t.palette.white} />
) : ( ) : (
<UnmuteIcon width={13} fill={t.palette.white} /> <UnmuteIcon width={14} fill={t.palette.white} />
)} )}
</Pressable> </Pressable>
</Animated.View> </Animated.View>

View File

@ -78,8 +78,8 @@ export function Controls({
const setSubtitlesEnabled = useSetSubtitlesEnabled() const setSubtitlesEnabled = useSetSubtitlesEnabled()
const { const {
state: hovered, state: hovered,
onIn: onHover, onIn: onMouseEnter,
onOut: onEndHover, onOut: onMouseLeave,
} = 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,25 +220,6 @@ 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)
@ -255,17 +236,13 @@ export function Controls({
evt.stopPropagation() evt.stopPropagation()
setInteractingViaKeypress(false) setInteractingViaKeypress(false)
}} }}
onPointerEnter={onHover} onMouseEnter={onMouseEnter}
onPointerMove={onHover} onMouseLeave={onMouseLeave}
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`
@ -273,10 +250,10 @@ export function Controls({
? msg`Pause video` ? msg`Pause video`
: msg`Play video`, : msg`Play video`,
)} )}
style={[a.flex_1, web({cursor: showCursor ? 'pointer' : 'none'})]} style={a.flex_1}
onPress={onPressEmptySpace} onPress={onPressEmptySpace}
/> />
{!showControls && !focused && duration > 0 && ( {active && !showControls && !focused && (
<TimeIndicator time={Math.floor(duration - currentTime)} /> <TimeIndicator time={Math.floor(duration - currentTime)} />
)} )}
<View <View
@ -430,8 +407,8 @@ function Scrubber({
const [scrubberActive, setScrubberActive] = useState(false) const [scrubberActive, setScrubberActive] = useState(false)
const { const {
state: hovered, state: hovered,
onIn: onStartHover, onIn: onMouseEnter,
onOut: onEndHover, onOut: onMouseLeave,
} = 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)
@ -498,8 +475,21 @@ 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])
@ -544,8 +534,9 @@ 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]}
onPointerEnter={onStartHover} // @ts-expect-error web only -sfn
onPointerLeave={onEndHover}> onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
<div <div
ref={barRef} ref={barRef}
style={{ style={{
@ -557,8 +548,7 @@ 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,

View File

@ -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.30001655" version "1.0.30001596"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg== integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
caniuse-lite@^1.0.30001587: caniuse-lite@^1.0.30001587:
version "1.0.30001655" version "1.0.30001620"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4"
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg== integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==
case-anything@^2.1.13: case-anything@^2.1.13:
version "2.1.13" version "2.1.13"