diff --git a/.prettierignore b/.prettierignore
index 8ccbae2148..e107e129a4 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -15,3 +15,6 @@ android
ios
src/locale/locales
lib/react-compiler-runtime
+bskyweb/static
+coverage
+web-build
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 0f49ce5c3a..54c5f075ac 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
+ e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 53e8274d54..323f668b79 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
+import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
-import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 0c8eb330d7..2625beda27 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -901,4 +901,32 @@ export const atoms = {
hidden: {
display: 'none',
},
+
+ /*
+ * Transition
+ */
+ transition_none: web({
+ transitionProperty: 'none',
+ }),
+ transition_all: web({
+ transitionProperty: 'all',
+ transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+ transitionDuration: '100ms',
+ }),
+ transition_color: web({
+ transitionProperty:
+ 'color, background-color, border-color, text-decoration-color, fill, stroke',
+ transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+ transitionDuration: '100ms',
+ }),
+ transition_opacity: web({
+ transitionProperty: 'opacity',
+ transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+ transitionDuration: '100ms',
+ }),
+ transition_transform: web({
+ transitionProperty: 'transform',
+ transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+ transitionDuration: '100ms',
+ }),
} as const
diff --git a/src/components/dms/MessagesNUX.tsx b/src/components/dms/MessagesNUX.tsx
deleted file mode 100644
index 5c21ee05be..0000000000
--- a/src/components/dms/MessagesNUX.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import React, {useCallback, useEffect} from 'react'
-import {View} from 'react-native'
-import {ChatBskyActorDeclaration} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useSession} from '#/state/session'
-import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme, web} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import * as Toggle from '#/components/forms/Toggle'
-import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
-import {Text} from '#/components/Typography'
-
-export function MessagesNUX() {
- const control = Dialog.useDialogControl()
-
- const {currentAccount} = useSession()
- const {data: profile} = useProfileQuery({
- did: currentAccount!.did,
- })
-
- useEffect(() => {
- if (profile && typeof profile.associated?.chat === 'undefined') {
- const timeout = setTimeout(() => {
- control.open()
- }, 1000)
-
- return () => {
- clearTimeout(timeout)
- }
- }
- }, [profile, control])
-
- if (!profile) return null
-
- return (
-
-
-
-
- )
-}
-
-function DialogInner({
- chatDeclation,
-}: {
- chatDeclation?: ChatBskyActorDeclaration.Record
-}) {
- const control = Dialog.useDialogContext()
- const {_} = useLingui()
- const t = useTheme()
-
- const [initialized, setInitialzed] = React.useState(false)
- const {mutate: updateDeclaration} = useUpdateActorDeclaration({
- onError: () => {
- Toast.show(_(msg`Failed to update settings`), 'xmark')
- },
- })
-
- const onSelectItem = useCallback(
- (keys: string[]) => {
- const key = keys[0]
- if (!key) return
- updateDeclaration(key as 'all' | 'none' | 'following')
- },
- [updateDeclaration],
- )
-
- useEffect(() => {
- if (!chatDeclation && !initialized) {
- updateDeclaration('following')
- setInitialzed(true)
- }
- }, [chatDeclation, updateDeclaration, initialized])
-
- return (
-
-
-
-
-
- Direct messages are here!
-
-
- Privately chat with other users.
-
-
-
-
-
- Who can message you?
-
-
- You can change this at any time.
-
-
-
-
-
-
-
- Everyone
-
-
-
-
-
- Users I follow
-
-
-
-
-
- No one
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx
index e8c63af82f..c78aabb6d4 100644
--- a/src/components/intents/VerifyEmailIntentDialog.tsx
+++ b/src/components/intents/VerifyEmailIntentDialog.tsx
@@ -3,11 +3,14 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useAgent, useSession} from 'state/session'
-import {atoms as a} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {isNative} from '#/platform/detection'
+import {useAgent, useSession} from '#/state/session'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -23,7 +26,9 @@ export function VerifyEmailIntentDialog() {
)
}
-function Inner({control}: {control: DialogControlProps}) {
+function Inner({}: {control: DialogControlProps}) {
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
@@ -58,43 +63,47 @@ function Inner({control}: {control: DialogControlProps}) {
}
return (
-
-
+
{status === 'loading' ? (
-
+
) : status === 'success' ? (
- <>
-
+
+
Email Verified
-
+
- Thanks, you have successfully verified your email address.
+ Thanks, you have successfully verified your email address. You
+ can close this dialog.
- >
+
) : status === 'failure' ? (
- <>
-
+
+
Invalid Verification Code
-
+
The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request
a new one.
- >
+
) : (
- <>
-
+
+
Email Resent
-
+
We have sent another verification email to{' '}
@@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) {
.
- >
+
)}
- {status !== 'loading' ? (
-
+
+ {status === 'failure' && (
+ <>
+
- {status === 'failure' ? (
-
- ) : null}
-
- ) : null}
+ >
+ )}
+
+
)
}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 51bf51ffff..8b79250042 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -24,7 +24,7 @@ import {
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} from '#/state/queries/threadgate'
-import {ComposerState} from '#/view/com/composer/state'
+import {ComposerState} from '#/view/com/composer/state/composer'
import {LinkMeta} from '../link-meta/link-meta'
import {uploadBlob} from './upload-blob'
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index dec9032a34..c2d1470c63 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
import {ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
-import {extToMime} from '#/state/queries/video/util'
import {CompressedVideo} from './types'
+import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts
new file mode 100644
index 0000000000..8c217eadcf
--- /dev/null
+++ b/src/lib/media/video/upload.shared.ts
@@ -0,0 +1,61 @@
+import {BskyAgent} from '@atproto/api'
+import {I18n} from '@lingui/core'
+import {msg} from '@lingui/macro'
+
+import {VIDEO_SERVICE_DID} from '#/lib/constants'
+import {UploadLimitError} from '#/lib/media/video/errors'
+import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
+import {createVideoAgent} from './util'
+
+export async function getServiceAuthToken({
+ agent,
+ aud,
+ lxm,
+ exp,
+}: {
+ agent: BskyAgent
+ aud?: string
+ lxm: string
+ exp?: number
+}) {
+ const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
+ if (!pdsAud) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+ const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
+ aud: aud ?? pdsAud,
+ lxm,
+ exp,
+ })
+ return serviceAuth.token
+}
+
+export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
+ const token = await getServiceAuthToken({
+ agent,
+ lxm: 'app.bsky.video.getUploadLimits',
+ aud: VIDEO_SERVICE_DID,
+ })
+ const videoAgent = createVideoAgent()
+ const {data: limits} = await videoAgent.app.bsky.video
+ .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
+ .catch(err => {
+ if (err instanceof Error) {
+ throw new UploadLimitError(err.message)
+ } else {
+ throw err
+ }
+ })
+
+ if (!limits.canUpload) {
+ if (limits.message) {
+ throw new UploadLimitError(limits.message)
+ } else {
+ throw new UploadLimitError(
+ _(
+ msg`You have temporarily reached the limit for video uploads. Please try again later.`,
+ ),
+ )
+ }
+ }
+}
diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts
new file mode 100644
index 0000000000..3330370b3e
--- /dev/null
+++ b/src/lib/media/video/upload.ts
@@ -0,0 +1,79 @@
+import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
+import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
+import {I18n} from '@lingui/core'
+import {msg} from '@lingui/macro'
+import {nanoid} from 'nanoid/non-secure'
+
+import {AbortError} from '#/lib/async/cancelable'
+import {ServerError} from '#/lib/media/video/errors'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {createVideoEndpointUrl, mimeToExt} from './util'
+import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
+
+export async function uploadVideo({
+ video,
+ agent,
+ did,
+ setProgress,
+ signal,
+ _,
+}: {
+ video: CompressedVideo
+ agent: BskyAgent
+ did: string
+ setProgress: (progress: number) => void
+ signal: AbortSignal
+ _: I18n['_']
+}) {
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ await getVideoUploadLimits(agent, _)
+
+ const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
+ did,
+ name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
+ })
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ const token = await getServiceAuthToken({
+ agent,
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+ const uploadTask = createUploadTask(
+ uri,
+ video.uri,
+ {
+ headers: {
+ 'content-type': video.mimeType,
+ Authorization: `Bearer ${token}`,
+ },
+ httpMethod: 'POST',
+ uploadType: FileSystemUploadType.BINARY_CONTENT,
+ },
+ p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
+ )
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ const res = await uploadTask.uploadAsync()
+
+ if (!res?.body) {
+ throw new Error('No response')
+ }
+
+ const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
+
+ if (!responseBody.jobId) {
+ throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
+ }
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ return responseBody
+}
diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts
new file mode 100644
index 0000000000..ec65f96c97
--- /dev/null
+++ b/src/lib/media/video/upload.web.ts
@@ -0,0 +1,95 @@
+import {AppBskyVideoDefs} from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
+import {I18n} from '@lingui/core'
+import {msg} from '@lingui/macro'
+import {nanoid} from 'nanoid/non-secure'
+
+import {AbortError} from '#/lib/async/cancelable'
+import {ServerError} from '#/lib/media/video/errors'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {createVideoEndpointUrl, mimeToExt} from './util'
+import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
+
+export async function uploadVideo({
+ video,
+ agent,
+ did,
+ setProgress,
+ signal,
+ _,
+}: {
+ video: CompressedVideo
+ agent: BskyAgent
+ did: string
+ setProgress: (progress: number) => void
+ signal: AbortSignal
+ _: I18n['_']
+}) {
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ await getVideoUploadLimits(agent, _)
+
+ const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
+ did,
+ name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
+ })
+
+ let bytes = video.bytes
+ if (!bytes) {
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ bytes = await fetch(video.uri).then(res => res.arrayBuffer())
+ }
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ const token = await getServiceAuthToken({
+ agent,
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ const xhr = new XMLHttpRequest()
+ const res = await new Promise(
+ (resolve, reject) => {
+ xhr.upload.addEventListener('progress', e => {
+ const progress = e.loaded / e.total
+ setProgress(progress)
+ })
+ xhr.onloadend = () => {
+ if (signal.aborted) {
+ reject(new AbortError())
+ } else if (xhr.readyState === 4) {
+ const uploadRes = JSON.parse(
+ xhr.responseText,
+ ) as AppBskyVideoDefs.JobStatus
+ resolve(uploadRes)
+ } else {
+ reject(new ServerError(_(msg`Failed to upload video`)))
+ }
+ }
+ xhr.onerror = () => {
+ reject(new ServerError(_(msg`Failed to upload video`)))
+ }
+ xhr.open('POST', uri)
+ xhr.setRequestHeader('Content-Type', video.mimeType)
+ xhr.setRequestHeader('Authorization', `Bearer ${token}`)
+ xhr.send(bytes)
+ },
+ )
+
+ if (!res.jobId) {
+ throw new ServerError(res.error || _(msg`Failed to upload video`))
+ }
+
+ if (signal.aborted) {
+ throw new AbortError()
+ }
+ return res
+}
diff --git a/src/state/queries/video/util.ts b/src/lib/media/video/util.ts
similarity index 86%
rename from src/state/queries/video/util.ts
rename to src/lib/media/video/util.ts
index 2c1298ab63..87b422c2c9 100644
--- a/src/state/queries/video/util.ts
+++ b/src/lib/media/video/util.ts
@@ -1,4 +1,3 @@
-import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
@@ -17,12 +16,10 @@ export const createVideoEndpointUrl = (
return url.href
}
-export function useVideoAgent() {
- return useMemo(() => {
- return new AtpAgent({
- service: VIDEO_SERVICE,
- })
- }, [])
+export function createVideoAgent() {
+ return new AtpAgent({
+ service: VIDEO_SERVICE,
+ })
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index c9bc8fefb2..9a306ee4f4 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -145,6 +145,8 @@ export type LogEvents = {
}
'post:mute': {}
'post:unmute': {}
+ 'post:pin': {}
+ 'post:unpin': {}
'profile:follow:sampled': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/ChatList.tsx
similarity index 98%
rename from src/screens/Messages/List/index.tsx
rename to src/screens/Messages/ChatList.tsx
index efd717f0b4..9912456e13 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -22,7 +22,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
-import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
@@ -33,7 +32,7 @@ import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
-import {ChatListItem} from './ChatListItem'
+import {ChatListItem} from './components/ChatListItem'
type Props = NativeStackScreenProps
@@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) {
if (conversations.length < 1) {
return (
-
-
{gtMobile ? (
-
{!gtMobile && (
(
return (
-
-
-
-
-
+
+
+
+
+
+
+
)
},
diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts
deleted file mode 100644
index cefbf94066..0000000000
--- a/src/state/queries/video/compress-video.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {ImagePickerAsset} from 'expo-image-picker'
-import {useMutation} from '@tanstack/react-query'
-
-import {cancelable} from '#/lib/async/cancelable'
-import {CompressedVideo} from '#/lib/media/video/types'
-import {compressVideo} from 'lib/media/video/compress'
-
-export function useCompressVideoMutation({
- onProgress,
- onSuccess,
- onError,
- signal,
-}: {
- onProgress: (progress: number) => void
- onError: (e: any) => void
- onSuccess: (video: CompressedVideo) => void
- signal: AbortSignal
-}) {
- return useMutation({
- mutationKey: ['video', 'compress'],
- mutationFn: cancelable(
- (asset: ImagePickerAsset) =>
- compressVideo(asset, {
- onProgress: num => onProgress(trunc2dp(num)),
- signal,
- }),
- signal,
- ),
- onError,
- onSuccess,
- onMutate: () => {
- onProgress(0)
- },
- })
-}
-
-function trunc2dp(num: number) {
- return Math.trunc(num * 100) / 100
-}
diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts
deleted file mode 100644
index 6b633bf213..0000000000
--- a/src/state/queries/video/video-upload.shared.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import {useCallback} from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {VIDEO_SERVICE_DID} from '#/lib/constants'
-import {UploadLimitError} from '#/lib/media/video/errors'
-import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
-import {useAgent} from '#/state/session'
-import {useVideoAgent} from './util'
-
-export function useServiceAuthToken({
- aud,
- lxm,
- exp,
-}: {
- aud?: string
- lxm: string
- exp?: number
-}) {
- const agent = useAgent()
-
- return useCallback(async () => {
- const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
-
- if (!pdsAud) {
- throw new Error('Agent does not have a PDS URL')
- }
-
- const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
- aud: aud ?? pdsAud,
- lxm,
- exp,
- })
-
- return serviceAuth.token
- }, [agent, aud, lxm, exp])
-}
-
-export function useVideoUploadLimits() {
- const agent = useVideoAgent()
- const getToken = useServiceAuthToken({
- lxm: 'app.bsky.video.getUploadLimits',
- aud: VIDEO_SERVICE_DID,
- })
- const {_} = useLingui()
-
- return useCallback(async () => {
- const {data: limits} = await agent.app.bsky.video
- .getUploadLimits(
- {},
- {headers: {Authorization: `Bearer ${await getToken()}`}},
- )
- .catch(err => {
- if (err instanceof Error) {
- throw new UploadLimitError(err.message)
- } else {
- throw err
- }
- })
-
- if (!limits.canUpload) {
- if (limits.message) {
- throw new UploadLimitError(limits.message)
- } else {
- throw new UploadLimitError(
- _(
- msg`You have temporarily reached the limit for video uploads. Please try again later.`,
- ),
- )
- }
- }
- }, [agent, _, getToken])
-}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
deleted file mode 100644
index 170b538901..0000000000
--- a/src/state/queries/video/video-upload.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
-import {AppBskyVideoDefs} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useMutation} from '@tanstack/react-query'
-import {nanoid} from 'nanoid/non-secure'
-
-import {cancelable} from '#/lib/async/cancelable'
-import {ServerError} from '#/lib/media/video/errors'
-import {CompressedVideo} from '#/lib/media/video/types'
-import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
-import {useSession} from '#/state/session'
-import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
-
-export const useUploadVideoMutation = ({
- onSuccess,
- onError,
- setProgress,
- signal,
-}: {
- onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
- onError: (e: any) => void
- setProgress: (progress: number) => void
- signal: AbortSignal
-}) => {
- const {currentAccount} = useSession()
- const getToken = useServiceAuthToken({
- lxm: 'com.atproto.repo.uploadBlob',
- exp: Date.now() / 1000 + 60 * 30, // 30 minutes
- })
- const checkLimits = useVideoUploadLimits()
- const {_} = useLingui()
-
- return useMutation({
- mutationKey: ['video', 'upload'],
- mutationFn: cancelable(async (video: CompressedVideo) => {
- await checkLimits()
-
- const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
- did: currentAccount!.did,
- name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
- })
-
- const uploadTask = createUploadTask(
- uri,
- video.uri,
- {
- headers: {
- 'content-type': video.mimeType,
- Authorization: `Bearer ${await getToken()}`,
- },
- httpMethod: 'POST',
- uploadType: FileSystemUploadType.BINARY_CONTENT,
- },
- p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
- )
- const res = await uploadTask.uploadAsync()
-
- if (!res?.body) {
- throw new Error('No response')
- }
-
- const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
-
- if (!responseBody.jobId) {
- throw new ServerError(
- responseBody.error || _(msg`Failed to upload video`),
- )
- }
-
- return responseBody
- }, signal),
- onError,
- onSuccess,
- })
-}
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
deleted file mode 100644
index c93e206030..0000000000
--- a/src/state/queries/video/video-upload.web.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import {AppBskyVideoDefs} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useMutation} from '@tanstack/react-query'
-import {nanoid} from 'nanoid/non-secure'
-
-import {cancelable} from '#/lib/async/cancelable'
-import {ServerError} from '#/lib/media/video/errors'
-import {CompressedVideo} from '#/lib/media/video/types'
-import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
-import {useSession} from '#/state/session'
-import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
-
-export const useUploadVideoMutation = ({
- onSuccess,
- onError,
- setProgress,
- signal,
-}: {
- onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
- onError: (e: any) => void
- setProgress: (progress: number) => void
- signal: AbortSignal
-}) => {
- const {currentAccount} = useSession()
- const getToken = useServiceAuthToken({
- lxm: 'com.atproto.repo.uploadBlob',
- exp: Date.now() / 1000 + 60 * 30, // 30 minutes
- })
- const checkLimits = useVideoUploadLimits()
- const {_} = useLingui()
-
- return useMutation({
- mutationKey: ['video', 'upload'],
- mutationFn: cancelable(async (video: CompressedVideo) => {
- await checkLimits()
-
- const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
- did: currentAccount!.did,
- name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
- })
-
- let bytes = video.bytes
- if (!bytes) {
- bytes = await fetch(video.uri).then(res => res.arrayBuffer())
- }
-
- const token = await getToken()
-
- const xhr = new XMLHttpRequest()
- const res = await new Promise(
- (resolve, reject) => {
- xhr.upload.addEventListener('progress', e => {
- const progress = e.loaded / e.total
- setProgress(progress)
- })
- xhr.onloadend = () => {
- if (xhr.readyState === 4) {
- const uploadRes = JSON.parse(
- xhr.responseText,
- ) as AppBskyVideoDefs.JobStatus
- resolve(uploadRes)
- } else {
- reject(new ServerError(_(msg`Failed to upload video`)))
- }
- }
- xhr.onerror = () => {
- reject(new ServerError(_(msg`Failed to upload video`)))
- }
- xhr.open('POST', uri)
- xhr.setRequestHeader('Content-Type', video.mimeType)
- xhr.setRequestHeader('Authorization', `Bearer ${token}`)
- xhr.send(bytes)
- },
- )
-
- if (!res.jobId) {
- throw new ServerError(res.error || _(msg`Failed to upload video`))
- }
-
- return res
- }, signal),
- onError,
- onSuccess,
- })
-}
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
deleted file mode 100644
index 0d77935da9..0000000000
--- a/src/state/queries/video/video.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-import React, {useCallback, useEffect} from 'react'
-import {ImagePickerAsset} from 'expo-image-picker'
-import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
-
-import {AbortError} from '#/lib/async/cancelable'
-import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
-import {
- ServerError,
- UploadLimitError,
- VideoTooLargeError,
-} from '#/lib/media/video/errors'
-import {CompressedVideo} from '#/lib/media/video/types'
-import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
-import {useCompressVideoMutation} from '#/state/queries/video/compress-video'
-import {useVideoAgent} from '#/state/queries/video/util'
-import {useUploadVideoMutation} from '#/state/queries/video/video-upload'
-
-type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
-
-type Action =
- | {type: 'SetStatus'; status: Status}
- | {type: 'SetProgress'; progress: number}
- | {type: 'SetError'; error: string | undefined}
- | {type: 'Reset'}
- | {type: 'SetAsset'; asset: ImagePickerAsset}
- | {type: 'SetDimensions'; width: number; height: number}
- | {type: 'SetVideo'; video: CompressedVideo}
- | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
- | {type: 'SetComplete'; blobRef: BlobRef}
-
-export interface State {
- status: Status
- progress: number
- asset?: ImagePickerAsset
- video: CompressedVideo | null
- jobStatus?: AppBskyVideoDefs.JobStatus
- blobRef?: BlobRef
- error?: string
- abortController: AbortController
- pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
-}
-
-export type VideoUploadDispatch = (action: Action) => void
-
-function reducer(queryClient: QueryClient) {
- return (state: State, action: Action): State => {
- let updatedState = state
- if (action.type === 'SetStatus') {
- updatedState = {...state, status: action.status}
- } else if (action.type === 'SetProgress') {
- updatedState = {...state, progress: action.progress}
- } else if (action.type === 'SetError') {
- updatedState = {...state, error: action.error}
- } else if (action.type === 'Reset') {
- state.abortController.abort()
- queryClient.cancelQueries({
- queryKey: ['video'],
- })
- updatedState = {
- status: 'idle',
- progress: 0,
- video: null,
- blobRef: undefined,
- abortController: new AbortController(),
- }
- } else if (action.type === 'SetAsset') {
- updatedState = {
- ...state,
- asset: action.asset,
- status: 'compressing',
- error: undefined,
- }
- } else if (action.type === 'SetDimensions') {
- updatedState = {
- ...state,
- asset: state.asset
- ? {...state.asset, width: action.width, height: action.height}
- : undefined,
- }
- } else if (action.type === 'SetVideo') {
- updatedState = {...state, video: action.video, status: 'uploading'}
- } else if (action.type === 'SetJobStatus') {
- updatedState = {...state, jobStatus: action.jobStatus}
- } else if (action.type === 'SetComplete') {
- updatedState = {
- ...state,
- pendingPublish: {
- blobRef: action.blobRef,
- mutableProcessed: false,
- },
- status: 'done',
- }
- }
- return updatedState
- }
-}
-
-export function useUploadVideo({
- setStatus,
- initialVideoUri,
-}: {
- setStatus: (status: string) => void
- onSuccess: () => void
- initialVideoUri?: string
-}) {
- const {_} = useLingui()
- const queryClient = useQueryClient()
- const [state, dispatch] = React.useReducer(reducer(queryClient), {
- status: 'idle',
- progress: 0,
- video: null,
- abortController: new AbortController(),
- })
-
- const {setJobId} = useUploadStatusQuery({
- onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
- // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
- // Leaving it for now though
- dispatch({
- type: 'SetJobStatus',
- jobStatus: status,
- })
- setStatus(status.state.toString())
- },
- onSuccess: blobRef => {
- dispatch({
- type: 'SetComplete',
- blobRef,
- })
- },
- onError: useCallback(
- error => {
- logger.error('Error processing video', {safeMessage: error})
- dispatch({
- type: 'SetError',
- error: _(msg`Video failed to process`),
- })
- },
- [_],
- ),
- })
-
- const {mutate: onVideoCompressed} = useUploadVideoMutation({
- onSuccess: response => {
- dispatch({
- type: 'SetStatus',
- status: 'processing',
- })
- setJobId(response.jobId)
- },
- onError: e => {
- if (e instanceof AbortError) {
- return
- } else if (e instanceof ServerError || e instanceof UploadLimitError) {
- let message
- // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
- switch (e.message) {
- case 'User is not allowed to upload videos':
- message = _(msg`You are not allowed to upload videos.`)
- break
- case 'Uploading is disabled at the moment':
- message = _(
- msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
- )
- break
- case "Failed to get user's upload stats":
- message = _(
- msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
- )
- break
- case 'User has exceeded daily upload bytes limit':
- message = _(
- msg`You've reached your daily limit for video uploads (too many bytes)`,
- )
- break
- case 'User has exceeded daily upload videos limit':
- message = _(
- msg`You've reached your daily limit for video uploads (too many videos)`,
- )
- break
- case 'Account is not old enough to upload videos':
- message = _(
- msg`Your account is not yet old enough to upload videos. Please try again later.`,
- )
- break
- default:
- message = e.message
- break
- }
- dispatch({
- type: 'SetError',
- error: message,
- })
- } else {
- dispatch({
- type: 'SetError',
- error: _(msg`An error occurred while uploading the video.`),
- })
- }
- logger.error('Error uploading video', {safeMessage: e})
- },
- setProgress: p => {
- dispatch({type: 'SetProgress', progress: p})
- },
- signal: state.abortController.signal,
- })
-
- const {mutate: onSelectVideo} = useCompressVideoMutation({
- onProgress: p => {
- dispatch({type: 'SetProgress', progress: p})
- },
- onSuccess: (video: CompressedVideo) => {
- dispatch({
- type: 'SetVideo',
- video,
- })
- onVideoCompressed(video)
- },
- onError: e => {
- if (e instanceof AbortError) {
- return
- } else if (e instanceof VideoTooLargeError) {
- dispatch({
- type: 'SetError',
- error: _(msg`The selected video is larger than 50MB.`),
- })
- } else {
- dispatch({
- type: 'SetError',
- error: _(msg`An error occurred while compressing the video.`),
- })
- logger.error('Error compressing video', {safeMessage: e})
- }
- },
- signal: state.abortController.signal,
- })
-
- const selectVideo = React.useCallback(
- (asset: ImagePickerAsset) => {
- // compression step on native converts to mp4, so no need to check there
- if (isWeb) {
- const mimeType = getMimeType(asset)
- if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
- throw new Error(_(msg`Unsupported video type: ${mimeType}`))
- }
- }
-
- dispatch({
- type: 'SetAsset',
- asset,
- })
- onSelectVideo(asset)
- },
- [_, onSelectVideo],
- )
-
- const clearVideo = () => {
- dispatch({type: 'Reset'})
- }
-
- const updateVideoDimensions = useCallback((width: number, height: number) => {
- dispatch({
- type: 'SetDimensions',
- width,
- height,
- })
- }, [])
-
- // Whenever we receive an initial video uri, we should immediately run compression if necessary
- useEffect(() => {
- if (initialVideoUri) {
- selectVideo({uri: initialVideoUri} as ImagePickerAsset)
- }
- }, [initialVideoUri, selectVideo])
-
- return {
- state,
- dispatch,
- selectVideo,
- clearVideo,
- updateVideoDimensions,
- }
-}
-
-const useUploadStatusQuery = ({
- onStatusChange,
- onSuccess,
- onError,
-}: {
- onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
- onSuccess: (blobRef: BlobRef) => void
- onError: (error: Error) => void
-}) => {
- const videoAgent = useVideoAgent()
- const [enabled, setEnabled] = React.useState(true)
- const [jobId, setJobId] = React.useState()
-
- const {error} = useQuery({
- queryKey: ['video', 'upload status', jobId],
- queryFn: async () => {
- if (!jobId) return // this won't happen, can ignore
-
- const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
- const status = data.jobStatus
- if (status.state === 'JOB_STATE_COMPLETED') {
- setEnabled(false)
- if (!status.blob)
- throw new Error('Job completed, but did not return a blob')
- onSuccess(status.blob)
- } else if (status.state === 'JOB_STATE_FAILED') {
- throw new Error(status.error ?? 'Job failed to process')
- }
- onStatusChange(status)
- return status
- },
- enabled: Boolean(jobId && enabled),
- refetchInterval: 1500,
- })
-
- useEffect(() => {
- if (error) {
- onError(error)
- setEnabled(false)
- }
- }, [error, onError])
-
- return {
- setJobId: (_jobId: string) => {
- setJobId(_jobId)
- setEnabled(true)
- },
- }
-}
-
-function getMimeType(asset: ImagePickerAsset) {
- if (isWeb) {
- const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
- if (!mimeType) {
- throw new Error('Could not determine mime type')
- }
- return mimeType
- }
- if (!asset.mimeType) {
- throw new Error('Could not determine mime type')
- }
- return asset.mimeType
-}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f354f0f0dc..f4e290ca8d 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -36,6 +36,7 @@ import Animated, {
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
@@ -81,11 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
-import {
- State as VideoUploadState,
- useUploadVideo,
- VideoUploadDispatch,
-} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {ComposerOpts} from '#/state/shell/composer'
@@ -120,7 +116,8 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
-import {composerReducer, createComposerState} from './state'
+import {composerReducer, createComposerState} from './state/composer'
+import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const MAX_IMAGES = 4
@@ -147,7 +144,8 @@ export const ComposePost = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
- const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
+ const currentDid = currentAccount!.did
+ const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
const pal = usePalette('default')
@@ -189,22 +187,62 @@ export const ComposePost = ({
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
- const {
- selectVideo,
- clearVideo,
- state: videoUploadState,
- updateVideoDimensions,
- dispatch: videoUploadDispatch,
- } = useUploadVideo({
- setStatus: setProcessingState,
- onSuccess: () => {
- if (publishOnUpload) {
- onPressPublish(true)
- }
+ // TODO: Move more state here.
+ const [composerState, dispatch] = useReducer(
+ composerReducer,
+ {initImageUris},
+ createComposerState,
+ )
+
+ let videoState: VideoState | NoVideoState = NO_VIDEO
+ if (composerState.embed.media?.type === 'video') {
+ videoState = composerState.embed.media.video
+ }
+
+ const selectVideo = React.useCallback(
+ (asset: ImagePickerAsset) => {
+ const abortController = new AbortController()
+ dispatch({type: 'embed_add_video', asset, abortController})
+ processVideo(
+ asset,
+ videoAction => dispatch({type: 'embed_update_video', videoAction}),
+ agent,
+ currentDid,
+ abortController.signal,
+ _,
+ )
},
- initialVideoUri: initVideoUri,
- })
- const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
+ [_, agent, currentDid],
+ )
+
+ // Whenever we receive an initial video uri, we should immediately run compression if necessary
+ useEffect(() => {
+ if (initVideoUri) {
+ selectVideo({uri: initVideoUri} as ImagePickerAsset)
+ }
+ }, [initVideoUri, selectVideo])
+
+ const clearVideo = React.useCallback(() => {
+ videoState.abortController.abort()
+ dispatch({type: 'embed_remove_video'})
+ }, [videoState.abortController, dispatch])
+
+ const updateVideoDimensions = useCallback(
+ (width: number, height: number) => {
+ dispatch({
+ type: 'embed_update_video',
+ videoAction: {
+ type: 'update_dimensions',
+ width,
+ height,
+ signal: videoState.abortController.signal,
+ },
+ })
+ },
+ [videoState.abortController],
+ )
+
+ const hasVideo = Boolean(videoState.asset || videoState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -217,12 +255,6 @@ export const ComposePost = ({
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
- // TODO: Move more state here.
- const [composerState, dispatch] = useReducer(
- composerReducer,
- {initImageUris},
- createComposerState,
- )
let images = NO_IMAGES
if (composerState.embed.media?.type === 'images') {
images = composerState.embed.media.images
@@ -247,7 +279,7 @@ export const ComposePost = ({
graphemeLength > 0 ||
images.length !== 0 ||
extGif ||
- videoUploadState.status !== 'idle'
+ videoState.status !== 'idle'
) {
closeAllDialogs()
Keyboard.dismiss()
@@ -262,7 +294,7 @@ export const ComposePost = ({
closeAllDialogs,
discardPromptControl,
onClose,
- videoUploadState.status,
+ videoState.status,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -359,8 +391,8 @@ export const ComposePost = ({
if (
!finishedUploading &&
- videoUploadState.asset &&
- videoUploadState.status !== 'done'
+ videoState.asset &&
+ videoState.status !== 'done'
) {
setPublishOnUpload(true)
return
@@ -373,7 +405,7 @@ export const ComposePost = ({
images.length === 0 &&
!extLink &&
!quote &&
- videoUploadState.status === 'idle'
+ videoState.status === 'idle'
) {
setError(_(msg`Did you want to say anything?`))
return
@@ -400,19 +432,18 @@ export const ComposePost = ({
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
- video: videoUploadState.pendingPublish?.blobRef
- ? {
- blobRef: videoUploadState.pendingPublish.blobRef,
- altText: videoAltText,
- captions: captions,
- aspectRatio: videoUploadState.asset
- ? {
- width: videoUploadState.asset?.width,
- height: videoUploadState.asset?.height,
- }
- : undefined,
- }
- : undefined,
+ video:
+ videoState.status === 'done'
+ ? {
+ blobRef: videoState.pendingPublish.blobRef,
+ altText: videoAltText,
+ captions: captions,
+ aspectRatio: {
+ width: videoState.asset.width,
+ height: videoState.asset.height,
+ },
+ }
+ : undefined,
})
).uri
try {
@@ -510,20 +541,20 @@ export const ComposePost = ({
setLangPrefs,
threadgateAllowUISettings,
videoAltText,
- videoUploadState.asset,
- videoUploadState.pendingPublish,
- videoUploadState.status,
+ videoState.asset,
+ videoState.pendingPublish,
+ videoState.status,
],
)
React.useEffect(() => {
- if (videoUploadState.pendingPublish && publishOnUpload) {
- if (!videoUploadState.pendingPublish.mutableProcessed) {
- videoUploadState.pendingPublish.mutableProcessed = true
+ if (videoState.pendingPublish && publishOnUpload) {
+ if (!videoState.pendingPublish.mutableProcessed) {
+ videoState.pendingPublish.mutableProcessed = true
onPressPublish(true)
}
}
- }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
+ }, [onPressPublish, publishOnUpload, videoState.pendingPublish])
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
@@ -536,10 +567,10 @@ export const ComposePost = ({
const canSelectImages =
images.length < MAX_IMAGES &&
!extLink &&
- videoUploadState.status === 'idle' &&
- !videoUploadState.video
+ videoState.status === 'idle' &&
+ !videoState.video
const hasMedia =
- images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
+ images.length > 0 || Boolean(extLink) || Boolean(videoState.video)
const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -654,9 +685,7 @@ export const ComposePost = ({
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
- disabled={
- videoUploadState.status !== 'idle' && publishOnUpload
- }>
+ disabled={videoState.status !== 'idle' && publishOnUpload}>
{replyTo ? (
Reply
@@ -692,9 +721,9 @@ export const ComposePost = ({
)}
setError('')}
- videoUploadDispatch={videoUploadDispatch}
+ clearVideo={clearVideo}
/>
- {videoUploadState.asset &&
- (videoUploadState.status === 'compressing' ? (
+ {videoState.asset &&
+ (videoState.status === 'compressing' ? (
- ) : videoUploadState.video ? (
+ ) : videoState.video ? (
@@ -814,9 +843,8 @@ export const ComposePost = ({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
- {videoUploadState.status !== 'idle' &&
- videoUploadState.status !== 'done' ? (
-
+ {videoState.status !== 'idle' && videoState.status !== 'done' ? (
+
) : (
0}
setError={setError}
/>
@@ -1081,27 +1109,27 @@ const styles = StyleSheet.create({
function ErrorBanner({
error: standardError,
- videoUploadState,
+ videoState,
clearError,
- videoUploadDispatch,
+ clearVideo,
}: {
error: string
- videoUploadState: VideoUploadState
+ videoState: VideoState | NoVideoState
clearError: () => void
- videoUploadDispatch: VideoUploadDispatch
+ clearVideo: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const videoError =
- videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
+ videoState.status === 'error' ? videoState.error : undefined
const error = standardError || videoError
const onClearError = () => {
if (standardError) {
clearError()
} else {
- videoUploadDispatch({type: 'Reset'})
+ clearVideo()
}
}
@@ -1136,7 +1164,7 @@ function ErrorBanner({
- {videoError && videoUploadState.jobStatus?.jobId && (
+ {videoError && videoState.jobId && (
- Job ID: {videoUploadState.jobStatus.jobId}
+ Job ID: {videoState.jobId}
)}
@@ -1171,12 +1199,10 @@ function ToolbarWrapper({
)
}
-function VideoUploadToolbar({state}: {state: VideoUploadState}) {
+function VideoUploadToolbar({state}: {state: VideoState}) {
const t = useTheme()
const {_} = useLingui()
- const progress = state.jobStatus?.progress
- ? state.jobStatus.progress / 100
- : state.progress
+ const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
@@ -1212,16 +1238,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
case 'processing':
text = _('Processing video...')
break
+ case 'error':
+ text = _('Error')
+ wheelProgress = 100
+ break
case 'done':
text = _('Video uploaded')
break
}
- if (state.error) {
- text = _('Error')
- wheelProgress = 100
- }
-
return (
@@ -1229,7 +1254,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
- color={state.error ? t.palette.negative_500 : t.palette.primary_500}
+ color={
+ state.status === 'error'
+ ? t.palette.negative_500
+ : t.palette.primary_500
+ }
progress={wheelProgress}
/>
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 5692f3d2c9..5ff7042bc1 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -21,7 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
-import {ComposerAction} from '../state'
+import {ComposerAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state/composer.ts
similarity index 63%
rename from src/view/com/composer/state.ts
rename to src/view/com/composer/state/composer.ts
index 5588de1aa3..a23a5d8c86 100644
--- a/src/view/com/composer/state.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,5 +1,8 @@
+import {ImagePickerAsset} from 'expo-image-picker'
+
import {ComposerImage, createInitialImages} from '#/state/gallery'
import {ComposerOpts} from '#/state/shell/composer'
+import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
type PostRecord = {
uri: string
@@ -11,11 +14,16 @@ type ImagesMedia = {
labels: string[]
}
+type VideoMedia = {
+ type: 'video'
+ video: VideoState
+}
+
type ComposerEmbed = {
// TODO: Other record types.
record: PostRecord | undefined
// TODO: Other media types.
- media: ImagesMedia | undefined
+ media: ImagesMedia | VideoMedia | undefined
}
export type ComposerState = {
@@ -27,6 +35,13 @@ export type ComposerAction =
| {type: 'embed_add_images'; images: ComposerImage[]}
| {type: 'embed_update_image'; image: ComposerImage}
| {type: 'embed_remove_image'; image: ComposerImage}
+ | {
+ type: 'embed_add_video'
+ asset: ImagePickerAsset
+ abortController: AbortController
+ }
+ | {type: 'embed_remove_video'}
+ | {type: 'embed_update_video'; videoAction: VideoAction}
const MAX_IMAGES = 4
@@ -36,6 +51,9 @@ export function composerReducer(
): ComposerState {
switch (action.type) {
case 'embed_add_images': {
+ if (action.images.length === 0) {
+ return state
+ }
const prevMedia = state.embed.media
let nextMedia = prevMedia
if (!prevMedia) {
@@ -104,6 +122,55 @@ export function composerReducer(
}
return state
}
+ case 'embed_add_video': {
+ const prevMedia = state.embed.media
+ let nextMedia = prevMedia
+ if (!prevMedia) {
+ nextMedia = {
+ type: 'video',
+ video: createVideoState(action.asset, action.abortController),
+ }
+ }
+ return {
+ ...state,
+ embed: {
+ ...state.embed,
+ media: nextMedia,
+ },
+ }
+ }
+ case 'embed_update_video': {
+ const videoAction = action.videoAction
+ const prevMedia = state.embed.media
+ let nextMedia = prevMedia
+ if (prevMedia?.type === 'video') {
+ nextMedia = {
+ ...prevMedia,
+ video: videoReducer(prevMedia.video, videoAction),
+ }
+ }
+ return {
+ ...state,
+ embed: {
+ ...state.embed,
+ media: nextMedia,
+ },
+ }
+ }
+ case 'embed_remove_video': {
+ const prevMedia = state.embed.media
+ let nextMedia = prevMedia
+ if (prevMedia?.type === 'video') {
+ nextMedia = undefined
+ }
+ return {
+ ...state,
+ embed: {
+ ...state.embed,
+ media: nextMedia,
+ },
+ }
+ }
default:
return state
}
@@ -122,6 +189,7 @@ export function createComposerState({
labels: [],
}
}
+ // TODO: initial video.
return {
embed: {
record: undefined,
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
new file mode 100644
index 0000000000..2695056579
--- /dev/null
+++ b/src/view/com/composer/state/video.ts
@@ -0,0 +1,406 @@
+import {ImagePickerAsset} from 'expo-image-picker'
+import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api'
+import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
+import {I18n} from '@lingui/core'
+import {msg} from '@lingui/macro'
+
+import {createVideoAgent} from '#/lib/media/video/util'
+import {uploadVideo} from '#/lib/media/video/upload'
+import {AbortError} from '#/lib/async/cancelable'
+import {compressVideo} from '#/lib/media/video/compress'
+import {
+ ServerError,
+ UploadLimitError,
+ VideoTooLargeError,
+} from '#/lib/media/video/errors'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {logger} from '#/logger'
+
+export type VideoAction =
+ | {
+ type: 'compressing_to_uploading'
+ video: CompressedVideo
+ signal: AbortSignal
+ }
+ | {
+ type: 'uploading_to_processing'
+ jobId: string
+ signal: AbortSignal
+ }
+ | {type: 'to_error'; error: string; signal: AbortSignal}
+ | {
+ type: 'to_done'
+ blobRef: BlobRef
+ signal: AbortSignal
+ }
+ | {type: 'update_progress'; progress: number; signal: AbortSignal}
+ | {
+ type: 'update_dimensions'
+ width: number
+ height: number
+ signal: AbortSignal
+ }
+ | {
+ type: 'update_job_status'
+ jobStatus: AppBskyVideoDefs.JobStatus
+ signal: AbortSignal
+ }
+
+const noopController = new AbortController()
+noopController.abort()
+
+export const NO_VIDEO = Object.freeze({
+ status: 'idle',
+ progress: 0,
+ abortController: noopController,
+ asset: undefined,
+ video: undefined,
+ jobId: undefined,
+ pendingPublish: undefined,
+})
+
+export type NoVideoState = typeof NO_VIDEO
+
+type ErrorState = {
+ status: 'error'
+ progress: 100
+ abortController: AbortController
+ asset: ImagePickerAsset | null
+ video: CompressedVideo | null
+ jobId: string | null
+ error: string
+ pendingPublish?: undefined
+}
+
+type CompressingState = {
+ status: 'compressing'
+ progress: number
+ abortController: AbortController
+ asset: ImagePickerAsset
+ video?: undefined
+ jobId?: undefined
+ pendingPublish?: undefined
+}
+
+type UploadingState = {
+ status: 'uploading'
+ progress: number
+ abortController: AbortController
+ asset: ImagePickerAsset
+ video: CompressedVideo
+ jobId?: undefined
+ pendingPublish?: undefined
+}
+
+type ProcessingState = {
+ status: 'processing'
+ progress: number
+ abortController: AbortController
+ asset: ImagePickerAsset
+ video: CompressedVideo
+ jobId: string
+ jobStatus: AppBskyVideoDefs.JobStatus | null
+ pendingPublish?: undefined
+}
+
+type DoneState = {
+ status: 'done'
+ progress: 100
+ abortController: AbortController
+ asset: ImagePickerAsset
+ video: CompressedVideo
+ jobId?: undefined
+ pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
+}
+
+export type VideoState =
+ | ErrorState
+ | CompressingState
+ | UploadingState
+ | ProcessingState
+ | DoneState
+
+export function createVideoState(
+ asset: ImagePickerAsset,
+ abortController: AbortController,
+): CompressingState {
+ return {
+ status: 'compressing',
+ progress: 0,
+ abortController,
+ asset,
+ }
+}
+
+export function videoReducer(
+ state: VideoState,
+ action: VideoAction,
+): VideoState {
+ if (action.signal.aborted || action.signal !== state.abortController.signal) {
+ // This action is stale and the process that spawned it is no longer relevant.
+ return state
+ }
+ if (action.type === 'to_error') {
+ return {
+ status: 'error',
+ progress: 100,
+ abortController: state.abortController,
+ error: action.error,
+ asset: state.asset ?? null,
+ video: state.video ?? null,
+ jobId: state.jobId ?? null,
+ }
+ } else if (action.type === 'update_progress') {
+ if (state.status === 'compressing' || state.status === 'uploading') {
+ return {
+ ...state,
+ progress: action.progress,
+ }
+ }
+ } else if (action.type === 'update_dimensions') {
+ if (state.asset) {
+ return {
+ ...state,
+ asset: {...state.asset, width: action.width, height: action.height},
+ }
+ }
+ } else if (action.type === 'compressing_to_uploading') {
+ if (state.status === 'compressing') {
+ return {
+ status: 'uploading',
+ progress: 0,
+ abortController: state.abortController,
+ asset: state.asset,
+ video: action.video,
+ }
+ }
+ return state
+ } else if (action.type === 'uploading_to_processing') {
+ if (state.status === 'uploading') {
+ return {
+ status: 'processing',
+ progress: 0,
+ abortController: state.abortController,
+ asset: state.asset,
+ video: state.video,
+ jobId: action.jobId,
+ jobStatus: null,
+ }
+ }
+ } else if (action.type === 'update_job_status') {
+ if (state.status === 'processing') {
+ return {
+ ...state,
+ jobStatus: action.jobStatus,
+ progress:
+ action.jobStatus.progress !== undefined
+ ? action.jobStatus.progress / 100
+ : state.progress,
+ }
+ }
+ } else if (action.type === 'to_done') {
+ if (state.status === 'processing') {
+ return {
+ status: 'done',
+ progress: 100,
+ abortController: state.abortController,
+ asset: state.asset,
+ video: state.video,
+ pendingPublish: {
+ blobRef: action.blobRef,
+ mutableProcessed: false,
+ },
+ }
+ }
+ }
+ console.error(
+ 'Unexpected video action (' +
+ action.type +
+ ') while in ' +
+ state.status +
+ ' state',
+ )
+ return state
+}
+
+function trunc2dp(num: number) {
+ return Math.trunc(num * 100) / 100
+}
+
+export async function processVideo(
+ asset: ImagePickerAsset,
+ dispatch: (action: VideoAction) => void,
+ agent: BskyAgent,
+ did: string,
+ signal: AbortSignal,
+ _: I18n['_'],
+) {
+ let video: CompressedVideo | undefined
+ try {
+ video = await compressVideo(asset, {
+ onProgress: num => {
+ dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
+ },
+ signal,
+ })
+ } catch (e) {
+ const message = getCompressErrorMessage(e, _)
+ if (message !== null) {
+ dispatch({
+ type: 'to_error',
+ error: message,
+ signal,
+ })
+ }
+ return
+ }
+ dispatch({
+ type: 'compressing_to_uploading',
+ video,
+ signal,
+ })
+
+ let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
+ try {
+ uploadResponse = await uploadVideo({
+ video,
+ agent,
+ did,
+ signal,
+ _,
+ setProgress: p => {
+ dispatch({type: 'update_progress', progress: p, signal})
+ },
+ })
+ } catch (e) {
+ const message = getUploadErrorMessage(e, _)
+ if (message !== null) {
+ dispatch({
+ type: 'to_error',
+ error: message,
+ signal,
+ })
+ }
+ return
+ }
+
+ const jobId = uploadResponse.jobId
+ dispatch({
+ type: 'uploading_to_processing',
+ jobId,
+ signal,
+ })
+
+ let pollFailures = 0
+ while (true) {
+ if (signal.aborted) {
+ return // Exit async loop
+ }
+
+ const videoAgent = createVideoAgent()
+ let status: JobStatus | undefined
+ let blob: BlobRef | undefined
+ try {
+ const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
+ status = response.data.jobStatus
+ pollFailures = 0
+
+ if (status.state === 'JOB_STATE_COMPLETED') {
+ blob = status.blob
+ if (!blob) {
+ throw new Error('Job completed, but did not return a blob')
+ }
+ } else if (status.state === 'JOB_STATE_FAILED') {
+ throw new Error(status.error ?? 'Job failed to process')
+ }
+ } catch (e) {
+ if (!status) {
+ pollFailures++
+ if (pollFailures < 50) {
+ await new Promise(resolve => setTimeout(resolve, 5000))
+ continue // Continue async loop
+ }
+ }
+
+ logger.error('Error processing video', {safeMessage: e})
+ dispatch({
+ type: 'to_error',
+ error: _(msg`Video failed to process`),
+ signal,
+ })
+ return // Exit async loop
+ }
+
+ if (blob) {
+ dispatch({
+ type: 'to_done',
+ blobRef: blob,
+ signal,
+ })
+ } else {
+ dispatch({
+ type: 'update_job_status',
+ jobStatus: status,
+ signal,
+ })
+ }
+
+ if (
+ status.state !== 'JOB_STATE_COMPLETED' &&
+ status.state !== 'JOB_STATE_FAILED'
+ ) {
+ await new Promise(resolve => setTimeout(resolve, 1500))
+ continue // Continue async loop
+ }
+
+ return // Exit async loop
+ }
+}
+
+function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
+ if (e instanceof AbortError) {
+ return null
+ }
+ if (e instanceof VideoTooLargeError) {
+ return _(msg`The selected video is larger than 50MB.`)
+ }
+ logger.error('Error compressing video', {safeMessage: e})
+ return _(msg`An error occurred while compressing the video.`)
+}
+
+function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
+ if (e instanceof AbortError) {
+ return null
+ }
+ logger.error('Error uploading video', {safeMessage: e})
+ if (e instanceof ServerError || e instanceof UploadLimitError) {
+ // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
+ switch (e.message) {
+ case 'User is not allowed to upload videos':
+ return _(msg`You are not allowed to upload videos.`)
+ case 'Uploading is disabled at the moment':
+ return _(
+ msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
+ )
+ case "Failed to get user's upload stats":
+ return _(
+ msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
+ )
+ case 'User has exceeded daily upload bytes limit':
+ return _(
+ msg`You've reached your daily limit for video uploads (too many bytes)`,
+ )
+ case 'User has exceeded daily upload videos limit':
+ return _(
+ msg`You've reached your daily limit for video uploads (too many videos)`,
+ )
+ case 'Account is not old enough to upload videos':
+ return _(
+ msg`Your account is not yet old enough to upload videos. Please try again later.`,
+ )
+ default:
+ return e.message
+ }
+ }
+ return _(msg`An error occurred while uploading the video.`)
+}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 2f2b4c3e7a..bbb3d95f2b 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -9,12 +9,14 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
+import {BSKY_SERVICE} from '#/lib/constants'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
+import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
+import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
-import {BSKY_SERVICE} from 'lib/constants'
-import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current,
})
if (response.assets && response.assets.length > 0) {
- if (isNative) {
- if (typeof response.assets[0].duration !== 'number')
- throw Error('Asset is not a video')
- if (response.assets[0].duration > VIDEO_MAX_DURATION) {
- setError(_(msg`Videos must be less than 60 seconds long`))
- return
- }
- }
+ const asset = response.assets[0]
try {
- onSelectVideo(response.assets[0])
+ if (isWeb) {
+ // compression step on native converts to mp4, so no need to check there
+ const mimeType = getMimeType(asset)
+ if (
+ !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
+ ) {
+ throw Error(_(msg`Unsupported video type: ${mimeType}`))
+ }
+ } else {
+ if (typeof asset.duration !== 'number') {
+ throw Error('Asset is not a video')
+ }
+ if (asset.duration > VIDEO_MAX_DURATION) {
+ throw Error(_(msg`Videos must be less than 60 seconds long`))
+ }
+ }
+ onSelectVideo(asset)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
@@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
/>
)
}
+
+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
+}
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 67981618e1..5ad4c256dd 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useHapticsDisabled} from '#/state/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function PostThreadComposePrompt({
@@ -21,10 +21,15 @@ export function PostThreadComposePrompt({
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {_} = useLingui()
- const {isTabletOrDesktop} = useWebMediaQueries()
+ const {gtMobile} = useBreakpoints()
const t = useTheme()
const playHaptics = useHaptics()
const isHapticsDisabled = useHapticsDisabled()
+ const {
+ state: hovered,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
const onPress = () => {
playHaptics('Light')
@@ -42,13 +47,15 @@ export function PostThreadComposePrompt({
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
style={[
- {paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11},
- a.px_sm,
+ gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
+ gtMobile ? {paddingLeft: 6, paddingRight: 6} : a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
]}
- onPress={onPress}>
+ onPress={onPress}
+ onHoverIn={onHoverIn}
+ onHoverOut={onHoverOut}>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index fe6efc02fa..33287564a7 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -22,6 +22,7 @@ import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
+import {logEvent} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTheme} from '#/lib/ThemeContext'
@@ -350,6 +351,7 @@ let PostDropdownBtn = ({
])
const onPressPin = useCallback(() => {
+ logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({
postUri,
postCid,