Merge remote-tracking branch 'origin/main' into silence-of-the-words
* origin/main: (37 commits) Only show "followed you back" when appropriate (#4849) [Web] Retrigger onEndReached if needed when content height changes (#4859) Remove unused NoopFeedTuner (#4856) useDedupe callback (#4855) [Video] Uploads (#4754) Make label required in link components (#4844) Boolean filter improvement alternative: TS upgrade (#4840) Add label to profile card (#4843) Improve a11y on noty feed (#4842) Add labels in feed card (#4836) Add labels to mod details dialog (#4839) Add labels to a few missing places (#4838) Add labels in list card (#4837) Refactor feed slices (#4834) `true` (#4833) Replace `import hairlineWidth =` with const (#4831) [Videos] Video player - PR #1 - basic player (#4731) Bump 1.90 (#4832) Fix sloppy filter(Boolean) types (#4830) Fuggedaboudit (#4829) ...
This commit is contained in:
@@ -45,9 +45,13 @@ export function useActorAutocompleteQuery(
|
||||
},
|
||||
select: React.useCallback(
|
||||
(data: AppBskyActorDefs.ProfileViewBasic[]) => {
|
||||
return computeSuggestions(data, moderationOpts || DEFAULT_MOD_OPTS)
|
||||
return computeSuggestions({
|
||||
q: prefix,
|
||||
searched: data,
|
||||
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
|
||||
})
|
||||
},
|
||||
[moderationOpts],
|
||||
[prefix, moderationOpts],
|
||||
),
|
||||
placeholderData: maintainData ? keepPreviousData : undefined,
|
||||
})
|
||||
@@ -81,19 +85,25 @@ export function useActorAutocompleteFn() {
|
||||
}
|
||||
}
|
||||
|
||||
return computeSuggestions(
|
||||
res?.data.actors,
|
||||
moderationOpts || DEFAULT_MOD_OPTS,
|
||||
)
|
||||
return computeSuggestions({
|
||||
q: query,
|
||||
searched: res?.data.actors,
|
||||
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
|
||||
})
|
||||
},
|
||||
[queryClient, moderationOpts, agent],
|
||||
)
|
||||
}
|
||||
|
||||
function computeSuggestions(
|
||||
searched: AppBskyActorDefs.ProfileViewBasic[] = [],
|
||||
moderationOpts: ModerationOpts,
|
||||
) {
|
||||
function computeSuggestions({
|
||||
q,
|
||||
searched = [],
|
||||
moderationOpts,
|
||||
}: {
|
||||
q?: string
|
||||
searched?: AppBskyActorDefs.ProfileViewBasic[]
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
let items: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
for (const item of searched) {
|
||||
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||
@@ -102,6 +112,7 @@ function computeSuggestions(
|
||||
}
|
||||
return items.filter(profile => {
|
||||
const modui = moderateProfile(profile, moderationOpts).ui('profileList')
|
||||
return !modui.filter || isJustAMute(modui)
|
||||
const isExactMatch = q && profile.handle.toLowerCase() === q
|
||||
return isExactMatch || !modui.filter || isJustAMute(modui)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,11 +46,14 @@ const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
const RQKEY_ROOT = 'notification-feed'
|
||||
export function RQKEY() {
|
||||
return [RQKEY_ROOT]
|
||||
export function RQKEY(priority?: false) {
|
||||
return [RQKEY_ROOT, priority]
|
||||
}
|
||||
|
||||
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
export function useNotificationFeedQuery(opts?: {
|
||||
enabled?: boolean
|
||||
overridePriorityNotifications?: boolean
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
const lastPageCountRef = useRef(0)
|
||||
const gate = useGate()
|
||||
|
||||
// false: force showing all notifications
|
||||
// undefined: let the server decide
|
||||
const priority = opts?.overridePriorityNotifications ? false : undefined
|
||||
|
||||
const query = useInfiniteQuery<
|
||||
FeedPage,
|
||||
Error,
|
||||
@@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
RQPageParam
|
||||
>({
|
||||
staleTime: STALE.INFINITY,
|
||||
queryKey: RQKEY(),
|
||||
queryKey: RQKEY(priority),
|
||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||
let page
|
||||
if (!pageParam) {
|
||||
@@ -75,17 +82,17 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
page = unreads.getCachedUnreadPage()
|
||||
}
|
||||
if (!page) {
|
||||
page = (
|
||||
await fetchPage({
|
||||
agent,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
})
|
||||
).page
|
||||
const {page: fetchedPage} = await fetchPage({
|
||||
agent,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
priority,
|
||||
})
|
||||
page = fetchedPage
|
||||
}
|
||||
|
||||
// if the first page has an unread, mark all read
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export function useNotificationsSettingsMutation() {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (keys: string[]) => {
|
||||
const enabled = keys[0] === 'enabled'
|
||||
|
||||
await agent.api.app.bsky.notification.putPreferences({
|
||||
priority: enabled,
|
||||
})
|
||||
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
res => res.data.priority === enabled,
|
||||
() => agent.api.app.bsky.notification.listNotifications({limit: 1}),
|
||||
)
|
||||
|
||||
eagerlySetCachedPriority(queryClient, enabled)
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Failed to save notification preferences', {
|
||||
safeMessage: err,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`Failed to save notification preferences, please try again`),
|
||||
'xmark',
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
Toast.show(_(msg`Preference saved`))
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateCachedUnreadPage()
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function eagerlySetCachedPriority(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page: any) => {
|
||||
return {
|
||||
...page,
|
||||
priority: enabled,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface FeedPage {
|
||||
cursor: string | undefined
|
||||
seenAt: Date
|
||||
items: FeedNotification[]
|
||||
priority: boolean
|
||||
}
|
||||
|
||||
export interface CachedFeedPage {
|
||||
|
||||
@@ -39,10 +39,15 @@ export async function fetchPage({
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
fetchAdditionalData: boolean
|
||||
shouldUngroupFollowBacks?: () => boolean
|
||||
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
|
||||
priority?: boolean
|
||||
}): Promise<{
|
||||
page: FeedPage
|
||||
indexedAt: string | undefined
|
||||
}> {
|
||||
const res = await agent.listNotifications({
|
||||
limit,
|
||||
cursor,
|
||||
// priority,
|
||||
})
|
||||
|
||||
const indexedAt = res.data.notifications[0]?.indexedAt
|
||||
@@ -88,6 +93,7 @@ export async function fetchPage({
|
||||
cursor: res.data.cursor,
|
||||
seenAt,
|
||||
items: notifsGrouped,
|
||||
priority: res.data.priority ?? false,
|
||||
},
|
||||
indexedAt,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
|
||||
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
|
||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||
@@ -61,7 +61,6 @@ export type FeedDescriptor =
|
||||
| `list|${ListUri}`
|
||||
| `list|${ListUri}|${ListFilter}`
|
||||
export interface FeedParams {
|
||||
disableTuner?: boolean
|
||||
mergeFeedEnabled?: boolean
|
||||
mergeFeedSources?: string[]
|
||||
}
|
||||
@@ -78,7 +77,10 @@ export interface FeedPostSliceItem {
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
feedContext: string | undefined
|
||||
moderation: ModerationDecision
|
||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||
@@ -102,7 +104,7 @@ export interface FeedPageUnselected {
|
||||
|
||||
export interface FeedPage {
|
||||
api: FeedAPI
|
||||
tuner: FeedTuner | NoopFeedTuner
|
||||
tuner: FeedTuner
|
||||
cursor: string | undefined
|
||||
slices: FeedPostSlice[]
|
||||
fetchedAt: number
|
||||
@@ -139,18 +141,11 @@ export function usePostFeedQuery(
|
||||
const selectArgs = React.useMemo(
|
||||
() => ({
|
||||
feedTuners,
|
||||
disableTuner: params?.disableTuner,
|
||||
moderationOpts,
|
||||
ignoreFilterFor: opts?.ignoreFilterFor,
|
||||
isDiscover,
|
||||
}),
|
||||
[
|
||||
feedTuners,
|
||||
params?.disableTuner,
|
||||
moderationOpts,
|
||||
opts?.ignoreFilterFor,
|
||||
isDiscover,
|
||||
],
|
||||
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
|
||||
)
|
||||
|
||||
const query = useInfiniteQuery<
|
||||
@@ -229,17 +224,10 @@ export function usePostFeedQuery(
|
||||
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
||||
// If the selection depends on some data, that data should
|
||||
// be included in the selectArgs object and read here.
|
||||
const {
|
||||
feedTuners,
|
||||
disableTuner,
|
||||
moderationOpts,
|
||||
ignoreFilterFor,
|
||||
isDiscover,
|
||||
} = selectArgs
|
||||
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
|
||||
selectArgs
|
||||
|
||||
const tuner = disableTuner
|
||||
? new NoopFeedTuner()
|
||||
: new FeedTuner(feedTuners)
|
||||
const tuner = new FeedTuner(feedTuners)
|
||||
|
||||
// Keep track of the last run and whether we can reuse
|
||||
// some already selected pages from there.
|
||||
@@ -311,7 +299,7 @@ export function usePostFeedQuery(
|
||||
if (isDiscover) {
|
||||
userActionHistory.seen(
|
||||
slice.items.map(item => ({
|
||||
feedContext: item.feedContext,
|
||||
feedContext: slice.feedContext,
|
||||
likeCount: item.post.likeCount ?? 0,
|
||||
repostCount: item.post.repostCount ?? 0,
|
||||
replyCount: item.post.replyCount ?? 0,
|
||||
@@ -323,10 +311,10 @@ export function usePostFeedQuery(
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
const feedPostSlice: FeedPostSlice = {
|
||||
_reactKey: slice._reactKey,
|
||||
_isFeedPostSlice: true,
|
||||
rootUri: slice.rootItem.post.uri,
|
||||
rootUri: slice.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
slice.items.every(
|
||||
@@ -341,35 +329,42 @@ export function usePostFeedQuery(
|
||||
AppBskyFeedPost.validateRecord(item.post.record)
|
||||
.success
|
||||
) {
|
||||
const parentAuthor =
|
||||
item.reply?.parent?.author ??
|
||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||
const parent = item.reply?.parent
|
||||
let parentAuthor:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
if (!parentAuthor) {
|
||||
parentAuthor =
|
||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||
}
|
||||
const replyRef = item.reply
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||
replyRef?.parent,
|
||||
)
|
||||
|
||||
return {
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
reason:
|
||||
i === 0 && slice.source
|
||||
? slice.source
|
||||
: item.reason,
|
||||
feedContext: item.feedContext || slice.feedContext,
|
||||
reason: slice.reason,
|
||||
feedContext: slice.feedContext,
|
||||
moderation: moderations[i],
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter(Boolean) as FeedPostSliceItem[],
|
||||
.filter(n => !!n),
|
||||
}
|
||||
return feedPostSlice
|
||||
})
|
||||
.filter(Boolean) as FeedPostSlice[],
|
||||
.filter(n => !!n),
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type ThreadgateSetting =
|
||||
| {type: 'nobody'}
|
||||
| {type: 'mention'}
|
||||
| {type: 'following'}
|
||||
| {type: 'list'; list: string}
|
||||
| {type: 'list'; list: unknown}
|
||||
|
||||
export function threadgateViewToSettings(
|
||||
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
|
||||
@@ -21,18 +21,18 @@ export function threadgateViewToSettings(
|
||||
if (!record.allow?.length) {
|
||||
return [{type: 'nobody'}]
|
||||
}
|
||||
return record.allow
|
||||
const settings: ThreadgateSetting[] = record.allow
|
||||
.map(allow => {
|
||||
let setting: ThreadgateSetting | undefined
|
||||
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
|
||||
return {type: 'mention'}
|
||||
setting = {type: 'mention'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
|
||||
setting = {type: 'following'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
|
||||
setting = {type: 'list', list: allow.list}
|
||||
}
|
||||
if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
|
||||
return {type: 'following'}
|
||||
}
|
||||
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
|
||||
return {type: 'list', list: allow.list}
|
||||
}
|
||||
return undefined
|
||||
return setting
|
||||
})
|
||||
.filter(Boolean) as ThreadgateSetting[]
|
||||
.filter(n => !!n)
|
||||
return settings
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
||||
|
||||
export function useCompressVideoMutation({
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onProgress: (progress: number) => void
|
||||
onError: (e: any) => void
|
||||
onSuccess: (video: CompressedVideo) => void
|
||||
}) {
|
||||
return useMutation({
|
||||
mutationFn: async (asset: ImagePickerAsset) => {
|
||||
return await compressVideo(asset.uri, {
|
||||
onProgress: num => onProgress(trunc2dp(num)),
|
||||
})
|
||||
},
|
||||
onError,
|
||||
onSuccess,
|
||||
onMutate: () => {
|
||||
onProgress(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function trunc2dp(num: number) {
|
||||
return Math.trunc(num * 100) / 100
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
route: string,
|
||||
params?: Record<string, string>,
|
||||
) => {
|
||||
const url = new URL(`${UPLOAD_ENDPOINT}`)
|
||||
url.pathname = route
|
||||
if (params) {
|
||||
for (const key in params) {
|
||||
url.searchParams.set(key, params[key])
|
||||
}
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
},
|
||||
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')
|
||||
}
|
||||
|
||||
// @TODO rm, useful for debugging/getting video cid
|
||||
console.log('[VIDEO]', res.body)
|
||||
const responseBody = JSON.parse(res.body) as UploadVideoResponse
|
||||
onSuccess(responseBody)
|
||||
return responseBody
|
||||
},
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
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 UploadVideoResponse
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
||||
// @TODO remove this header for prod
|
||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
||||
xhr.send(bytes)
|
||||
})) as UploadVideoResponse
|
||||
|
||||
// @TODO rm for prod
|
||||
console.log('[VIDEO]', res)
|
||||
return res
|
||||
},
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import React from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {JobState, JobStatus} from 'lib/media/video/types'
|
||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||
import {createVideoEndpointUrl} 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: 'SetVideo'; video: CompressedVideo}
|
||||
| {type: 'SetJobStatus'; jobStatus: JobStatus}
|
||||
|
||||
export interface State {
|
||||
status: Status
|
||||
progress: number
|
||||
asset?: ImagePickerAsset
|
||||
video: CompressedVideo | null
|
||||
jobStatus?: JobStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
function reducer(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') {
|
||||
updatedState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {...state, asset: action.asset}
|
||||
} else if (action.type === 'SetVideo') {
|
||||
updatedState = {...state, video: action.video}
|
||||
} else if (action.type === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
|
||||
export function useUploadVideo({
|
||||
setStatus,
|
||||
onSuccess,
|
||||
}: {
|
||||
setStatus: (status: string) => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
})
|
||||
|
||||
const {setJobId} = useUploadStatusQuery({
|
||||
onStatusChange: (status: 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: () => {
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'idle',
|
||||
})
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
||||
onSuccess: response => {
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'processing',
|
||||
})
|
||||
setJobId(response.job_id)
|
||||
},
|
||||
onError: e => {
|
||||
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})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: onSelectVideo} = useCompressVideoMutation({
|
||||
onProgress: p => {
|
||||
dispatch({type: 'SetProgress', progress: p})
|
||||
},
|
||||
onError: e => {
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`The selected video is larger than 100MB.`),
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
// @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})
|
||||
}
|
||||
},
|
||||
onSuccess: (video: CompressedVideo) => {
|
||||
dispatch({
|
||||
type: 'SetVideo',
|
||||
video,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'uploading',
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'compressing',
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
}
|
||||
|
||||
const clearVideo = () => {
|
||||
// @TODO cancel any running jobs
|
||||
dispatch({type: 'Reset'})
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
dispatch,
|
||||
selectVideo,
|
||||
clearVideo,
|
||||
}
|
||||
}
|
||||
|
||||
const useUploadStatusQuery = ({
|
||||
onStatusChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
onStatusChange: (status: JobStatus) => void
|
||||
onSuccess: () => void
|
||||
}) => {
|
||||
const [enabled, setEnabled] = React.useState(true)
|
||||
const [jobId, setJobId] = React.useState<string>()
|
||||
|
||||
const {isLoading, isError} = useQuery({
|
||||
queryKey: ['video-upload'],
|
||||
queryFn: async () => {
|
||||
const url = createVideoEndpointUrl(`/job/${jobId}/status`)
|
||||
const res = await fetch(url)
|
||||
const status = (await res.json()) as JobStatus
|
||||
if (status.state === JobState.JOB_STATE_COMPLETED) {
|
||||
setEnabled(false)
|
||||
onSuccess()
|
||||
}
|
||||
onStatusChange(status)
|
||||
return status
|
||||
},
|
||||
enabled: Boolean(jobId && enabled),
|
||||
refetchInterval: 1500,
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
setJobId: (_jobId: string) => {
|
||||
setJobId(_jobId)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user