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:
Eric Bailey
2024-07-31 16:57:44 -05:00
159 changed files with 13317 additions and 11027 deletions
+22 -11
View File
@@ -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)
})
}
+22 -15
View File
@@ -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,
}
}),
}
})
}
+1
View File
@@ -22,6 +22,7 @@ export interface FeedPage {
cursor: string | undefined
seenAt: Date
items: FeedNotification[]
priority: boolean
}
export interface CachedFeedPage {
+7 -1
View File
@@ -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 -36
View File
@@ -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),
})),
],
}
+11 -11
View File
@@ -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
}
+31
View File
@@ -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
}
+15
View File
@@ -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
}
+59
View File
@@ -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,
})
}
+212
View File
@@ -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)
},
}
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react'
interface PostProgressState {
progress: number
status: 'pending' | 'success' | 'error' | 'idle'
error?: string
}
const PostProgressContext = React.createContext<PostProgressState>({
progress: 0,
status: 'idle',
})
export function Provider() {}
export function usePostProgress() {
return React.useContext(PostProgressContext)
}