From 8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 30 Jul 2024 08:25:31 -0700 Subject: [PATCH 01/30] [Video] Uploads (#4754) * state for video uploads * get upload working * add a debug log * add post progress * progress * fetch data * add some progress info, web uploads * post on finished uploading (wip) * add a note * add some todos * clear video * merge some stuff * convert to `createUploadTask` * patch expo modules core * working native upload progress * platform fork * upload progress for web * cleanup * cleanup * more tweaks * simplify * fix type errors --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- patches/expo-modules-core+1.12.11.patch | 12 + src/lib/api/index.ts | 4 + src/lib/media/video/types.ts | 36 +++ src/state/queries/video/compress-video.ts | 31 +++ src/state/queries/video/util.ts | 15 ++ src/state/queries/video/video-upload.ts | 59 +++++ src/state/queries/video/video-upload.web.ts | 66 ++++++ src/state/queries/video/video.ts | 212 ++++++++++++++++++ src/state/shell/post-progress.tsx | 18 ++ src/view/com/composer/Composer.tsx | 193 +++++++++++----- src/view/com/composer/videos/VideoPreview.tsx | 1 + .../videos/VideoTranscodeProgress.tsx | 8 +- src/view/com/composer/videos/state.ts | 51 ----- 13 files changed, 594 insertions(+), 112 deletions(-) create mode 100644 src/lib/media/video/types.ts create mode 100644 src/state/queries/video/compress-video.ts create mode 100644 src/state/queries/video/util.ts create mode 100644 src/state/queries/video/video-upload.ts create mode 100644 src/state/queries/video/video-upload.web.ts create mode 100644 src/state/queries/video/video.ts create mode 100644 src/state/shell/post-progress.tsx delete mode 100644 src/view/com/composer/videos/state.ts diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index 4878bb9f7e..a4ee027c81 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644 Map constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); +diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js +index 109d3fe..c7fce9e 100644 +--- a/node_modules/expo-modules-core/build/uuid/uuid.js ++++ b/node_modules/expo-modules-core/build/uuid/uuid.js +@@ -1,5 +1,7 @@ + import bytesToUuid from './lib/bytesToUuid'; + import { Uuidv5Namespace } from './uuid.types'; ++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; ++ensureNativeModulesAreInstalled(); + const nativeUuidv4 = globalThis?.expo?.uuidv4; + const nativeUuidv5 = globalThis?.expo?.uuidv5; + function uuidv4() { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5b1c998cb8..12e30bf6c1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -54,6 +54,10 @@ interface PostOpts { uri: string cid: string } + video?: { + uri: string + cid: string + } extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts new file mode 100644 index 0000000000..c458da96e0 --- /dev/null +++ b/src/lib/media/video/types.ts @@ -0,0 +1,36 @@ +/** + * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. + * @temporary + * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. + * Not joking, this is temporary. + */ + +export interface JobStatus { + jobId: string + did: string + cid: string + state: JobState + progress?: number + errorHuman?: string + errorMachine?: string +} + +export enum JobState { + JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', + JOB_STATE_CREATED = 'JOB_STATE_CREATED', + JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', + JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', + JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', + JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', + JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', + JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', + JOB_STATE_FAILED = 'JOB_STATE_FAILED', + JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', +} + +export interface UploadVideoResponse { + job_id: string + did: string + cid: string + state: JobState +} diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts new file mode 100644 index 0000000000..a2c739cfde --- /dev/null +++ b/src/state/queries/video/compress-video.ts @@ -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 +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts new file mode 100644 index 0000000000..266d8aee37 --- /dev/null +++ b/src/state/queries/video/util.ts @@ -0,0 +1,15 @@ +const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' + +export const createVideoEndpointUrl = ( + route: string, + params?: Record, +) => { + 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 +} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts new file mode 100644 index 0000000000..4d7f7995c5 --- /dev/null +++ b/src/state/queries/video/video-upload.ts @@ -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, + }) +} diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts new file mode 100644 index 0000000000..b5b9e93bf9 --- /dev/null +++ b/src/state/queries/video/video-upload.web.ts @@ -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, + }) +} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts new file mode 100644 index 0000000000..295db38b43 --- /dev/null +++ b/src/state/queries/video/video.ts @@ -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() + + 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) + }, + } +} diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx new file mode 100644 index 0000000000..0df2a6be4a --- /dev/null +++ b/src/state/shell/post-progress.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +interface PostProgressState { + progress: number + status: 'pending' | 'success' | 'error' | 'idle' + error?: string +} + +const PostProgressContext = React.createContext({ + progress: 0, + status: 'idle', +}) + +export function Provider() {} + +export function usePostProgress() { + return React.useContext(PostProgressContext) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 72b6fae5fd..08ce4441f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,10 +13,16 @@ import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, + StyleProp, StyleSheet, View, + ViewStyle, } from 'react-native' +// @ts-expect-error no type definition +import ProgressCircle from 'react-native-progress/Circle' import Animated, { + FadeIn, + FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, @@ -55,6 +61,7 @@ import { import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateSetting} from '#/state/queries/threadgate' +import {useUploadVideo} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' @@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' import {GalleryModel} from 'state/models/media/gallery' +import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a, useTheme} from '#/alf' @@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import {SelectVideoBtn} from './videos/SelectVideoBtn' -import {useVideoState} from './videos/state' import {VideoPreview} from './videos/VideoPreview' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' @@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState( initQuote, ) + const { - video, - onSelectVideo, - videoPending, - videoProcessingData, + selectVideo, clearVideo, - videoProcessingProgress, - } = useVideoState({setError}) + state: videoUploadState, + } = useUploadVideo({ + setStatus: (status: string) => setProcessingState(status), + onSuccess: () => { + if (publishOnUpload) { + onPressPublish(true) + } + }, + }) + const [publishOnUpload, setPublishOnUpload] = useState(false) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({ return false }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) - const onPressPublish = async () => { + const onPressPublish = async (finishedUploading?: boolean) => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({ return } + if ( + !finishedUploading && + videoUploadState.status !== 'idle' && + videoUploadState.asset + ) { + setPublishOnUpload(true) + return + } + setError('') if ( @@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && !extLink && !video && !videoPending - const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) + gallery.size < 4 && + !extLink && + videoUploadState.status === 'idle' && + !videoUploadState.video + const hasMedia = + gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({ shape="default" size="small" style={[a.rounded_full, a.py_sm]} - onPress={onPressPublish}> + onPress={() => onPressPublish()} + disabled={ + videoUploadState.status !== 'idle' && publishOnUpload + }> {replyTo ? ( Reply @@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({ autoFocus setRichText={setRichText} onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} + onPressPublish={() => onPressPublish()} onNewLink={onNewLink} onError={setError} accessible={true} @@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({ )} - {quote ? ( - - - + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + setQuote(undefined)} /> + )} - {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> - )} - - ) : null} - {videoPending && videoProcessingData ? ( - - ) : ( - video && ( + ) : null} + {videoUploadState.status === 'compressing' && + videoUploadState.asset ? ( + + ) : videoUploadState.video ? ( // remove suspense when we get rid of lazy - + - ) - )} + ) : null} + @@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - - - {gate('videos') && ( - + ) : ( + + + {gate('videos') && ( + + )} + + - )} - - - {!isMobile ? ( - - ) : null} - + {!isMobile ? ( + + ) : null} + + )} @@ -893,3 +931,44 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, }, }) + +function ToolbarWrapper({ + style, + children, +}: { + style: StyleProp + children: React.ReactNode +}) { + if (isWeb) return children + return ( + + {children} + + ) +} + +function VideoUploadToolbar({state}: {state: VideoUploadState}) { + const t = useTheme() + + const progress = + state.status === 'compressing' || state.status === 'uploading' + ? state.progress + : state.jobStatus?.progress ?? 100 + + return ( + + + {state.status} + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index b04cdf1c8b..8e2a22852d 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -17,6 +17,7 @@ export function VideoPreview({ const player = useVideoPlayer(video.uri, player => { player.loop = true player.play() + player.volume = 0 }) return ( diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index 79407cd3ef..db58448a30 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -9,15 +9,15 @@ import {Text} from '#/components/Typography' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' export function VideoTranscodeProgress({ - input, + asset, progress, }: { - input: ImagePickerAsset + asset: ImagePickerAsset progress: number }) { const t = useTheme() - const aspectRatio = input.width / input.height + const aspectRatio = asset.width / asset.height return ( - + void}) { - const {_} = useLingui() - const [progress, setProgress] = useState(0) - - const {mutate, data, isPending, isError, reset, variables} = useMutation({ - mutationFn: async (asset: ImagePickerAsset) => { - const compressed = await compressVideo(asset.uri, { - onProgress: num => setProgress(trunc2dp(num)), - }) - - return compressed - }, - onError: (e: any) => { - // Don't log these errors in sentry, just let the user know - if (e instanceof VideoTooLargeError) { - Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark') - return - } - logger.error('Failed to compress video', {safeError: e}) - setError(_(msg`Could not compress video`)) - }, - onMutate: () => { - setProgress(0) - }, - }) - - return { - video: data, - onSelectVideo: mutate, - videoPending: isPending, - videoProcessingData: variables, - videoError: isError, - clearVideo: reset, - videoProcessingProgress: progress, - } -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} From c3e77b56ffab9deb9f9a730ea984d801d84a1b94 Mon Sep 17 00:00:00 2001 From: GSMT Date: Wed, 31 Jul 2024 00:19:23 +0200 Subject: [PATCH 02/30] useDedupe callback (#4855) --- src/lib/hooks/useDedupe.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts index d9432cb2c2..13b5b83f58 100644 --- a/src/lib/hooks/useDedupe.ts +++ b/src/lib/hooks/useDedupe.ts @@ -3,7 +3,7 @@ import React from 'react' export const useDedupe = () => { const canDo = React.useRef(true) - return React.useRef((cb: () => unknown) => { + return React.useCallback((cb: () => unknown) => { if (canDo.current) { canDo.current = false setTimeout(() => { @@ -13,5 +13,5 @@ export const useDedupe = () => { return true } return false - }).current + }, []) } From c75bb65bef1671e493f10e06b51ee4d0cda98d83 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 31 Jul 2024 13:00:22 +0100 Subject: [PATCH 03/30] Remove unused NoopFeedTuner (#4856) --- src/lib/api/feed-manip.ts | 10 ---------- src/state/queries/post-feed.ts | 27 ++++++--------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 89f6a0bb45..226dd17c41 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -136,16 +136,6 @@ export class FeedViewPostsSlice { } } -export class NoopFeedTuner { - reset() {} - tune( - feed: FeedViewPost[], - _opts?: {dryRun: boolean; maintainOrder: boolean}, - ): FeedViewPostsSlice[] { - return feed.map(item => new FeedViewPostsSlice(item)) - } -} - export class FeedTuner { seenKeys: Set = new Set() seenUris: Set = new Set() diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 1d6ec80d91..569c85c3ad 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -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[] } @@ -105,7 +104,7 @@ export interface FeedPageUnselected { export interface FeedPage { api: FeedAPI - tuner: FeedTuner | NoopFeedTuner + tuner: FeedTuner cursor: string | undefined slices: FeedPostSlice[] fetchedAt: number @@ -142,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< @@ -232,17 +224,10 @@ export function usePostFeedQuery( (data: InfiniteData) => { // 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. From 576cef88b550bacba26988a53c28fcc31bc9f8c5 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 31 Jul 2024 19:10:24 +0100 Subject: [PATCH 04/30] [Web] Retrigger onEndReached if needed when content height changes (#4859) * Extract EdgeVisibility * Key Visibility by container height instead of item count --- src/view/com/util/List.web.tsx | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 12d223db03..5aa699356d 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -344,10 +344,11 @@ function ListImpl( style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> {onStartReached && !isEmpty && ( - )} {headerComponent} @@ -368,11 +369,11 @@ function ListImpl( ) })} {onEndReached && !isEmpty && ( - )} {footerComponent} @@ -381,6 +382,34 @@ function ListImpl( ) } +function EdgeVisibility({ + root, + topMargin, + bottomMargin, + containerRef, + onVisibleChange, +}: { + root?: React.RefObject | null + topMargin?: string + bottomMargin?: string + containerRef: React.RefObject + onVisibleChange: (isVisible: boolean) => void +}) { + const [containerHeight, setContainerHeight] = React.useState(0) + useResizeObserver(containerRef, (w, h) => { + setContainerHeight(h) + }) + return ( + + ) +} + function useResizeObserver( ref: React.RefObject, onResize: undefined | ((w: number, h: number) => void), From 70ffd387e3fc9c08076e9ff5f6df33fa86db8151 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 31 Jul 2024 11:16:14 -0700 Subject: [PATCH 05/30] Only show "followed you back" when appropriate (#4849) * only show followed back when we should * try/catch * log * Update FeedItem.tsx * tweak --- src/view/com/notifications/FeedItem.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 520a059ae2..e4294eaa5f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -13,11 +13,13 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, + AppBskyGraphFollow, moderateProfile, ModerationDecision, ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' +import {TID} from '@atproto/common-web' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -184,10 +186,28 @@ let FeedItem = ({ action = _(msg`reposted your post`) icon = } else if (item.type === 'follow') { + let isFollowBack = false + if ( item.notification.author.viewer?.following && - gate('ungroup_follow_backs') + AppBskyGraphFollow.isRecord(item.notification.record) ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + // For some reason the following URI was invalid. Default to it not being a follow back. + console.error('Invalid following URI') + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + isFollowBack = followedTimestamp > followingTimestamp + } + } + + if (isFollowBack && gate('ungroup_follow_backs')) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) From d2e88cc623b2df5fe40280618fe9598334df8241 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 02:27:25 +0100 Subject: [PATCH 06/30] Fetch enough pages to fill a page's worth of items (#4863) * Fetch enough pages to fill a page's worth of items * Add failsafe in case of appview bug --- src/state/queries/notifications/feed.ts | 63 +++++++++++++++++-------- src/state/queries/post-feed.ts | 63 +++++++++++++++++-------- 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 3cafcb7168..3054860db2 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -59,7 +59,6 @@ export function useNotificationFeedQuery(opts?: { const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false - const lastPageCountRef = useRef(0) const gate = useGate() // false: force showing all notifications @@ -121,28 +120,52 @@ export function useNotificationFeedQuery(opts?: { }, }) + // The server may end up returning an empty page, a page with too few items, + // or a page with items that end up getting filtered out. When we fetch pages, + // we'll keep track of how many items we actually hope to see. If the server + // doesn't return enough items, we're going to continue asking for more items. + const lastItemCount = useRef(0) + const wantedItemCount = useRef(0) + const autoPaginationAttemptCount = useRef(0) useEffect(() => { - const {isFetching, hasNextPage, data} = query - if (isFetching || !hasNextPage) { - return - } - - // avoid double-fires of fetchNextPage() - if ( - lastPageCountRef.current !== 0 && - lastPageCountRef.current === data?.pages?.length - ) { - return - } - - // fetch next page if we haven't gotten a full page of content - let count = 0 + const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = + query + // Count the items that we already have. + let itemCount = 0 for (const page of data?.pages || []) { - count += page.items.length + itemCount += page.items.length } - if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { - query.fetchNextPage() - lastPageCountRef.current = data?.pages?.length || 0 + + // If items got truncated, reset the state we're tracking below. + if (itemCount !== lastItemCount.current) { + if (itemCount < lastItemCount.current) { + wantedItemCount.current = itemCount + } + lastItemCount.current = itemCount + } + + // Now track how many items we really want, and fetch more if needed. + if (isLoading || isRefetching) { + // During the initial fetch, we want to get an entire page's worth of items. + wantedItemCount.current = PAGE_SIZE + } else if (isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + // We have more items than wantedItemCount, so wantedItemCount must be out of date. + // Some other code must have called fetchNextPage(), for example, from onEndReached. + // Adjust the wantedItemCount to reflect that we want one more full page of items. + wantedItemCount.current = itemCount + PAGE_SIZE + } + } else if (hasNextPage) { + // At this point we're not fetching anymore, so it's time to make a decision. + // If we didn't receive enough items from the server, paginate again until we do. + if (itemCount < wantedItemCount.current) { + autoPaginationAttemptCount.current++ + if (autoPaginationAttemptCount.current < 50 /* failsafe */) { + query.fetchNextPage() + } + } else { + autoPaginationAttemptCount.current = 0 + } } }, [query]) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 569c85c3ad..65467e8023 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -134,7 +134,6 @@ export function usePostFeedQuery( args: typeof selectArgs result: InfiniteData } | null>(null) - const lastPageCountRef = useRef(0) const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) // Make sure this doesn't invalidate unless really needed. @@ -376,30 +375,54 @@ export function usePostFeedQuery( ), }) + // The server may end up returning an empty page, a page with too few items, + // or a page with items that end up getting filtered out. When we fetch pages, + // we'll keep track of how many items we actually hope to see. If the server + // doesn't return enough items, we're going to continue asking for more items. + const lastItemCount = useRef(0) + const wantedItemCount = useRef(0) + const autoPaginationAttemptCount = useRef(0) useEffect(() => { - const {isFetching, hasNextPage, data} = query - if (isFetching || !hasNextPage) { - return - } - - // avoid double-fires of fetchNextPage() - if ( - lastPageCountRef.current !== 0 && - lastPageCountRef.current === data?.pages?.length - ) { - return - } - - // fetch next page if we haven't gotten a full page of content - let count = 0 + const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = + query + // Count the items that we already have. + let itemCount = 0 for (const page of data?.pages || []) { for (const slice of page.slices) { - count += slice.items.length + itemCount += slice.items.length } } - if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { - query.fetchNextPage() - lastPageCountRef.current = data?.pages?.length || 0 + + // If items got truncated, reset the state we're tracking below. + if (itemCount !== lastItemCount.current) { + if (itemCount < lastItemCount.current) { + wantedItemCount.current = itemCount + } + lastItemCount.current = itemCount + } + + // Now track how many items we really want, and fetch more if needed. + if (isLoading || isRefetching) { + // During the initial fetch, we want to get an entire page's worth of items. + wantedItemCount.current = PAGE_SIZE + } else if (isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + // We have more items than wantedItemCount, so wantedItemCount must be out of date. + // Some other code must have called fetchNextPage(), for example, from onEndReached. + // Adjust the wantedItemCount to reflect that we want one more full page of items. + wantedItemCount.current = itemCount + PAGE_SIZE + } + } else if (hasNextPage) { + // At this point we're not fetching anymore, so it's time to make a decision. + // If we didn't receive enough items from the server, paginate again until we do. + if (itemCount < wantedItemCount.current) { + autoPaginationAttemptCount.current++ + if (autoPaginationAttemptCount.current < 50 /* failsafe */) { + query.fetchNextPage() + } + } else { + autoPaginationAttemptCount.current = 0 + } } }, [query]) From b0e130a4d85f2056bddcbf210aa7ea4068d41686 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 1 Aug 2024 10:29:27 -0500 Subject: [PATCH 07/30] Update muted words dialog with `expiresAt` and `actorTarget` (#4801) * WIP not working dropdown * Update MutedWords dialog * Add i18n formatDistance * Comments * Handle text wrapping * Update label copy Co-authored-by: Hailey * Fix alignment * Improve translation output * Revert toggle changes * Better types for useFormatDistance * Tweaks * Integrate new sdk version into TagMenu * Use ampersand Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Bump SDK --------- Co-authored-by: Hailey Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- package.json | 2 +- src/components/TagMenu/index.tsx | 56 ++-- src/components/TagMenu/index.web.tsx | 36 ++- src/components/dialogs/MutedWords.tsx | 373 +++++++++++++++++++------ src/components/hooks/dates.ts | 69 +++++ src/state/queries/preferences/index.ts | 15 + yarn.lock | 8 +- 7 files changed, 432 insertions(+), 127 deletions(-) create mode 100644 src/components/hooks/dates.ts diff --git a/package.json b/package.json index 91b427ae91..3d053bc83b 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "0.12.25", + "@atproto/api": "^0.12.26", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 0ed7036671..2c6a0b674c 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -1,27 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useNavigation} from '@react-navigation/native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' -import {atoms as a, native, useTheme} from '#/alf' -import * as Dialog from '#/components/Dialog' -import {Text} from '#/components/Typography' -import {Button, ButtonText} from '#/components/Button' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' -import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {Divider} from '#/components/Divider' -import {Link} from '#/components/Link' import {makeSearchLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' +import {atoms as a, native, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' -import {isInvalidHandle} from '#/lib/strings/handles' +import {Text} from '#/components/Typography' export function useTagMenuControl() { return Dialog.useDialogControl() @@ -52,10 +52,10 @@ export function TagMenu({ reset: resetUpsert, } = useUpsertMutedWordsMutation() const { - mutateAsync: removeMutedWord, + mutateAsync: removeMutedWords, variables: optimisticRemove, reset: resetRemove, - } = useRemoveMutedWordMutation() + } = useRemoveMutedWordsMutation() const displayTag = '#' + tag const isMuted = Boolean( @@ -65,9 +65,20 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + return ( <> {children} @@ -212,13 +223,16 @@ export function TagMenu({ control.close(() => { if (isMuted) { resetUpsert() - removeMutedWord({ - value: tag, - targets: ['tag'], - }) + removeMutedWords(removeableMuteWords) } else { resetRemove() - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + { + value: tag, + targets: ['tag'], + actorTarget: 'all', + }, + ]) } }) }}> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 4336223861..b6c306439a 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -3,16 +3,16 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {isInvalidHandle} from '#/lib/strings/handles' -import {EventStopper} from '#/view/com/util/EventStopper' -import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' -import {enforceLen} from '#/lib/strings/helpers' +import {EventStopper} from '#/view/com/util/EventStopper' +import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {web} from '#/alf' import * as Dialog from '#/components/Dialog' @@ -47,8 +47,8 @@ export function TagMenu({ const {data: preferences} = usePreferencesQuery() const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = useUpsertMutedWordsMutation() - const {mutateAsync: removeMutedWord, variables: optimisticRemove} = - useRemoveMutedWordMutation() + const {mutateAsync: removeMutedWords, variables: optimisticRemove} = + useRemoveMutedWordsMutation() const isMuted = Boolean( (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), @@ -56,10 +56,21 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + const dropdownItems = React.useMemo(() => { return [ { @@ -105,9 +116,11 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: tag, targets: ['tag']}) + removeMutedWords(removeableMuteWords) } else { - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + {value: tag, targets: ['tag'], actorTarget: 'all'}, + ]) } }, testID: 'tagMenuMute', @@ -129,7 +142,8 @@ export function TagMenu({ tag, truncatedTag, upsertMutedWord, - removeMutedWord, + removeMutedWords, + removeableMuteWords, ]) return ( diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 526652be95..38273aad54 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {View} from 'react-native' import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' +import {useFormatDistance} from '#/components/hooks/dates' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' @@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +const ONE_DAY = 24 * 60 * 60 * 1000 + export function MutedWordsDialog() { const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() return ( @@ -53,16 +56,32 @@ function MutedWordsInner() { } = usePreferencesQuery() const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') - const [options, setOptions] = React.useState(['content']) + const [targets, setTargets] = React.useState(['content']) const [error, setError] = React.useState('') + const [durations, setDurations] = React.useState(['forever']) + const [excludeFollowing, setExcludeFollowing] = React.useState(false) const submit = React.useCallback(async () => { const sanitizedValue = sanitizeMutedWordValue(field) - const targets = ['tag', options.includes('content') && 'content'].filter( + const surfaces = ['tag', targets.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] + const actorTarget = excludeFollowing ? 'exclude-following' : 'all' - if (!sanitizedValue || !targets.length) { + const now = Date.now() + const rawDuration = durations.at(0) + // undefined evaluates to 'forever' + let duration: string | undefined + + if (rawDuration === '24_hours') { + duration = new Date(now + ONE_DAY).toISOString() + } else if (rawDuration === '7_days') { + duration = new Date(now + 7 * ONE_DAY).toISOString() + } else if (rawDuration === '30_days') { + duration = new Date(now + 30 * ONE_DAY).toISOString() + } + + if (!sanitizedValue || !surfaces.length) { setField('') setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) return @@ -70,28 +89,37 @@ function MutedWordsInner() { try { // send raw value and rely on SDK as sanitization source of truth - await addMutedWord([{value: field, targets}]) + await addMutedWord([ + { + value: field, + targets: surfaces, + actorTarget, + expiresAt: duration, + }, + ]) setField('') } catch (e: any) { logger.error(`Failed to save muted word`, {message: e.message}) setError(e.message) } - }, [_, field, options, addMutedWord, setField]) + }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) return ( - + Add muted words and tags - Posts can be muted based on their text, their tags, or both. + Posts can be muted based on their text, their tags, or both. We + recommend avoiding common words that appear in many posts, since it + can result in no posts being shown. - + + + + values={durations} + onChange={setDurations}> + + Duration: + + + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + + + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + + + + + + Mute in: + + + + style={[a.flex_1]}> - + - - Mute in text & tags + + Text & tags @@ -140,34 +273,64 @@ function MutedWordsInner() { + style={[a.flex_1]}> - + - - Mute in tags only + + Tags only - - + + + Options: + + + + + + + Exclude users you follow + + + + + + + + + + {error && ( )} - - - - We recommend avoiding common words that appear in many posts, - since it can result in no posts being shown. - - @@ -268,6 +417,9 @@ function MutedWordRow({ const {_} = useLingui() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() const control = Prompt.usePromptControl() + const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined + const isExpired = expiryDate && expiryDate < new Date() + const formatDistance = useFormatDistance() const remove = React.useCallback(async () => { control.close() @@ -280,7 +432,7 @@ function MutedWordRow({ control={control} title={_(msg`Are you sure?`)} description={_( - msg`This will delete ${word.value} from your muted words. You can always add it back later.`, + msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, )} onConfirm={remove} confirmButtonCta={_(msg`Remove`)} @@ -289,53 +441,94 @@ function MutedWordRow({ - - {word.value} - + + + + {word.targets.find(t => t === 'content') ? ( + + {word.value}{' '} + + in{' '} + + text & tags + + + + ) : ( + + {word.value}{' '} + + in{' '} + + tags + + + + )} + + - - {word.targets.map(target => ( - + {(expiryDate || word.actorTarget === 'exclude-following') && ( + - {target === 'content' ? _(msg`text`) : _(msg`tag`)} + style={[ + a.flex_1, + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {expiryDate && ( + <> + {isExpired ? ( + Expired + ) : ( + + Expires{' '} + {formatDistance(expiryDate, new Date(), { + addSuffix: true, + })} + + )} + + )} + {word.actorTarget === 'exclude-following' && ( + <> + {' • '} + Excludes users you follow + + )} - ))} - - + )} + + ) diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts new file mode 100644 index 0000000000..b0f94133b7 --- /dev/null +++ b/src/components/hooks/dates.ts @@ -0,0 +1,69 @@ +/** + * Hooks for date-fns localized formatters. + * + * Our app supports some languages that are not included in date-fns by + * default, in which case it will fall back to English. + * + * {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md} + */ + +import React from 'react' +import {formatDistance, Locale} from 'date-fns' +import { + ca, + de, + es, + fi, + fr, + hi, + id, + it, + ja, + ko, + ptBR, + tr, + uk, + zhCN, + zhTW, +} from 'date-fns/locale' + +import {AppLanguage} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' + +/** + * {@link AppLanguage} + */ +const locales: Record = { + en: undefined, + ca, + de, + es, + fi, + fr, + ga: undefined, + hi, + id, + it, + ja, + ko, + ['pt-BR']: ptBR, + tr, + uk, + ['zh-CN']: zhCN, + ['zh-TW']: zhTW, +} + +/** + * Returns a localized `formatDistance` function. + * {@link formatDistance} + */ +export function useFormatDistance() { + const {appLanguage} = useLanguagePrefs() + return React.useCallback( + (date, baseDate, options) => { + const locale = locales[appLanguage as AppLanguage] + return formatDistance(date, baseDate, {...options, locale: locale}) + }, + [appLanguage], + ) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 9bb57fcaf6..6991f8647b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() { }) } +export function useRemoveMutedWordsMutation() { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { + await agent.removeMutedWords(mutedWords) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + export function useQueueNudgesMutation() { const queryClient = useQueryClient() const agent = useAgent() diff --git a/yarn.lock b/yarn.lock index 675fda4c2f..6fa8805125 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@0.12.25": - version "0.12.25" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b" - integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A== +"@atproto/api@^0.12.26": + version "0.12.26" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" + integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" From 388c157c366e67e0cb3d74e1cd05413ef41b235d Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 17:49:43 +0100 Subject: [PATCH 08/30] Display second-to-last rather than second post in a slice (#4864) --- src/view/com/posts/FeedSlice.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index b8c9a1f2c7..8d707d78ea 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -19,6 +19,7 @@ let FeedSlice = ({ hideTopBorder?: boolean }): React.ReactNode => { if (slice.isThread && slice.items.length > 3) { + const beforeLast = slice.items.length - 2 const last = slice.items.length - 1 return ( <> @@ -36,20 +37,20 @@ let FeedSlice = ({ hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} /> - + Date: Thu, 1 Aug 2024 19:14:32 +0200 Subject: [PATCH 09/30] Move theme controls to its own screen (#4866) --- assets/icons/moon_stroke2_corner2_rounded.svg | 1 + .../icons/phone_stroke2_corner2_rounded.svg | 1 + bskyweb/cmd/bskyweb/server.go | 1 + src/Navigation.tsx | 9 ++ src/components/forms/ToggleButton.tsx | 2 +- src/components/icons/Moon.tsx | 5 + src/components/icons/Phone.tsx | 5 + src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/screens/Settings/AppearanceSettings.tsx | 135 ++++++++++++++++++ src/view/icons/index.tsx | 2 + src/view/screens/AccessibilitySettings.tsx | 10 +- .../screens/PreferencesExternalEmbeds.tsx | 5 +- src/view/screens/PreferencesFollowingFeed.tsx | 5 +- src/view/screens/PreferencesThreads.tsx | 4 +- src/view/screens/Settings/index.tsx | 95 ++++-------- 16 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 assets/icons/moon_stroke2_corner2_rounded.svg create mode 100644 assets/icons/phone_stroke2_corner2_rounded.svg create mode 100644 src/components/icons/Moon.tsx create mode 100644 src/components/icons/Phone.tsx create mode 100644 src/screens/Settings/AppearanceSettings.tsx diff --git a/assets/icons/moon_stroke2_corner2_rounded.svg b/assets/icons/moon_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..8f5c03699b --- /dev/null +++ b/assets/icons/moon_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/phone_stroke2_corner2_rounded.svg b/assets/icons/phone_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..4f44f08e52 --- /dev/null +++ b/assets/icons/phone_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 61a524a70b..8da291fe56 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -211,6 +211,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/settings/accessibility", server.WebGeneric) + e.GET("/settings/appearance", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 8646577c8b..79856879c3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import { StarterPackScreen, StarterPackScreenShort, @@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + AppearanceSettingsScreen} + options={{ + title: title(msg`Appearance Settings`), + requireAuth: true, + }} + /> HashtagScreen} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 7528426380..f47a272b18 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) { style={[ a.w_full, a.flex_row, - a.border, a.rounded_sm, a.overflow_hidden, t.atoms.border_contrast_low, + {borderWidth: 1}, ]}> {children} diff --git a/src/components/icons/Moon.tsx b/src/components/icons/Moon.tsx new file mode 100644 index 0000000000..4994370b9e --- /dev/null +++ b/src/components/icons/Moon.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z', +}) diff --git a/src/components/icons/Phone.tsx b/src/components/icons/Phone.tsx new file mode 100644 index 0000000000..62000a1e5d --- /dev/null +++ b/src/components/icons/Phone.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z', +}) diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index fbb66c9e9a..0cc83b475a 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -38,6 +38,7 @@ export type CommonNavigatorParams = { PreferencesThreads: undefined PreferencesExternalEmbeds: undefined AccessibilitySettings: undefined + AppearanceSettings: undefined Search: {q?: string} Hashtag: {tag: string; author?: string} MessagesConversation: {conversation: string; embed?: string} diff --git a/src/routes.ts b/src/routes.ts index ddf4fb39fa..c9e23e08c8 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -32,6 +32,7 @@ export const router = new Router({ PreferencesThreads: '/settings/threads', PreferencesExternalEmbeds: '/settings/external-embeds', AccessibilitySettings: '/settings/accessibility', + AppearanceSettings: '/settings/appearance', SavedFeeds: '/settings/saved-feeds', Support: '/support', PrivacyPolicy: '/support/privacy', diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx new file mode 100644 index 0000000000..00a04bbfb6 --- /dev/null +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -0,0 +1,135 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, { + FadeInDown, + FadeOutDown, + LayoutAnimationConfig, +} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {s} from '#/lib/styles' +import {useSetThemePrefs, useThemePrefs} from '#/state/shell' +import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' +import {ScrollView} from '#/view/com/util/Views' +import {atoms as a, native, useTheme} from '#/alf' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' +import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' +import {Text} from '#/components/Typography' + +type Props = NativeStackScreenProps +export function AppearanceSettingsScreen({}: Props) { + const {_} = useLingui() + const t = useTheme() + const {isTabletOrMobile} = useWebMediaQueries() + + const {colorMode, darkTheme} = useThemePrefs() + const {setColorMode, setDarkTheme} = useSetThemePrefs() + + const onChangeAppearance = useCallback( + (keys: string[]) => { + const appearance = keys.find(key => key !== colorMode) as + | 'system' + | 'light' + | 'dark' + | undefined + if (!appearance) return + setColorMode(appearance) + }, + [setColorMode, colorMode], + ) + + const onChangeDarkTheme = useCallback( + (keys: string[]) => { + const theme = keys.find(key => key !== darkTheme) as + | 'dim' + | 'dark' + | undefined + if (!theme) return + setDarkTheme(theme) + }, + [setDarkTheme, darkTheme], + ) + + return ( + + + + + + + Appearance + + + + + + + + + Mode + + + + + + System + + + + + Light + + + + + Dark + + + + {colorMode !== 'light' && ( + + + + + Dark theme + + + + + + + Dim + + + + + Dark + + + + + )} + + + + + ) +} diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index beb31eca4e..8b1655e6a8 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -77,6 +77,7 @@ import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky' +import {faPaintRoller} from '@fortawesome/free-solid-svg-icons/faPaintRoller' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' @@ -178,6 +179,7 @@ library.add( faMagnifyingGlass, faMessage, faNoteSticky, + faPaintRoller, faPaste, faPause, faPen, diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx index abe1550762..2a4477532d 100644 --- a/src/view/screens/AccessibilitySettings.tsx +++ b/src/view/screens/AccessibilitySettings.tsx @@ -27,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' +import {atoms as a} from '#/alf' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -61,10 +62,13 @@ export function AccessibilitySettingsScreen({}: Props) { showBackButton={isTabletOrMobile} style={[ pal.border, - {borderBottomWidth: 1}, - !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, + a.border_b, + !isMobile && { + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, + }, ]}> - + Accessibility Settings diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx index 57ca5e7653..ade7a53d90 100644 --- a/src/view/screens/PreferencesExternalEmbeds.tsx +++ b/src/view/screens/PreferencesExternalEmbeds.tsx @@ -18,6 +18,7 @@ import { useSetExternalEmbedPref, } from 'state/preferences' import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {atoms as a} from '#/alf' import {SimpleViewHeader} from '../com/util/SimpleViewHeader' import {Text} from '../com/util/text/Text' import {ScrollView} from '../com/util/Views' @@ -47,8 +48,8 @@ export function PreferencesExternalEmbeds({}: Props) { contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}> - + style={[pal.border, a.border_b]}> + External Media Preferences diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index 879c925fbf..daa2aba858 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -19,6 +19,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' +import {atoms as a} from '#/alf' function RepliesThresholdInput({ enabled, @@ -99,8 +100,8 @@ export function PreferencesFollowingFeed({}: Props) { contentContainerStyle={{paddingBottom: 75}}> - + style={[pal.border, a.border_b]}> + Following Feed Preferences diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index 3b09f0abb5..4a311f91ce 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) { contentContainerStyle={{paddingBottom: 75}}> - + style={[pal.border, a.border_b]}> + Thread Preferences diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index db74d5c0d5..c33be7d542 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -31,12 +31,7 @@ import {useClearPreferencesMutation} from '#/state/queries/preferences' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile' import {SessionAccount, useSession, useSessionApi} from '#/state/session' -import { - useOnboardingDispatch, - useSetMinimalShellMode, - useSetThemePrefs, - useThemePrefs, -} from '#/state/shell' +import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {useAnalytics} from 'lib/analytics/analytics' @@ -52,7 +47,6 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' import {colors, s} from 'lib/styles' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {Link, TextLink} from 'view/com/util/Link' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' @@ -61,8 +55,7 @@ import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' import {ScrollView} from 'view/com/util/Views' import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' -import {useTheme} from '#/alf' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {navigate, resetToTab} from '#/Navigation' @@ -168,8 +161,6 @@ function SettingsAccountCard({ type Props = NativeStackScreenProps export function SettingsScreen({}: Props) { const queryClient = useQueryClient() - const {colorMode, darkTheme} = useThemePrefs() - const {setColorMode, setDarkTheme} = useSetThemePrefs() const pal = usePalette('default') const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() @@ -296,6 +287,10 @@ export function SettingsScreen({}: Props) { navigation.navigate('AccessibilitySettings') }, [navigation]) + const onPressAppearanceSettings = React.useCallback(() => { + navigation.navigate('AppearanceSettings') + }, [navigation]) + const onPressBirthday = React.useCallback(() => { birthdayControl.open() }, [birthdayControl]) @@ -436,63 +431,6 @@ export function SettingsScreen({}: Props) { - - Appearance - - - - setColorMode('system')} - accessibilityHint={_(msg`Sets color theme to system setting`)} - /> - setColorMode('light')} - accessibilityHint={_(msg`Sets color theme to light`)} - /> - setColorMode('dark')} - accessibilityHint={_(msg`Sets color theme to dark`)} - /> - - - - - - {colorMode !== 'light' && ( - <> - - Dark Theme - - - - setDarkTheme('dim')} - accessibilityHint={_(msg`Sets dark theme to the dim theme`)} - /> - setDarkTheme('dark')} - accessibilityHint={_(msg`Sets dark theme to the dark theme`)} - /> - - - - - )} - Basics @@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) { Accessibility + + + + + + Appearance + + Date: Thu, 1 Aug 2024 10:32:36 -0700 Subject: [PATCH 10/30] Fix missing header on Likes/Reposted By, add missing perf optimizations (#4867) * fix liked by list * fix lists * tweaks to style * change string --- src/view/com/post-thread/PostLikedBy.tsx | 105 ++++++++++--------- src/view/com/post-thread/PostRepostedBy.tsx | 106 ++++++++++---------- src/view/screens/PostLikedBy.tsx | 15 +-- src/view/screens/PostRepostedBy.tsx | 17 ++-- src/view/screens/ProfileFeedLikedBy.tsx | 17 ++-- 5 files changed, 131 insertions(+), 129 deletions(-) diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 0760ed7ff3..da230aade9 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,38 +1,57 @@ import React, {useCallback, useMemo, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {List} from '../util/List' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {logger} from '#/logger' -import {LoadingScreen} from '../util/LoadingScreen' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {List} from '../util/List' + +function renderItem({item}: {item: GetLikes.Like}) { + return +} + +function keyExtractor(item: GetLikes.Like) { + return item.actor.did +} export function PostLikedBy({uri}: {uri: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTRing, setIsPTRing] = useState(false) + const { data: resolvedUri, error: resolveError, - isFetching: isFetchingResolvedUri, + isLoading: isLoadingUri, } = useResolveUriQuery(uri) const { data, - isFetching, - isFetched, + isLoading: isLoadingLikes, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = useLikedByQuery(resolvedUri?.uri) + + const isError = Boolean(resolveError || error) + const likes = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.likes) } + return [] }, [data]) const onRefresh = useCallback(async () => { @@ -46,64 +65,44 @@ export function PostLikedBy({uri}: {uri: string}) { }, [refetch, setIsPTRing]) const onEndReached = useCallback(async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more likes', {message: err}) } - }, [isFetching, hasNextPage, isError, fetchNextPage]) + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - const renderItem = useCallback(({item}: {item: GetLikes.Like}) => { + if (likes.length < 1) { return ( - - ) - }, []) - - if (isFetchingResolvedUri || !isFetched) { - return - } - - // error - // = - if (resolveError || isError) { - return ( - - - + ) } - // loaded - // = return ( item.actor.did} + renderItem={renderItem} + keyExtractor={keyExtractor} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - - {(isFetching || isFetchingNextPage) && } - - )} + onEndReachedThreshold={4} + ListHeaderComponent={} + ListFooterComponent={ + + } // @ts-ignore our .web version only -prf desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} /> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 31a0be832d..9038549a50 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,38 +1,57 @@ -import React, {useMemo, useCallback, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' +import React, {useCallback, useMemo, useState} from 'react' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {List} from '../util/List' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {logger} from '#/logger' -import {LoadingScreen} from '../util/LoadingScreen' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {List} from '../util/List' + +function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { + return +} + +function keyExtractor(item: ActorDefs.ProfileViewBasic) { + return item.did +} export function PostRepostedBy({uri}: {uri: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTRing, setIsPTRing] = useState(false) + const { data: resolvedUri, error: resolveError, - isFetching: isFetchingResolvedUri, + isLoading: isLoadingUri, } = useResolveUriQuery(uri) const { data, - isFetching, - isFetched, + isLoading: isLoadingRepostedBy, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = usePostRepostedByQuery(resolvedUri?.uri) + + const isError = Boolean(resolveError || error) + const repostedBy = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.repostedBy) } + return [] }, [data]) const onRefresh = useCallback(async () => { @@ -46,35 +65,20 @@ export function PostRepostedBy({uri}: {uri: string}) { }, [refetch, setIsPTRing]) const onEndReached = useCallback(async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more reposts', {message: err}) } - }, [isFetching, hasNextPage, isError, fetchNextPage]) + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - const renderItem = useCallback( - ({item}: {item: ActorDefs.ProfileViewBasic}) => { - return - }, - [], - ) - - if (isFetchingResolvedUri || !isFetched) { - return - } - - // error - // = - if (resolveError || isError) { + if (repostedBy.length < 1) { return ( - - - + ) } @@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) { return ( item.did} + renderItem={renderItem} + keyExtractor={keyExtractor} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - - {(isFetching || isFetchingNextPage) && } - - )} + onEndReachedThreshold={4} + ListHeaderComponent={} + ListFooterComponent={ + + } // @ts-ignore our .web version only -prf desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} /> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index 604301544c..5ff5a1932e 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const PostLikedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index 07017d6920..eaacc67807 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const PostRepostedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index b1bcf48ba4..bb9ec2baeb 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useSetMinimalShellMode} from '#/state/shell' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' +import {ViewHeader} from '../com/util/ViewHeader' type Props = NativeStackScreenProps export const ProfileFeedLikedByScreen = ({route}: Props) => { @@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => { ) return ( - + From 7f292abf51a4cd4e25702c33a3ed75f25be5b3a3 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 1 Aug 2024 22:05:40 +0100 Subject: [PATCH 11/30] Always limit Following replies to the people you follow (#4868) * Limit feed replies to people you follow * Remove dead code --- src/lib/api/feed-manip.ts | 14 +-- src/state/preferences/feed-tuners.tsx | 9 +- src/state/queries/preferences/const.ts | 4 +- src/view/screens/PreferencesFollowingFeed.tsx | 107 +----------------- 4 files changed, 8 insertions(+), 126 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 226dd17c41..01f05685dd 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -299,15 +299,7 @@ export class FeedTuner { return slices } - static thresholdRepliesOnly({ - userDid, - minLikes, - followedOnly, - }: { - userDid: string - minLikes: number - followedOnly: boolean - }) { + static followedRepliesOnly({userDid}: {userDid: string}) { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], @@ -322,9 +314,7 @@ export class FeedTuner { if (slice.isRepost) { continue } - if (slice.likeCount < minLikes) { - slices.splice(i, 1) - } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { + if (!slice.isFollowingAllAuthors(userDid)) { slices.splice(i, 1) } } diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index 7d44515138..d816bde649 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -38,11 +38,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { feedTuners.push(FeedTuner.removeReplies) } else { feedTuners.push( - FeedTuner.thresholdRepliesOnly({ + FeedTuner.followedRepliesOnly({ userDid: currentAccount?.did || '', - minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0, - followedOnly: - !!preferences?.feedViewPrefs.hideRepliesByUnfollowed, }), ) } @@ -66,10 +63,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { feedTuners.push(FeedTuner.removeReplies) } else { feedTuners.push( - FeedTuner.thresholdRepliesOnly({ + FeedTuner.followedRepliesOnly({ userDid: currentAccount?.did || '', - minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0, - followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed, }), ) } diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 2a8c51165e..1ae7d20684 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -7,8 +7,8 @@ import { export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = { hideReplies: false, - hideRepliesByUnfollowed: true, - hideRepliesByLikeCount: 0, + hideRepliesByUnfollowed: true, // Legacy, ignored + hideRepliesByLikeCount: 0, // Legacy, ignored hideReposts: false, hideQuotePosts: false, lab_mergeFeedEnabled: false, // experimental diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index daa2aba858..8aa4221e6c 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -1,16 +1,13 @@ -import React, {useState} from 'react' +import React from 'react' import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import debounce from 'lodash.debounce' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {colors, s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' import { usePreferencesQuery, useSetFeedViewPreferencesMutation, @@ -21,61 +18,6 @@ import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' import {atoms as a} from '#/alf' -function RepliesThresholdInput({ - enabled, - initialValue, -}: { - enabled: boolean - initialValue: number -}) { - const pal = usePalette('default') - const [value, setValue] = useState(initialValue) - const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() - const preValue = React.useRef(initialValue) - const save = React.useMemo( - () => - debounce( - threshold => - setFeedViewPref({ - hideRepliesByLikeCount: threshold, - }), - 500, - ), // debouce for 500ms - [setFeedViewPref], - ) - - return ( - - { - let threshold = Array.isArray(v) ? v[0] : v - if (threshold > preValue.current) threshold = Math.floor(threshold) - else threshold = Math.ceil(threshold) - - preValue.current = threshold - - setValue(threshold) - save(threshold) - }} - minimumValue={0} - maximumValue={25} - containerStyle={isWeb ? undefined : s.flex1} - disabled={!enabled} - thumbTintColor={colors.blue3} - /> - - - - - ) -} - type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesFollowingFeed' @@ -137,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) { } /> - - - Reply Filters - - - - Enable this setting to only see replies between people you - follow. - - - - setFeedViewPref({ - hideRepliesByUnfollowed: !( - variables?.hideRepliesByUnfollowed ?? - preferences?.feedViewPrefs?.hideRepliesByUnfollowed - ), - }) - : undefined - } - style={[s.mb10]} - /> - - - Adjust the number of likes a reply must have to be shown in your - feed. - - - {preferences && ( - - )} - - Show Reposts From 293ac6fab21f26baa8347c998f3a50224112c7c5 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 2 Aug 2024 17:13:31 +0100 Subject: [PATCH 12/30] Only show replies in Following if following all involved actors (#4869) * Only show replies in Following for followed root and grandparent * Remove now-unnecessary check * Simplify condition --- src/lib/api/feed-manip.ts | 44 +++++++++++++++------------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 01f05685dd..7ddb79434a 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -82,10 +82,6 @@ export class FeedViewPostsSlice { return AppBskyFeedDefs.isReasonRepost(reason) } - get includesThreadRoot() { - return !this.items[0].reply - } - get likeCount() { return this._feedPost.post.likeCount ?? 0 } @@ -119,20 +115,19 @@ export class FeedViewPostsSlice { isFollowingAllAuthors(userDid: string) { const feedPost = this._feedPost - if (feedPost.post.author.did === userDid) { - return true - } - if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) { - const parent = feedPost.reply?.parent - if (parent?.author.did === userDid) { - return true + const authors = [feedPost.post.author] + if (feedPost.reply) { + if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) { + authors.push(feedPost.reply.parent.author) + } + if (feedPost.reply.grandparentAuthor) { + authors.push(feedPost.reply.grandparentAuthor) + } + if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) { + authors.push(feedPost.reply.root.author) } - return ( - parent?.author.viewer?.following && - feedPost.post.author.viewer?.following - ) } - return false + return authors.every(a => a.did === userDid || a.viewer?.following) } } @@ -304,19 +299,14 @@ export class FeedTuner { tuner: FeedTuner, slices: FeedViewPostsSlice[], ): FeedViewPostsSlice[] => { - // remove any replies without at least minLikes likes for (let i = slices.length - 1; i >= 0; i--) { const slice = slices[i] - if (slice.isReply) { - if (slice.isThread && slice.includesThreadRoot) { - continue - } - if (slice.isRepost) { - continue - } - if (!slice.isFollowingAllAuthors(userDid)) { - slices.splice(i, 1) - } + if ( + slice.isReply && + !slice.isRepost && + !slice.isFollowingAllAuthors(userDid) + ) { + slices.splice(i, 1) } } return slices From c3d8beee6dc141ced2c41795f90b3309a2bc75a2 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 2 Aug 2024 13:05:33 -0500 Subject: [PATCH 13/30] Respect labels on feeds and lists (#4818) * Prep * Pass in optional moderation to FeedCard * Compute moderation decision, filter contentList contexts, pass into card * Let's go a different route * Filter from within search queries * Use same search query for starter packs * Filter lists from profile tabs * Cleanup * Filter from profile feeds * Moderate post embeds * Memoize * Use ScreenHider on lists * Hide both list types * Fix crash on iOS in screen hider, fix lineheight * Memoize renderItem * Reuse objects to prevent re-renders --- src/components/moderation/ScreenHider.tsx | 21 ++- src/screens/StarterPack/Wizard/StepFeeds.tsx | 4 +- src/state/queries/feed.ts | 53 +++++--- src/state/queries/profile-feedgens.ts | 22 +++- src/state/queries/profile-lists.ts | 37 ++++-- src/view/com/feeds/ProfileFeedgens.tsx | 83 ++++++------ src/view/com/lists/ProfileLists.tsx | 9 +- src/view/com/util/post-embeds/index.tsx | 50 ++++++-- src/view/screens/ProfileList.tsx | 127 ++++++++++++------- 9 files changed, 261 insertions(+), 145 deletions(-) diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx index 0d316bc885..f855d63331 100644 --- a/src/components/moderation/ScreenHider.tsx +++ b/src/components/moderation/ScreenHider.tsx @@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {NavigationProp} from 'lib/routes/types' import {CenteredView} from '#/view/com/util/Views' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import { ModerationDetailsDialog, @@ -105,6 +105,7 @@ export function ScreenHider({ a.mb_md, a.px_lg, a.text_center, + a.leading_snug, t.atoms.text_contrast_medium, ]}> {isNoPwi ? ( @@ -113,8 +114,15 @@ export function ScreenHider({ ) : ( <> - This {screenDescription} has been flagged: - + This {screenDescription} has been flagged:{' '} + {desc.name}.{' '} Learn More - )}{' '} diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx index de8d856aba..f047b612ae 100644 --- a/src/screens/StarterPack/Wizard/StepFeeds.tsx +++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx @@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y' import {DISCOVER_FEED_URI} from 'lib/constants' import { useGetPopularFeedsQuery, + usePopularFeedsSearch, useSavedFeeds, - useSearchPopularFeedsQuery, } from 'state/queries/feed' import {SearchInput} from 'view/com/util/forms/SearchInput' import {List} from 'view/com/util/List' @@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) { : undefined const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} = - useSearchPopularFeedsQuery({q: throttledQuery}) + usePopularFeedsSearch({query: throttledQuery}) const isLoading = !isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 36555c1813..2b6751e890 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -5,6 +5,7 @@ import { AppBskyGraphDefs, AppBskyUnspeccedGetPopularFeedGenerators, AtUri, + moderateFeedGenerator, RichText, } from '@atproto/api' import { @@ -26,6 +27,7 @@ import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' import {router} from '#/routes' +import {useModerationOpts} from '../preferences/moderation-opts' import {FeedDescriptor} from './post-feed' import {precacheResolvedUri} from './resolve-uri' @@ -207,14 +209,16 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { const limit = options?.limit || 10 const {data: preferences} = usePreferencesQuery() const queryClient = useQueryClient() + const moderationOpts = useModerationOpts() // Make sure this doesn't invalidate unless really needed. const selectArgs = useMemo( () => ({ hasSession, savedFeeds: preferences?.savedFeeds || [], + moderationOpts, }), - [hasSession, preferences?.savedFeeds], + [hasSession, preferences?.savedFeeds, moderationOpts], ) const lastPageCountRef = useRef(0) @@ -225,6 +229,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { QueryKey, string | undefined >({ + enabled: Boolean(moderationOpts), queryKey: createGetPopularFeedsQueryKey(options), queryFn: async ({pageParam}) => { const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ @@ -246,7 +251,11 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { ( data: InfiniteData, ) => { - const {savedFeeds, hasSession: hasSessionInner} = selectArgs + const { + savedFeeds, + hasSession: hasSessionInner, + moderationOpts, + } = selectArgs return { ...data, pages: data.pages.map(page => { @@ -264,7 +273,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { return f.value === feed.uri }), ) - return !alreadySaved + const decision = moderateFeedGenerator(feed, moderationOpts!) + return !alreadySaved && !decision.ui('contentList').filter }), } }), @@ -304,6 +314,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { export function useSearchPopularFeedsMutation() { const agent = useAgent() + const moderationOpts = useModerationOpts() + return useMutation({ mutationFn: async (query: string) => { const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ @@ -311,24 +323,15 @@ export function useSearchPopularFeedsMutation() { query: query, }) - return res.data.feeds - }, - }) -} - -export function useSearchPopularFeedsQuery({q}: {q: string}) { - const agent = useAgent() - return useQuery({ - queryKey: ['searchPopularFeeds', q], - queryFn: async () => { - const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: 15, - query: q, - }) + if (moderationOpts) { + return res.data.feeds.filter(feed => { + const decision = moderateFeedGenerator(feed, moderationOpts) + return !decision.ui('contentList').filter + }) + } return res.data.feeds }, - placeholderData: keepPreviousData, }) } @@ -346,17 +349,27 @@ export function usePopularFeedsSearch({ enabled?: boolean }) { const agent = useAgent() + const moderationOpts = useModerationOpts() + const enabledInner = enabled ?? Boolean(moderationOpts) + return useQuery({ - enabled, + enabled: enabledInner, queryKey: createPopularFeedsSearchQueryKey(query), queryFn: async () => { const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: 10, + limit: 15, query: query, }) return res.data.feeds }, + placeholderData: keepPreviousData, + select(data) { + return data.filter(feed => { + const decision = moderateFeedGenerator(feed, moderationOpts!) + return !decision.ui('contentList').filter + }) + }, }) } diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index 8ad12ab611..b50a2a2890 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -1,7 +1,8 @@ -import {AppBskyFeedGetActorFeeds} from '@atproto/api' +import {AppBskyFeedGetActorFeeds, moderateFeedGenerator} from '@atproto/api' import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useModerationOpts} from '../preferences/moderation-opts' const PAGE_SIZE = 50 type RQPageParam = string | undefined @@ -14,7 +15,8 @@ export function useProfileFeedgensQuery( did: string, opts?: {enabled?: boolean}, ) { - const enabled = opts?.enabled !== false + const moderationOpts = useModerationOpts() + const enabled = opts?.enabled !== false && Boolean(moderationOpts) const agent = useAgent() return useInfiniteQuery< AppBskyFeedGetActorFeeds.OutputSchema, @@ -38,5 +40,21 @@ export function useProfileFeedgensQuery( initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, enabled, + select(data) { + return { + ...data, + pages: data.pages.map(page => { + return { + ...page, + feeds: page.feeds + // filter by labels + .filter(list => { + const decision = moderateFeedGenerator(list, moderationOpts!) + return !decision.ui('contentList').filter + }), + } + }), + } + }, }) } diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 112a62c839..75e3dd6e48 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -1,7 +1,8 @@ -import {AppBskyGraphGetLists} from '@atproto/api' +import {AppBskyGraphGetLists, moderateUserList} from '@atproto/api' import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useModerationOpts} from '../preferences/moderation-opts' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -10,7 +11,8 @@ const RQKEY_ROOT = 'profile-lists' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { - const enabled = opts?.enabled !== false + const moderationOpts = useModerationOpts() + const enabled = opts?.enabled !== false && Boolean(moderationOpts) const agent = useAgent() return useInfiniteQuery< AppBskyGraphGetLists.OutputSchema, @@ -27,17 +29,32 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { cursor: pageParam, }) - // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably - // just filter this out on the backend instead of in the client. - return { - ...res.data, - lists: res.data.lists.filter( - l => l.purpose !== 'app.bsky.graph.defs#referencelist', - ), - } + return res.data }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, enabled, + select(data) { + return { + ...data, + pages: data.pages.map(page => { + return { + ...page, + lists: page.lists + /* + * Starter packs use a reference list, which we do not want to + * show on profiles. At some point we could probably just filter + * this out on the backend instead of in the client. + */ + .filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist') + // filter by labels + .filter(list => { + const decision = moderateUserList(list, moderationOpts!) + return !decision.ui('contentList').filter + }), + } + }), + } + }, }) } diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 831ab4d1dd..6f98cc49a4 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -129,46 +129,49 @@ export const ProfileFeedgens = React.forwardRef< // rendering // = - const renderItem = ({item, index}: ListRenderItemInfo) => { - if (item === EMPTY) { - return ( - - ) - } else if (item === ERROR_ITEM) { - return ( - - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - - ) - } else if (item === LOADING) { - return - } - if (preferences) { - return ( - - - - ) - } - return null - } + const renderItem = React.useCallback( + ({item, index}: ListRenderItemInfo) => { + if (item === EMPTY) { + return ( + + ) + } else if (item === ERROR_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING) { + return + } + if (preferences) { + return ( + + + + ) + } + return null + }, + [_, t, error, refetch, onPressRetryLoadMore, preferences], + ) React.useEffect(() => { if (enabled && scrollElRef.current) { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index dc385d4361..f633774c7a 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -75,12 +75,7 @@ export const ProfileLists = React.forwardRef( items = items.concat([EMPTY]) } else if (data?.pages) { for (const page of data?.pages) { - items = items.concat( - page.lists.map(l => ({ - ...l, - _reactKey: l.uri, - })), - ) + items = items.concat(page.lists) } } if (isError && !isEmpty) { @@ -192,7 +187,7 @@ export const ProfileLists = React.forwardRef( testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={items} - keyExtractor={(item: any) => item._reactKey} + keyExtractor={(item: any) => item._reactKey || item.uri} renderItem={renderItemInner} refreshing={isPTRing} onRefresh={onRefresh} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index a0dc94e4d8..0462212fbd 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -15,11 +15,14 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyGraphDefs, + moderateFeedGenerator, + moderateUserList, ModerationDecision, } from '@atproto/api' import {ImagesLightbox, useLightboxControls} from '#/state/lightbox' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {atoms as a} from '#/alf' @@ -51,7 +54,6 @@ export function PostEmbeds({ style?: StyleProp allowNestedQuotes?: boolean }) { - const pal = usePalette('default') const {openLightbox} = useLightboxControls() const largeAltBadge = useLargeAltBadgeEnabled() @@ -72,22 +74,13 @@ export function PostEmbeds({ if (AppBskyEmbedRecord.isView(embed)) { // custom feed embed (i.e. generator view) - // = if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - // TODO moderation - return ( - - ) + return } // list embed if (AppBskyGraphDefs.isListView(embed.record)) { - // TODO moderation - return + return } if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { @@ -185,6 +178,39 @@ export function PostEmbeds({ return } +function MaybeFeedCard({view}: {view: AppBskyFeedDefs.GeneratorView}) { + const pal = usePalette('default') + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderateFeedGenerator(view, moderationOpts) + : undefined + }, [view, moderationOpts]) + + return ( + + + + ) +} + +function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts ? moderateUserList(view, moderationOpts) : undefined + }, [view, moderationOpts]) + + return ( + + + + ) +} + const styles = StyleSheet.create({ container: { marginTop: 8, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 0ed44758d4..bf13791ae6 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -1,6 +1,12 @@ import React, {useCallback, useMemo} from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' +import { + AppBskyGraphDefs, + AtUri, + moderateUserList, + ModerationOpts, + RichText as RichTextAPI, +} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -14,6 +20,7 @@ import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useModalControls} from '#/state/modals' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import { useListBlockMutation, useListDeleteMutation, @@ -62,6 +69,7 @@ import * as Toast from 'view/com/util/Toast' import {CenteredView} from 'view/com/util/Views' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' +import {ScreenHider} from '#/components/moderation/ScreenHider' import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' @@ -81,6 +89,7 @@ export function ProfileListScreen(props: Props) { AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), ) const {data: list, error: listError} = useListQuery(resolvedUri?.uri) + const moderationOpts = useModerationOpts() if (resolveError) { return ( @@ -101,8 +110,13 @@ export function ProfileListScreen(props: Props) { ) } - return resolvedUri && list ? ( - + return resolvedUri && list && moderationOpts ? ( + ) : ( ) @@ -112,7 +126,12 @@ function ProfileListScreenLoaded({ route, uri, list, -}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + moderationOpts, +}: Props & { + uri: string + list: AppBskyGraphDefs.ListView + moderationOpts: ModerationOpts +}) { const {_} = useLingui() const queryClient = useQueryClient() const {openComposer} = useComposerControls() @@ -124,6 +143,10 @@ function ProfileListScreenLoaded({ const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' const isScreenFocused = useIsFocused() + const moderation = React.useMemo(() => { + return moderateUserList(list, moderationOpts) + }, [list, moderationOpts]) + useSetTitle(list.name) useFocusEffect( @@ -161,26 +184,65 @@ function ProfileListScreenLoaded({ if (isCurateList) { return ( + + + + {({headerHeight, scrollElRef, isFocused}) => ( + + )} + {({headerHeight, scrollElRef}) => ( + + )} + + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + + ) + } + return ( + - {({headerHeight, scrollElRef, isFocused}) => ( - - )} + renderHeader={renderHeader}> {({headerHeight, scrollElRef}) => ( @@ -201,34 +263,7 @@ function ProfileListScreenLoaded({ accessibilityHint="" /> - ) - } - return ( - - - {({headerHeight, scrollElRef}) => ( - - )} - - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - + ) } From 6298e6897fa8f4a0d296869777326cd43fb875a0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 3 Aug 2024 00:33:45 +0200 Subject: [PATCH 14/30] tweak list header (#4870) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/components/Lists.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index e706e101f5..beeb554763 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -122,8 +122,16 @@ export function ListHeaderDesktop({ if (!gtTablet) return null return ( - - {title} + + {title} {subtitle ? ( {subtitle} From fb278384c64f55e5037275a23f4bd7af91dc7274 Mon Sep 17 00:00:00 2001 From: bnewbold Date: Fri, 2 Aug 2024 15:57:50 -0700 Subject: [PATCH 15/30] bskyweb: optional basic auth password middleware (#4759) --- bskyweb/cmd/bskyweb/main.go | 13 ++++++++++--- bskyweb/cmd/bskyweb/server.go | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 908486aa7e..d9235afdee 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -41,10 +41,10 @@ func run(args []string) { EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, }, &cli.StringFlag{ - Name: "ogcard-host", - Usage: "scheme, hostname, and port of ogcard service", + Name: "ogcard-host", + Usage: "scheme, hostname, and port of ogcard service", Required: false, - EnvVars: []string{"OGCARD_HOST"}, + EnvVars: []string{"OGCARD_HOST"}, }, &cli.StringFlag{ Name: "http-address", @@ -67,6 +67,13 @@ func run(args []string) { Required: false, EnvVars: []string{"DEBUG"}, }, + &cli.StringFlag{ + Name: "basic-auth-password", + Usage: "optional password to restrict access to web interface", + Required: false, + Value: "", + EnvVars: []string{"BASIC_AUTH_PASSWORD"}, + }, }, }, } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 8da291fe56..fdef01ce78 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "errors" "fmt" "io/fs" @@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error { appviewHost := cctx.String("appview-host") ogcardHost := cctx.String("ogcard-host") linkHost := cctx.String("link-host") + basicAuthPassword := cctx.String("basic-auth-password") // Echo e := echo.New() @@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error { }, })) + // optional password gating of entire web interface + if basicAuthPassword != "" { + e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { + // Be careful to use constant time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 && + subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 { + return true, nil + } + return false, nil + })) + } + // redirect trailing slash to non-trailing slash. // all of our current endpoints have no trailing slash. e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ From 18b423396b75d8b4348a434412d0da1f38230717 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 5 Aug 2024 12:21:34 -0700 Subject: [PATCH 16/30] Add `PlatformInfo` module (#4877) --- jest/jestSetup.js | 10 +++++++ .../platforminfo/ExpoPlatformInfoModule.kt | 24 ++++++++++++++++ .../expo-module.config.json | 5 ++-- modules/expo-bluesky-swiss-army/index.ts | 3 +- .../PlatformInfo/ExpoPlatformInfoModule.swift | 11 ++++++++ .../src/PlatformInfo/index.native.ts | 7 +++++ .../src/PlatformInfo/index.ts | 5 ++++ .../src/PlatformInfo/index.web.ts | 6 ++++ patches/react-native-reanimated+3.11.0.patch | 28 ------------------- src/platform/detection.ts | 3 -- src/state/a11y.tsx | 4 +-- src/state/persisted/schema.ts | 5 ++-- src/view/screens/Storybook/Dialogs.tsx | 19 +++++++++++++ 13 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt create mode 100644 modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a6b7c24f69..ac175900ed 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -95,3 +95,13 @@ jest.mock('expo-application', () => ({ nativeApplicationVersion: '1.0.0', nativeBuildVersion: '1', })) + +jest.mock('expo-modules-core', () => ({ + requireNativeModule: jest.fn().mockImplementation(moduleName => { + if (moduleName === 'ExpoPlatformInfo') { + return { + getIsReducedMotionEnabled: () => false, + } + } + }), +})) diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt new file mode 100644 index 0000000000..189796f817 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt @@ -0,0 +1,24 @@ +package expo.modules.blueskyswissarmy.platforminfo + +import android.provider.Settings +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoPlatformInfoModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoPlatformInfo") + + // See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242 + Function("getIsReducedMotionEnabled") { + val resolver = appContext.reactContext?.contentResolver ?: return@Function false + val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false + + try { + return@Function scale.toFloat() == 0f + } catch (_: Error) { + return@Function false + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 1111f8a0be..adb535e7f9 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -1,12 +1,13 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"] + "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"] }, "android": { "modules": [ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", - "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 89cea00a28..f62596cb70 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,4 +1,5 @@ +import * as PlatformInfo from './src/PlatformInfo' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' -export {Referrer, SharedPrefs} +export {PlatformInfo, Referrer, SharedPrefs} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift new file mode 100644 index 0000000000..4a1e6d7e7d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -0,0 +1,11 @@ +import ExpoModulesCore + +public class ExpoPlatformInfoModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoPlatformInfo") + + Function("getIsReducedMotionEnabled") { + return UIAccessibility.isReduceMotionEnabled + } + } +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts new file mode 100644 index 0000000000..e05f173d64 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -0,0 +1,7 @@ +import {requireNativeModule} from 'expo-modules-core' + +const NativeModule = requireNativeModule('ExpoPlatformInfo') + +export function getIsReducedMotionEnabled(): boolean { + return NativeModule.getIsReducedMotionEnabled() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts new file mode 100644 index 0000000000..9b9b7fc0c7 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -0,0 +1,5 @@ +import {NotImplementedError} from '../NotImplemented' + +export function getIsReducedMotionEnabled(): boolean { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts new file mode 100644 index 0000000000..c7ae6b7cd4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -0,0 +1,6 @@ +export function getIsReducedMotionEnabled(): boolean { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} diff --git a/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch index 9147cf08ef..a79a0ac085 100644 --- a/patches/react-native-reanimated+3.11.0.patch +++ b/patches/react-native-reanimated+3.11.0.patch @@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644 const { layout, entering, exiting, sharedTransitionTag } = this.props; if ( -diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -index ac9be5d..86d4605 100644 ---- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig'; - export { PerformanceMonitor } from './component/PerformanceMonitor'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; - //# sourceMappingURL=index.js.map -diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -index f01dc57..161ef22 100644 ---- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition'; - export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; -diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts -index 5885fa1..a3c693f 100644 ---- a/node_modules/react-native-reanimated/src/reanimated2/index.ts -+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts -@@ -284,3 +284,4 @@ export type { - GoBackGesture, - ScreenTransitionConfig, - } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; diff --git a/src/platform/detection.ts b/src/platform/detection.ts index 0c0360a82a..f00df0ee4e 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -1,5 +1,4 @@ import {Platform} from 'react-native' -import {isReducedMotion} from 'react-native-reanimated' import {getLocales} from 'expo-localization' import {fixLegacyLanguageCode} from '#/locale/helpers' @@ -21,5 +20,3 @@ export const deviceLocales = dedupArray( .map?.(locale => fixLegacyLanguageCode(locale.languageCode)) .filter(code => typeof code === 'string'), ) as string[] - -export const prefersReducedMotion = isReducedMotion() diff --git a/src/state/a11y.tsx b/src/state/a11y.tsx index aefcfd1ec4..08948267c0 100644 --- a/src/state/a11y.tsx +++ b/src/state/a11y.tsx @@ -1,8 +1,8 @@ import React from 'react' import {AccessibilityInfo} from 'react-native' -import {isReducedMotion} from 'react-native-reanimated' import {isWeb} from '#/platform/detection' +import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army' const Context = React.createContext({ reduceMotionEnabled: false, @@ -15,7 +15,7 @@ export function useA11y() { export function Provider({children}: React.PropsWithChildren<{}>) { const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() => - isReducedMotion(), + PlatformInfo.getIsReducedMotionEnabled(), ) const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 88fc370a6f..399a7e7932 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,6 +1,7 @@ import {z} from 'zod' -import {deviceLocales, prefersReducedMotion} from '#/platform/detection' +import {deviceLocales} from '#/platform/detection' +import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' const externalEmbedOptions = ['show', 'hide'] as const @@ -128,7 +129,7 @@ export const defaults: Schema = { lastSelectedHomeFeed: undefined, pdsAddressHistory: [], disableHaptics: false, - disableAutoplay: prefersReducedMotion, + disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, } diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index ca2420fed0..3a9f67de81 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -9,6 +9,7 @@ import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as Prompt from '#/components/Prompt' import {H3, P, Text} from '#/components/Typography' +import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' export function Dialogs() { const scrollable = Dialog.useDialogControl() @@ -17,6 +18,8 @@ export function Dialogs() { const testDialog = Dialog.useDialogControl() const {closeAllDialogs} = useDialogStateControlContext() const unmountTestDialog = Dialog.useDialogControl() + const [reducedMotionEnabled, setReducedMotionEnabled] = + React.useState() const [shouldRenderUnmountTest, setShouldRenderUnmountTest] = React.useState(false) const unmountTestInterval = React.useRef() @@ -147,6 +150,22 @@ export function Dialogs() { Open Shared Prefs Tester + + This is a prompt From 74b0318d89b5ec4746cd4861f8573ea24c6ccea1 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 5 Aug 2024 20:51:41 +0100 Subject: [PATCH 17/30] Show replies in context of their threads (#4871) * Don't reconstruct threads from separate posts * Remove post-level dedupe for now * Change repost dedupe condition to look just at length * Delete unused isThread * Delete another isThread field It is now meaningless because there's nothing special about author threads. * Narrow down slice item shape so it does not need reply * Consolidate slice validation criteria in one place * Show replies in context * Make fallback marker work * Remove misleading and now-unused property It was called rootUri but it was actually the leaf URI. Regardless, it's not used anymore. * Add by-thread dedupe to non-author feeds * Add post-level dedupe * Always count from the start This is easier to think about. * Only tuner state need to be untouched on dry run * Account for threads in reply filtering * Remove repost deduping This is already being taken care of by item-level deduping. It's also now wrong and removing too much (since it wasn't filtering for reposts directly). * Calculate rootUri correctly * Apply Following settings to all lists * Don't dedupe intentional reposts by thread * Show reply parent when ambiguous * Explicitly remove orphaned replies from following/lists * Fix thread dedupe to work across pages * Mark grandparent-blocked as orphaned * Guard tuner state change by dryRun * Remove dead code * Don't dedupe feedgen threads * Revert "Apply Following settings to all lists" This reverts commit aff86be6d37b60cc5d0ac38f22c31a4808342cf4. Let's not do this yet and have a bit more discussion. This is a chunky change already. * Reason belongs to a slice, not item * Logically feedContext belongs to the slice * Update comment to reflect latest behavior --- src/lib/api/feed-manip.ts | 424 ++++++++++++++------------ src/lib/api/feed/merge.ts | 12 - src/state/feed-feedback.tsx | 2 +- src/state/preferences/feed-tuners.tsx | 19 +- src/state/queries/post-feed.ts | 78 ++--- src/view/com/posts/Feed.tsx | 3 +- src/view/com/posts/FeedItem.tsx | 8 +- src/view/com/posts/FeedSlice.tsx | 33 +- 8 files changed, 279 insertions(+), 300 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 7ddb79434a..b8fc586ec4 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -1,4 +1,5 @@ import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, @@ -6,50 +7,118 @@ import { } from '@atproto/api' import {isPostInLanguage} from '../../locale/helpers' +import {FALLBACK_MARKER_POST} from './feed/home' import {ReasonFeedSource} from './feed/types' + type FeedViewPost = AppBskyFeedDefs.FeedViewPost export type FeedTunerFn = ( tuner: FeedTuner, slices: FeedViewPostsSlice[], + dryRun: boolean, ) => FeedViewPostsSlice[] type FeedSliceItem = { post: AppBskyFeedDefs.PostView - reply?: AppBskyFeedDefs.ReplyRef -} - -function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem { - return { - post: feedViewPost.post, - reply: feedViewPost.reply, - } + record: AppBskyFeedPost.Record + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + isParentBlocked: boolean } export class FeedViewPostsSlice { _reactKey: string _feedPost: FeedViewPost items: FeedSliceItem[] + isIncompleteThread: boolean + isFallbackMarker: boolean + isOrphan: boolean + rootUri: string constructor(feedPost: FeedViewPost) { + const {post, reply, reason} = feedPost + this.items = [] + this.isIncompleteThread = false + this.isFallbackMarker = false + this.isOrphan = false + if (AppBskyFeedDefs.isPostView(reply?.root)) { + this.rootUri = reply.root.uri + } else { + this.rootUri = post.uri + } this._feedPost = feedPost - this._reactKey = `slice-${feedPost.post.uri}-${ - feedPost.reason?.indexedAt || feedPost.post.indexedAt + this._reactKey = `slice-${post.uri}-${ + feedPost.reason?.indexedAt || post.indexedAt }` - this.items = [toSliceItem(feedPost)] - } - - get uri() { - return this._feedPost.post.uri - } - - get isThread() { - return ( - this.items.length > 1 && - this.items.every( - item => item.post.author.did === this.items[0].post.author.did, - ) + if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) { + this.isFallbackMarker = true + return + } + if ( + !AppBskyFeedPost.isRecord(post.record) || + !AppBskyFeedPost.validateRecord(post.record).success + ) { + return + } + const parent = reply?.parent + const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent) + let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + if (AppBskyFeedDefs.isPostView(parent)) { + parentAuthor = parent.author + } + this.items.push({ + post, + record: post.record, + parentAuthor, + isParentBlocked, + }) + if (!reply || reason) { + return + } + if ( + !AppBskyFeedDefs.isPostView(parent) || + !AppBskyFeedPost.isRecord(parent.record) || + !AppBskyFeedPost.validateRecord(parent.record).success + ) { + this.isOrphan = true + return + } + const grandparentAuthor = reply.grandparentAuthor + const isGrandparentBlocked = Boolean( + grandparentAuthor?.viewer?.blockedBy || + grandparentAuthor?.viewer?.blocking || + grandparentAuthor?.viewer?.blockingByList, ) + this.items.unshift({ + post: parent, + record: parent.record, + parentAuthor: grandparentAuthor, + isParentBlocked: isGrandparentBlocked, + }) + if (isGrandparentBlocked) { + this.isOrphan = true + // Keep going, it might still have a root. + } + const root = reply.root + if ( + !AppBskyFeedDefs.isPostView(root) || + !AppBskyFeedPost.isRecord(root.record) || + !AppBskyFeedPost.validateRecord(root.record).success + ) { + this.isOrphan = true + return + } + if (root.uri === parent.uri) { + return + } + this.items.unshift({ + post: root, + record: root.record, + isParentBlocked: false, + parentAuthor: undefined, + }) + if (parent.record.reply?.parent.uri !== root.uri) { + this.isIncompleteThread = true + } } get isQuotePost() { @@ -90,30 +159,7 @@ export class FeedViewPostsSlice { return !!this.items.find(item => item.post.uri === uri) } - isNextInThread(uri: string) { - return this.items[this.items.length - 1].post.uri === uri - } - - insert(item: FeedViewPost) { - const selfReplyUri = getSelfReplyUri(item) - const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri) - if (i !== -1) { - this.items.splice(i + 1, 0, item) - } else { - this.items.push(item) - } - } - - flattenReplyParent() { - if (this.items[0].reply) { - const reply = this.items[0].reply - if (AppBskyFeedDefs.isPostView(reply.parent)) { - this.items.splice(0, 0, {post: reply.parent}) - } - } - } - - isFollowingAllAuthors(userDid: string) { + getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] { const feedPost = this._feedPost const authors = [feedPost.post.author] if (feedPost.reply) { @@ -127,167 +173,149 @@ export class FeedViewPostsSlice { authors.push(feedPost.reply.root.author) } } - return authors.every(a => a.did === userDid || a.viewer?.following) + return authors } } export class FeedTuner { seenKeys: Set = new Set() seenUris: Set = new Set() + seenRootUris: Set = new Set() constructor(public tunerFns: FeedTunerFn[]) {} - reset() { - this.seenKeys.clear() - this.seenUris.clear() - } - tune( feed: FeedViewPost[], - {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { + {dryRun}: {dryRun: boolean} = { dryRun: false, - maintainOrder: false, }, ): FeedViewPostsSlice[] { - let slices: FeedViewPostsSlice[] = [] + let slices: FeedViewPostsSlice[] = feed + .map(item => new FeedViewPostsSlice(item)) + .filter(s => s.items.length > 0 || s.isFallbackMarker) - // remove posts that are replies, but which don't have the parent - // hydrated. this means the parent was either deleted or blocked - feed = feed.filter(item => { - if ( - AppBskyFeedPost.isRecord(item.post.record) && - item.post.record.reply && - !item.reply - ) { + // run the custom tuners + for (const tunerFn of this.tunerFns) { + slices = tunerFn(this, slices.slice(), dryRun) + } + + slices = slices.filter(slice => { + if (this.seenKeys.has(slice._reactKey)) { return false } + // Some feeds, like Following, dedupe by thread, so you only see the most recent reply. + // However, we don't want per-thread dedupe for author feeds (where we need to show every post) + // or for feedgens (where we want to let the feed serve multiple replies if it chooses to). + // To avoid showing the same context (root and/or parent) more than once, we do last resort + // per-post deduplication. It hides already seen posts as long as this doesn't break the thread. + for (let i = 0; i < slice.items.length; i++) { + const item = slice.items[i] + if (this.seenUris.has(item.post.uri)) { + if (i === 0) { + // Omit contiguous seen leading items. + // For example, [A -> B -> C], [A -> D -> E], [A -> D -> F] + // would turn into [A -> B -> C], [D -> E], [F]. + slice.items.splice(0, 1) + i-- + } + if (i === slice.items.length - 1) { + // If the last item in the slice was already seen, omit the whole slice. + // This means we'd miss its parents, but the user can "show more" to see them. + // For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C] + // would get collapsed into [A ... E -> F], with B/C/D considered seen. + return false + } + } else { + if (!dryRun) { + this.seenUris.add(item.post.uri) + } + } + } + if (!dryRun) { + this.seenKeys.add(slice._reactKey) + } return true }) - if (maintainOrder) { - slices = feed.map(item => new FeedViewPostsSlice(item)) - } else { - // arrange the posts into thread slices - for (let i = feed.length - 1; i >= 0; i--) { - const item = feed[i] - - const selfReplyUri = getSelfReplyUri(item) - if (selfReplyUri) { - const index = slices.findIndex(slice => - slice.isNextInThread(selfReplyUri), - ) - - if (index !== -1) { - const parent = slices[index] - - parent.insert(item) - - // If our slice isn't currently on the top, reinsert it to the top. - if (index !== 0) { - slices.splice(index, 1) - slices.unshift(parent) - } - - continue - } - } - - slices.unshift(new FeedViewPostsSlice(item)) - } - } - - // run the custom tuners - for (const tunerFn of this.tunerFns) { - slices = tunerFn(this, slices.slice()) - } - - // remove any items already "seen" - const soonToBeSeenUris: Set = new Set() - for (let i = slices.length - 1; i >= 0; i--) { - if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) { - slices.splice(i, 1) - } else { - for (const item of slices[i].items) { - soonToBeSeenUris.add(item.post.uri) - } - } - } - - // turn non-threads with reply parents into threads - for (const slice of slices) { - if (!slice.isThread && !slice.reason && slice.items[0].reply) { - const reply = slice.items[0].reply - if ( - AppBskyFeedDefs.isPostView(reply.parent) && - !this.seenUris.has(reply.parent.uri) && - !soonToBeSeenUris.has(reply.parent.uri) - ) { - const uri = reply.parent.uri - slice.flattenReplyParent() - soonToBeSeenUris.add(uri) - } - } - } - - if (!dryRun) { - slices = slices.filter(slice => { - if (this.seenKeys.has(slice._reactKey)) { - return false - } - for (const item of slice.items) { - this.seenUris.add(item.post.uri) - } - this.seenKeys.add(slice._reactKey) - return true - }) - } - return slices } - static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { - for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isReply) { - slices.splice(i, 1) - } - } - return slices - } - - static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { - for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isRepost) { - slices.splice(i, 1) - } - } - return slices - } - - static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { - for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isQuotePost) { - slices.splice(i, 1) - } - } - return slices - } - - static dedupReposts( + static removeReplies( tuner: FeedTuner, slices: FeedViewPostsSlice[], - ): FeedViewPostsSlice[] { - // remove duplicates caused by reposts + _dryRun: boolean, + ) { for (let i = 0; i < slices.length; i++) { - const item1 = slices[i] - for (let j = i + 1; j < slices.length; j++) { - const item2 = slices[j] - if (item2.isThread) { - // dont dedup items that are rendering in a thread as this can cause rendering errors - continue - } - if (item1.containsUri(item2.items[0].post.uri)) { - slices.splice(j, 1) - j-- + const slice = slices[i] + if ( + slice.isReply && + !slice.isRepost && + // This is not perfect but it's close as we can get to + // detecting threads without having to peek ahead. + !areSameAuthor(slice.getAllAuthors()) + ) { + slices.splice(i, 1) + i-- + } + } + return slices + } + + static removeReposts( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + _dryRun: boolean, + ) { + for (let i = 0; i < slices.length; i++) { + if (slices[i].isRepost) { + slices.splice(i, 1) + i-- + } + } + return slices + } + + static removeQuotePosts( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + _dryRun: boolean, + ) { + for (let i = 0; i < slices.length; i++) { + if (slices[i].isQuotePost) { + slices.splice(i, 1) + i-- + } + } + return slices + } + + static removeOrphans( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + _dryRun: boolean, + ) { + for (let i = 0; i < slices.length; i++) { + if (slices[i].isOrphan) { + slices.splice(i, 1) + i-- + } + } + return slices + } + + static dedupThreads( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + dryRun: boolean, + ): FeedViewPostsSlice[] { + for (let i = 0; i < slices.length; i++) { + const rootUri = slices[i].rootUri + if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) { + slices.splice(i, 1) + i-- + } else { + if (!dryRun) { + tuner.seenRootUris.add(rootUri) } } } @@ -298,15 +326,17 @@ export class FeedTuner { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], + _dryRun: boolean, ): FeedViewPostsSlice[] => { - for (let i = slices.length - 1; i >= 0; i--) { + for (let i = 0; i < slices.length; i++) { const slice = slices[i] if ( slice.isReply && !slice.isRepost && - !slice.isFollowingAllAuthors(userDid) + !isFollowingAll(slice.getAllAuthors(), userDid) ) { slices.splice(i, 1) + i-- } } return slices @@ -324,6 +354,7 @@ export class FeedTuner { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], + _dryRun: boolean, ): FeedViewPostsSlice[] => { const candidateSlices = slices.slice() @@ -332,7 +363,7 @@ export class FeedTuner { return slices } - for (let i = slices.length - 1; i >= 0; i--) { + for (let i = 0; i < slices.length; i++) { let hasPreferredLang = false for (const item of slices[i].items) { if (isPostInLanguage(item.post, preferredLangsCode2)) { @@ -358,16 +389,15 @@ export class FeedTuner { } } -function getSelfReplyUri(item: FeedViewPost): string | undefined { - if (item.reply) { - if ( - AppBskyFeedDefs.isPostView(item.reply.parent) && - !AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies - ) { - return item.reply.parent.author.did === item.post.author.did - ? item.reply.parent.uri - : undefined - } - } - return undefined +function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean { + const dids = authors.map(a => a.did) + const set = new Set(dids) + return set.size === 1 +} + +function isFollowingAll( + authors: AppBskyActorDefs.ProfileViewBasic[], + userDid: string, +): boolean { + return authors.every(a => a.did === userDid || a.viewer?.following) } diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 86db1b98fa..b41e82fb06 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -193,12 +193,6 @@ class MergeFeedSource { return this.hasMore && this.queue.length === 0 } - reset() { - this.cursor = undefined - this.queue = [] - this.hasMore = true - } - take(n: number): AppBskyFeedDefs.FeedViewPost[] { return this.queue.splice(0, n) } @@ -232,11 +226,6 @@ class MergeFeedSource { class MergeFeedSource_Following extends MergeFeedSource { tuner = new FeedTuner(this.feedTuners) - reset() { - super.reset() - this.tuner.reset() - } - async fetchNext(n: number) { return this._fetchNextInner(n) } @@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource { // run the tuner pre-emptively to ensure better mixing const slices = this.tuner.tune(res.data.feed, { dryRun: false, - maintainOrder: true, }) res.data.feed = slices.map(slice => slice._feedPost) return res diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 59b4bf78a4..aab2737e5a 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { toString({ item: postItem.uri, event: 'app.bsky.feed.defs#interactionSeen', - feedContext: postItem.feedContext, + feedContext: slice.feedContext, }), ) sendToFeed() diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index d816bde649..b6f14fae7b 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { } } if (feedDesc.startsWith('feedgen')) { - return [ - FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly(langPrefs.contentLanguages), - ] + return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)] } if (feedDesc.startsWith('list')) { - const feedTuners = [] - + let feedTuners = [] if (feedDesc.endsWith('|as_following')) { // Same as Following tuners below, copypaste for now. + feedTuners.push(FeedTuner.removeOrphans) if (preferences?.feedViewPrefs.hideReposts) { feedTuners.push(FeedTuner.removeReposts) - } else { - feedTuners.push(FeedTuner.dedupReposts) } if (preferences?.feedViewPrefs.hideReplies) { feedTuners.push(FeedTuner.removeReplies) @@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { if (preferences?.feedViewPrefs.hideQuotePosts) { feedTuners.push(FeedTuner.removeQuotePosts) } - } else { - feedTuners.push(FeedTuner.dedupReposts) + feedTuners.push(FeedTuner.dedupThreads) } return feedTuners } if (feedDesc === 'following') { - const feedTuners = [] + const feedTuners = [FeedTuner.removeOrphans] if (preferences?.feedViewPrefs.hideReposts) { feedTuners.push(FeedTuner.removeReposts) - } else { - feedTuners.push(FeedTuner.dedupReposts) } if (preferences?.feedViewPrefs.hideReplies) { feedTuners.push(FeedTuner.removeReplies) @@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { if (preferences?.feedViewPrefs.hideQuotePosts) { feedTuners.push(FeedTuner.removeQuotePosts) } + feedTuners.push(FeedTuner.dedupThreads) return feedTuners } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 65467e8023..724043e586 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -77,11 +77,6 @@ export interface FeedPostSliceItem { uri: string post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record - reason?: - | AppBskyFeedDefs.ReasonRepost - | ReasonFeedSource - | {[k: string]: unknown; $type: string} - feedContext: string | undefined moderation: ModerationDecision parentAuthor?: AppBskyActorDefs.ProfileViewBasic isParentBlocked?: boolean @@ -90,9 +85,14 @@ export interface FeedPostSliceItem { export interface FeedPostSlice { _isFeedPostSlice: boolean _reactKey: string - rootUri: string - isThread: boolean items: FeedPostSliceItem[] + isIncompleteThread: boolean + isFallbackMarker: boolean + feedContext: string | undefined + reason?: + | AppBskyFeedDefs.ReasonRepost + | ReasonFeedSource + | {[k: string]: unknown; $type: string} } export interface FeedPageUnselected { @@ -313,53 +313,22 @@ export function usePostFeedQuery( const feedPostSlice: FeedPostSlice = { _reactKey: slice._reactKey, _isFeedPostSlice: true, - rootUri: slice.uri, - isThread: - slice.items.length > 1 && - slice.items.every( - item => - item.post.author.did === - slice.items[0].post.author.did, - ), - items: slice.items - .map((item, i) => { - if ( - AppBskyFeedPost.isRecord(item.post.record) && - AppBskyFeedPost.validateRecord(item.post.record) - .success - ) { - 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, - ) - - const feedPostSliceItem: FeedPostSliceItem = { - _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, - uri: item.post.uri, - post: item.post, - record: item.post.record, - reason: slice.reason, - feedContext: slice.feedContext, - moderation: moderations[i], - parentAuthor, - isParentBlocked, - } - return feedPostSliceItem - } - return undefined - }) - .filter(n => !!n), + isIncompleteThread: slice.isIncompleteThread, + isFallbackMarker: slice.isFallbackMarker, + feedContext: slice.feedContext, + reason: slice.reason, + items: slice.items.map((item, i) => { + const feedPostSliceItem: FeedPostSliceItem = { + _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, + uri: item.post.uri, + post: item.post, + record: item.record, + moderation: moderations[i], + parentAuthor: item.parentAuthor, + isParentBlocked: item.isParentBlocked, + } + return feedPostSliceItem + }), } return feedPostSlice }) @@ -442,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) { if (post) { const slices = page.tuner.tune([post], { dryRun: true, - maintainOrder: true, }) if (slices[0]) { return true diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 7623ff37e3..46bf4a5fd4 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,7 +14,6 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' @@ -472,7 +471,7 @@ let Feed = ({ } else if (item.type === progressGuideInterstitialType) { return } else if (item.type === 'slice') { - if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { + if (item.slice.isFallbackMarker) { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 9ddc54a989..2c2e2163d7 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -345,11 +345,9 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {!isThreadChild && - showReplyTo && - (parentAuthor || isParentBlocked) && ( - - )} + {showReplyTo && (parentAuthor || isParentBlocked) && ( + + )} { - if (slice.isThread && slice.items.length > 3) { + if (slice.isIncompleteThread && slice.items.length >= 3) { const beforeLast = slice.items.length - 2 const last = slice.items.length - 1 return ( @@ -27,25 +27,28 @@ let FeedSlice = ({ key={slice.items[0]._reactKey} post={slice.items[0].post} record={slice.items[0].record} - reason={slice.items[0].reason} - feedContext={slice.items[0].feedContext} + reason={slice.reason} + feedContext={slice.feedContext} parentAuthor={slice.items[0].parentAuthor} - showReplyTo={true} + showReplyTo={false} moderation={slice.items[0].moderation} isThreadParent={isThreadParentAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)} hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} /> - + { - const urip = new AtUri(slice.rootUri) + const urip = new AtUri(uri) return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) - }, [slice.rootUri]) + }, [uri]) return ( From 5bf7f3769d005e7e606e4b10327eb7467f59f0aa Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 00:30:58 +0100 Subject: [PATCH 18/30] [Persisted] Fork web and native, make it synchronous on the web (#4872) * Delete logic for legacy storage * Delete superfluous tests At this point these tests aren't testing anything useful, let's just get rid of them. * Inline store.ts methods into persisted/index.ts * Fork persisted/index.ts into index.web.ts * Remove non-essential code and comments from both forks * Remove async/await from web fork of persisted/index.ts * Remove unused return * Enforce that forked types match --- src/state/persisted/__tests__/fixtures.ts | 67 ------- src/state/persisted/__tests__/index.test.ts | 49 ----- src/state/persisted/__tests__/migrate.test.ts | 93 ---------- src/state/persisted/__tests__/schema.test.ts | 21 --- src/state/persisted/index.ts | 108 ++++++----- src/state/persisted/index.web.ts | 126 +++++++++++++ src/state/persisted/legacy.ts | 167 ------------------ src/state/persisted/store.ts | 44 ----- src/state/persisted/types.ts | 9 + src/view/screens/Settings/index.tsx | 19 +- 10 files changed, 187 insertions(+), 516 deletions(-) delete mode 100644 src/state/persisted/__tests__/fixtures.ts delete mode 100644 src/state/persisted/__tests__/index.test.ts delete mode 100644 src/state/persisted/__tests__/migrate.test.ts delete mode 100644 src/state/persisted/__tests__/schema.test.ts create mode 100644 src/state/persisted/index.web.ts delete mode 100644 src/state/persisted/legacy.ts delete mode 100644 src/state/persisted/store.ts create mode 100644 src/state/persisted/types.ts diff --git a/src/state/persisted/__tests__/fixtures.ts b/src/state/persisted/__tests__/fixtures.ts deleted file mode 100644 index ac8f7c8d1d..0000000000 --- a/src/state/persisted/__tests__/fixtures.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type {LegacySchema} from '#/state/persisted/legacy' - -export const ALICE_DID = 'did:plc:ALICE_DID' -export const BOB_DID = 'did:plc:BOB_DID' - -export const LEGACY_DATA_DUMP: LegacySchema = { - session: { - data: { - service: 'https://bsky.social/', - did: ALICE_DID, - }, - accounts: [ - { - service: 'https://bsky.social', - did: ALICE_DID, - refreshJwt: 'refreshJwt', - accessJwt: 'accessJwt', - handle: 'alice.test', - email: 'alice@bsky.test', - displayName: 'Alice', - aviUrl: 'avi', - emailConfirmed: true, - }, - { - service: 'https://bsky.social', - did: BOB_DID, - refreshJwt: 'refreshJwt', - accessJwt: 'accessJwt', - handle: 'bob.test', - email: 'bob@bsky.test', - displayName: 'Bob', - aviUrl: 'avi', - emailConfirmed: true, - }, - ], - }, - me: { - did: ALICE_DID, - handle: 'alice.test', - displayName: 'Alice', - description: '', - avatar: 'avi', - }, - onboarding: {step: 'Home'}, - shell: {colorMode: 'system'}, - preferences: { - primaryLanguage: 'en', - contentLanguages: ['en'], - postLanguage: 'en', - postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'], - contentLabels: { - nsfw: 'warn', - nudity: 'warn', - suggestive: 'warn', - gore: 'warn', - hate: 'hide', - spam: 'hide', - impersonation: 'warn', - }, - savedFeeds: ['feed_a', 'feed_b', 'feed_c'], - pinnedFeeds: ['feed_a', 'feed_b'], - requireAltTextEnabled: false, - }, - invitedUsers: {seenDids: [], copiedInvites: []}, - mutedThreads: {uris: []}, - reminders: {}, -} diff --git a/src/state/persisted/__tests__/index.test.ts b/src/state/persisted/__tests__/index.test.ts deleted file mode 100644 index 90c5e0e4ec..0000000000 --- a/src/state/persisted/__tests__/index.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {jest, expect, test, afterEach} from '@jest/globals' -import AsyncStorage from '@react-native-async-storage/async-storage' - -import {defaults} from '#/state/persisted/schema' -import {migrate} from '#/state/persisted/legacy' -import * as store from '#/state/persisted/store' -import * as persisted from '#/state/persisted' - -const write = jest.mocked(store.write) -const read = jest.mocked(store.read) - -jest.mock('#/logger') -jest.mock('#/state/persisted/legacy', () => ({ - migrate: jest.fn(), -})) -jest.mock('#/state/persisted/store', () => ({ - write: jest.fn(), - read: jest.fn(), -})) - -afterEach(() => { - jest.useFakeTimers() - jest.clearAllMocks() - AsyncStorage.clear() -}) - -test('init: fresh install, no migration', async () => { - await persisted.init() - - expect(migrate).toHaveBeenCalledTimes(1) - expect(read).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(defaults) - - // default value - expect(persisted.get('colorMode')).toBe('system') -}) - -test('init: fresh install, migration ran', async () => { - read.mockResolvedValueOnce(defaults) - - await persisted.init() - - expect(migrate).toHaveBeenCalledTimes(1) - expect(read).toHaveBeenCalledTimes(1) - expect(write).not.toHaveBeenCalled() - - // default value - expect(persisted.get('colorMode')).toBe('system') -}) diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts deleted file mode 100644 index 97767e2732..0000000000 --- a/src/state/persisted/__tests__/migrate.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {jest, expect, test, afterEach} from '@jest/globals' -import AsyncStorage from '@react-native-async-storage/async-storage' - -import {defaults, schema} from '#/state/persisted/schema' -import {transform, migrate} from '#/state/persisted/legacy' -import * as store from '#/state/persisted/store' -import {logger} from '#/logger' -import * as fixtures from '#/state/persisted/__tests__/fixtures' - -const write = jest.mocked(store.write) -const read = jest.mocked(store.read) - -jest.mock('#/logger') -jest.mock('#/state/persisted/store', () => ({ - write: jest.fn(), - read: jest.fn(), -})) - -afterEach(() => { - jest.clearAllMocks() - AsyncStorage.clear() -}) - -test('migrate: fresh install', async () => { - await migrate() - - expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') - expect(read).toHaveBeenCalledTimes(1) - expect(logger.debug).toHaveBeenCalledWith( - 'persisted state: no migration needed', - ) -}) - -test('migrate: fresh install, existing new storage', async () => { - read.mockResolvedValueOnce(defaults) - - await migrate() - - expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') - expect(read).toHaveBeenCalledTimes(1) - expect(logger.debug).toHaveBeenCalledWith( - 'persisted state: no migration needed', - ) -}) - -test('migrate: fresh install, AsyncStorage error', async () => { - const prevGetItem = AsyncStorage.getItem - - const error = new Error('test error') - - AsyncStorage.getItem = jest.fn(() => { - throw error - }) - - await migrate() - - expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') - expect(logger.error).toHaveBeenCalledWith(error, { - message: 'persisted state: error migrating legacy storage', - }) - - AsyncStorage.getItem = prevGetItem -}) - -test('migrate: has legacy data', async () => { - await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP)) - - await migrate() - - expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) - expect(logger.debug).toHaveBeenCalledWith( - 'persisted state: migrated legacy storage', - ) -}) - -test('migrate: has legacy data, fails validation', async () => { - const legacy = fixtures.LEGACY_DATA_DUMP - // @ts-ignore - legacy.shell.colorMode = 'invalid' - await AsyncStorage.setItem('root', JSON.stringify(legacy)) - - await migrate() - - const transformed = transform(legacy) - const validate = schema.safeParse(transformed) - - expect(write).not.toHaveBeenCalled() - expect(logger.error).toHaveBeenCalledWith( - 'persisted state: legacy data failed validation', - // @ts-ignore - {message: validate.error}, - ) -}) diff --git a/src/state/persisted/__tests__/schema.test.ts b/src/state/persisted/__tests__/schema.test.ts deleted file mode 100644 index c78a2c27cb..0000000000 --- a/src/state/persisted/__tests__/schema.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {expect, test} from '@jest/globals' - -import {transform} from '#/state/persisted/legacy' -import {defaults, schema} from '#/state/persisted/schema' -import * as fixtures from '#/state/persisted/__tests__/fixtures' - -test('defaults', () => { - expect(() => schema.parse(defaults)).not.toThrow() -}) - -test('transform', () => { - const data = transform({}) - expect(() => schema.parse(data)).not.toThrow() -}) - -test('transform: legacy fixture', () => { - const data = transform(fixtures.LEGACY_DATA_DUMP) - expect(() => schema.parse(data)).not.toThrow() - expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID) - expect(data.session.accounts.length).toEqual(2) -}) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 5fe0f9bd0a..639e4e47f5 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,49 +1,35 @@ -import EventEmitter from 'eventemitter3' +import AsyncStorage from '@react-native-async-storage/async-storage' -import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {migrate} from '#/state/persisted/legacy' -import {defaults, Schema} from '#/state/persisted/schema' -import * as store from '#/state/persisted/store' +import {defaults, Schema, schema} from '#/state/persisted/schema' +import {PersistedApi} from './types' + export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' -const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') -const UPDATE_EVENT = 'BSKY_UPDATE' +const BSKY_STORAGE = 'BSKY_STORAGE' let _state: Schema = defaults -const _emitter = new EventEmitter() -/** - * Initializes and returns persisted data state, so that it can be passed to - * the Provider. - */ export async function init() { - logger.debug('persisted state: initializing') - - broadcast.onmessage = onBroadcastMessage - try { - await migrate() // migrate old store - const stored = await store.read() // check for new store + const stored = await readFromStorage() if (!stored) { - logger.debug('persisted state: initializing default storage') - await store.write(defaults) // opt: init new store + await writeToStorage(defaults) } - _state = stored || defaults // return new store - logger.debug('persisted state: initialized') + _state = stored || defaults } catch (e) { logger.error('persisted state: failed to load root state from storage', { message: e, }) - // AsyncStorage failure, but we can still continue in memory - return defaults } } +init satisfies PersistedApi['init'] export function get(key: K): Schema[K] { return _state[key] } +get satisfies PersistedApi['get'] export async function write( key: K, @@ -51,47 +37,55 @@ export async function write( ): Promise { try { _state[key] = value - await store.write(_state) - // must happen on next tick, otherwise the tab will read stale storage data - setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) - logger.debug(`persisted state: wrote root state to storage`, { - updatedKey: key, - }) + await writeToStorage(_state) } catch (e) { logger.error(`persisted state: failed writing root state to storage`, { message: e, }) } } +write satisfies PersistedApi['write'] -export function onUpdate(cb: () => void): () => void { - _emitter.addListener('update', cb) - return () => _emitter.removeListener('update', cb) +export function onUpdate(_cb: () => void): () => void { + return () => {} } +onUpdate satisfies PersistedApi['onUpdate'] -async function onBroadcastMessage({data}: MessageEvent) { - // validate event - if (typeof data === 'object' && data.event === UPDATE_EVENT) { - try { - // read next state, possibly updated by another tab - const next = await store.read() - - if (next) { - logger.debug(`persisted state: handling update from broadcast channel`) - _state = next - _emitter.emit('update') - } else { - logger.error( - `persisted state: handled update update from broadcast channel, but found no data`, - ) - } - } catch (e) { - logger.error( - `persisted state: failed handling update from broadcast channel`, - { - message: e, - }, - ) - } +export async function clearStorage() { + try { + await AsyncStorage.removeItem(BSKY_STORAGE) + } catch (e: any) { + logger.error(`persisted store: failed to clear`, {message: e.toString()}) + } +} +clearStorage satisfies PersistedApi['clearStorage'] + +async function writeToStorage(value: Schema) { + schema.parse(value) + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +async function readFromStorage(): Promise { + const rawData = await AsyncStorage.getItem(BSKY_STORAGE) + const objData = rawData ? JSON.parse(rawData) : undefined + + // new user + if (!objData) return undefined + + // existing user, validate + const parsed = schema.safeParse(objData) + + if (parsed.success) { + return objData + } else { + const errors = + parsed.error?.errors?.map(e => ({ + code: e.code, + // @ts-ignore exists on some types + expected: e?.expected, + path: e.path?.join('.'), + })) || [] + logger.error(`persisted store: data failed validation on read`, {errors}) + return undefined } } diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts new file mode 100644 index 0000000000..50f28b6b8a --- /dev/null +++ b/src/state/persisted/index.web.ts @@ -0,0 +1,126 @@ +import EventEmitter from 'eventemitter3' + +import BroadcastChannel from '#/lib/broadcast' +import {logger} from '#/logger' +import {defaults, Schema, schema} from '#/state/persisted/schema' +import {PersistedApi} from './types' + +export type {PersistedAccount, Schema} from '#/state/persisted/schema' +export {defaults} from '#/state/persisted/schema' + +const BSKY_STORAGE = 'BSKY_STORAGE' + +const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') +const UPDATE_EVENT = 'BSKY_UPDATE' + +let _state: Schema = defaults +const _emitter = new EventEmitter() + +export async function init() { + broadcast.onmessage = onBroadcastMessage + + try { + const stored = readFromStorage() + if (!stored) { + writeToStorage(defaults) + } + _state = stored || defaults + } catch (e) { + logger.error('persisted state: failed to load root state from storage', { + message: e, + }) + } +} +init satisfies PersistedApi['init'] + +export function get(key: K): Schema[K] { + return _state[key] +} +get satisfies PersistedApi['get'] + +export async function write( + key: K, + value: Schema[K], +): Promise { + try { + _state[key] = value + writeToStorage(_state) + // must happen on next tick, otherwise the tab will read stale storage data + setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + message: e, + }) + } +} +write satisfies PersistedApi['write'] + +export function onUpdate(cb: () => void): () => void { + _emitter.addListener('update', cb) + return () => _emitter.removeListener('update', cb) +} +onUpdate satisfies PersistedApi['onUpdate'] + +export async function clearStorage() { + try { + localStorage.removeItem(BSKY_STORAGE) + } catch (e: any) { + logger.error(`persisted store: failed to clear`, {message: e.toString()}) + } +} +clearStorage satisfies PersistedApi['clearStorage'] + +async function onBroadcastMessage({data}: MessageEvent) { + if (typeof data === 'object' && data.event === UPDATE_EVENT) { + try { + // read next state, possibly updated by another tab + const next = readFromStorage() + + if (next) { + _state = next + _emitter.emit('update') + } else { + logger.error( + `persisted state: handled update update from broadcast channel, but found no data`, + ) + } + } catch (e) { + logger.error( + `persisted state: failed handling update from broadcast channel`, + { + message: e, + }, + ) + } + } +} + +function writeToStorage(value: Schema) { + schema.parse(value) + localStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +function readFromStorage(): Schema | undefined { + const rawData = localStorage.getItem(BSKY_STORAGE) + const objData = rawData ? JSON.parse(rawData) : undefined + + // new user + if (!objData) return undefined + + // existing user, validate + const parsed = schema.safeParse(objData) + + if (parsed.success) { + return objData + } else { + const errors = + parsed.error?.errors?.map(e => ({ + code: e.code, + // @ts-ignore exists on some types + expected: e?.expected, + path: e.path?.join('.'), + })) || [] + logger.error(`persisted store: data failed validation on read`, {errors}) + return undefined + } +} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts deleted file mode 100644 index ca7967cd2e..0000000000 --- a/src/state/persisted/legacy.ts +++ /dev/null @@ -1,167 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -import {logger} from '#/logger' -import {defaults, Schema, schema} from '#/state/persisted/schema' -import {read, write} from '#/state/persisted/store' - -/** - * The shape of the serialized data from our legacy Mobx store. - */ -export type LegacySchema = { - shell: { - colorMode: 'system' | 'light' | 'dark' - } - session: { - data: { - service: string - did: `did:plc:${string}` - } | null - accounts: { - service: string - did: `did:plc:${string}` - refreshJwt: string - accessJwt: string - handle: string - email: string - displayName: string - aviUrl: string - emailConfirmed: boolean - }[] - } - me: { - did: `did:plc:${string}` - handle: string - displayName: string - description: string - avatar: string - } - onboarding: { - step: string - } - preferences: { - primaryLanguage: string - contentLanguages: string[] - postLanguage: string - postLanguageHistory: string[] - contentLabels: { - nsfw: string - nudity: string - suggestive: string - gore: string - hate: string - spam: string - impersonation: string - } - savedFeeds: string[] - pinnedFeeds: string[] - requireAltTextEnabled: boolean - } - invitedUsers: { - seenDids: string[] - copiedInvites: string[] - } - mutedThreads: {uris: string[]} - reminders: {lastEmailConfirm?: string} -} - -const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' - -export function transform(legacy: Partial): Schema { - return { - colorMode: legacy.shell?.colorMode || defaults.colorMode, - darkTheme: defaults.darkTheme, - session: { - accounts: legacy.session?.accounts || defaults.session.accounts, - currentAccount: - legacy.session?.accounts?.find( - a => a.did === legacy.session?.data?.did, - ) || defaults.session.currentAccount, - }, - reminders: { - lastEmailConfirm: - legacy.reminders?.lastEmailConfirm || - defaults.reminders.lastEmailConfirm, - }, - languagePrefs: { - primaryLanguage: - legacy.preferences?.primaryLanguage || - defaults.languagePrefs.primaryLanguage, - contentLanguages: - legacy.preferences?.contentLanguages || - defaults.languagePrefs.contentLanguages, - postLanguage: - legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage, - postLanguageHistory: - legacy.preferences?.postLanguageHistory || - defaults.languagePrefs.postLanguageHistory, - appLanguage: - legacy.preferences?.primaryLanguage || - defaults.languagePrefs.appLanguage, - }, - requireAltTextEnabled: - legacy.preferences?.requireAltTextEnabled || - defaults.requireAltTextEnabled, - mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads, - invites: { - copiedInvites: - legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites, - }, - onboarding: { - step: legacy.onboarding?.step || defaults.onboarding.step, - }, - hiddenPosts: defaults.hiddenPosts, - externalEmbeds: defaults.externalEmbeds, - lastSelectedHomeFeed: defaults.lastSelectedHomeFeed, - pdsAddressHistory: defaults.pdsAddressHistory, - disableHaptics: defaults.disableHaptics, - } -} - -/** - * Migrates legacy persisted state to new store if new store doesn't exist in - * local storage AND old storage exists. - */ -export async function migrate() { - logger.debug('persisted state: check need to migrate') - - try { - const rawLegacyData = await AsyncStorage.getItem( - DEPRECATED_ROOT_STATE_STORAGE_KEY, - ) - const newData = await read() - const alreadyMigrated = Boolean(newData) - - if (!alreadyMigrated && rawLegacyData) { - logger.debug('persisted state: migrating legacy storage') - - const legacyData = JSON.parse(rawLegacyData) - const newData = transform(legacyData) - const validate = schema.safeParse(newData) - - if (validate.success) { - await write(newData) - logger.debug('persisted state: migrated legacy storage') - } else { - logger.error('persisted state: legacy data failed validation', { - message: validate.error, - }) - } - } else { - logger.debug('persisted state: no migration needed') - } - } catch (e: any) { - logger.error(e, { - message: 'persisted state: error migrating legacy storage', - }) - } -} - -export async function clearLegacyStorage() { - try { - await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY) - } catch (e: any) { - logger.error(`persisted legacy store: failed to clear`, { - message: e.toString(), - }) - } -} diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts deleted file mode 100644 index f740126c45..0000000000 --- a/src/state/persisted/store.ts +++ /dev/null @@ -1,44 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -import {logger} from '#/logger' -import {Schema, schema} from '#/state/persisted/schema' - -const BSKY_STORAGE = 'BSKY_STORAGE' - -export async function write(value: Schema) { - schema.parse(value) - await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) -} - -export async function read(): Promise { - const rawData = await AsyncStorage.getItem(BSKY_STORAGE) - const objData = rawData ? JSON.parse(rawData) : undefined - - // new user - if (!objData) return undefined - - // existing user, validate - const parsed = schema.safeParse(objData) - - if (parsed.success) { - return objData - } else { - const errors = - parsed.error?.errors?.map(e => ({ - code: e.code, - // @ts-ignore exists on some types - expected: e?.expected, - path: e.path?.join('.'), - })) || [] - logger.error(`persisted store: data failed validation on read`, {errors}) - return undefined - } -} - -export async function clear() { - try { - await AsyncStorage.removeItem(BSKY_STORAGE) - } catch (e: any) { - logger.error(`persisted store: failed to clear`, {message: e.toString()}) - } -} diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts new file mode 100644 index 0000000000..95852f7960 --- /dev/null +++ b/src/state/persisted/types.ts @@ -0,0 +1,9 @@ +import type {Schema} from './schema' + +export type PersistedApi = { + init(): Promise + get(key: K): Schema[K] + write(key: K, value: Schema[K]): Promise + onUpdate(_cb: () => void): () => void + clearStorage: () => Promise +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index c33be7d542..a75fec5463 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -20,8 +20,7 @@ import {useQueryClient} from '@tanstack/react-query' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import {clearLegacyStorage} from '#/state/persisted/legacy' -import {clear as clearStorage} from '#/state/persisted/store' +import {clearStorage} from '#/state/persisted' import { useInAppBrowser, useSetInAppBrowser, @@ -299,10 +298,6 @@ export function SettingsScreen({}: Props) { await clearStorage() Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) }, [_]) - const clearAllLegacyStorage = React.useCallback(async () => { - await clearLegacyStorage() - Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`)) - }, [_]) const deactivateAccountControl = useDialogControl() const onPressDeactivateAccount = React.useCallback(() => { @@ -863,18 +858,6 @@ export function SettingsScreen({}: Props) { Reset onboarding state - - - - Clear all legacy storage data (restart after this) - - - Date: Tue, 6 Aug 2024 01:03:27 +0100 Subject: [PATCH 19/30] [Persisted] Fix the race condition causing clobbered writes between tabs (#4873) * Broadcast the update in the same tick The motivation for the original code is unclear. I was not able to reproduce the described behavior and have not seen it mentioned on the web. I'll assume that this was a misunderstanding. * Remove defensive programming The only places in this code that we can expect to throw are schema.parse(), JSON.parse(), JSON.stringify(), and localStorage.getItem/setItem/removeItem. Let's push try/catch'es where we expect them to be necessary. * Don't write or clobber defaults Writing defaults to local storage is unnecessary. We would write them as a part of next update anyway. So I'm removing that to reduce the number of moving pieces. However, we do need to be wary of _state being set to defaults. Because _state gets mutated on write. We don't want to mutate the defaults object. To avoid having to think about this, let's copy on write. We don't write to this object very often. * Refactor: extract tryParse * Refactor: move string parsing into tryParse * Extract tryStringify, split logging by platform Shared data parsing/stringification errors are always logged. Storage errors are only logged on native because we trust the web APIs to work. * Add a layer of caching to readFromStorage to web We're going to be doing a read on every write so let's add a fast path that avoids parsing and validating. * Fix the race condition causing clobbered writes between tabs --- src/state/persisted/index.ts | 74 +++++++++---------- src/state/persisted/index.web.ts | 123 +++++++++++++++---------------- src/state/persisted/schema.ts | 43 ++++++++++- 3 files changed, 133 insertions(+), 107 deletions(-) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 639e4e47f5..95f8148505 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,7 +1,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import {logger} from '#/logger' -import {defaults, Schema, schema} from '#/state/persisted/schema' +import { + defaults, + Schema, + tryParse, + tryStringify, +} from '#/state/persisted/schema' import {PersistedApi} from './types' export type {PersistedAccount, Schema} from '#/state/persisted/schema' @@ -12,16 +17,9 @@ const BSKY_STORAGE = 'BSKY_STORAGE' let _state: Schema = defaults export async function init() { - try { - const stored = await readFromStorage() - if (!stored) { - await writeToStorage(defaults) - } - _state = stored || defaults - } catch (e) { - logger.error('persisted state: failed to load root state from storage', { - message: e, - }) + const stored = await readFromStorage() + if (stored) { + _state = stored } } init satisfies PersistedApi['init'] @@ -35,14 +33,11 @@ export async function write( key: K, value: Schema[K], ): Promise { - try { - _state[key] = value - await writeToStorage(_state) - } catch (e) { - logger.error(`persisted state: failed writing root state to storage`, { - message: e, - }) + _state = { + ..._state, + [key]: value, } + await writeToStorage(_state) } write satisfies PersistedApi['write'] @@ -61,31 +56,28 @@ export async function clearStorage() { clearStorage satisfies PersistedApi['clearStorage'] async function writeToStorage(value: Schema) { - schema.parse(value) - await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) + const rawData = tryStringify(value) + if (rawData) { + try { + await AsyncStorage.setItem(BSKY_STORAGE, rawData) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + message: e, + }) + } + } } async function readFromStorage(): Promise { - const rawData = await AsyncStorage.getItem(BSKY_STORAGE) - const objData = rawData ? JSON.parse(rawData) : undefined - - // new user - if (!objData) return undefined - - // existing user, validate - const parsed = schema.safeParse(objData) - - if (parsed.success) { - return objData - } else { - const errors = - parsed.error?.errors?.map(e => ({ - code: e.code, - // @ts-ignore exists on some types - expected: e?.expected, - path: e.path?.join('.'), - })) || [] - logger.error(`persisted store: data failed validation on read`, {errors}) - return undefined + let rawData: string | null = null + try { + rawData = await AsyncStorage.getItem(BSKY_STORAGE) + } catch (e) { + logger.error(`persisted state: failed reading root state from storage`, { + message: e, + }) + } + if (rawData) { + return tryParse(rawData) } } diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index 50f28b6b8a..d71b59096b 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -2,7 +2,12 @@ import EventEmitter from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {defaults, Schema, schema} from '#/state/persisted/schema' +import { + defaults, + Schema, + tryParse, + tryStringify, +} from '#/state/persisted/schema' import {PersistedApi} from './types' export type {PersistedAccount, Schema} from '#/state/persisted/schema' @@ -18,17 +23,9 @@ const _emitter = new EventEmitter() export async function init() { broadcast.onmessage = onBroadcastMessage - - try { - const stored = readFromStorage() - if (!stored) { - writeToStorage(defaults) - } - _state = stored || defaults - } catch (e) { - logger.error('persisted state: failed to load root state from storage', { - message: e, - }) + const stored = readFromStorage() + if (stored) { + _state = stored } } init satisfies PersistedApi['init'] @@ -42,16 +39,20 @@ export async function write( key: K, value: Schema[K], ): Promise { - try { - _state[key] = value - writeToStorage(_state) - // must happen on next tick, otherwise the tab will read stale storage data - setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) - } catch (e) { - logger.error(`persisted state: failed writing root state to storage`, { - message: e, - }) + const next = readFromStorage() + if (next) { + // The storage could have been updated by a different tab before this tab is notified. + // Make sure this write is applied on top of the latest data in the storage as long as it's valid. + _state = next + // Don't fire the update listeners yet to avoid a loop. + // If there was a change, we'll receive the broadcast event soon enough which will do that. } + _state = { + ..._state, + [key]: value, + } + writeToStorage(_state) + broadcast.postMessage({event: UPDATE_EVENT}) } write satisfies PersistedApi['write'] @@ -65,62 +66,54 @@ export async function clearStorage() { try { localStorage.removeItem(BSKY_STORAGE) } catch (e: any) { - logger.error(`persisted store: failed to clear`, {message: e.toString()}) + // Expected on the web in private mode. } } clearStorage satisfies PersistedApi['clearStorage'] async function onBroadcastMessage({data}: MessageEvent) { if (typeof data === 'object' && data.event === UPDATE_EVENT) { - try { - // read next state, possibly updated by another tab - const next = readFromStorage() - - if (next) { - _state = next - _emitter.emit('update') - } else { - logger.error( - `persisted state: handled update update from broadcast channel, but found no data`, - ) - } - } catch (e) { + // read next state, possibly updated by another tab + const next = readFromStorage() + if (next) { + _state = next + _emitter.emit('update') + } else { logger.error( - `persisted state: failed handling update from broadcast channel`, - { - message: e, - }, + `persisted state: handled update update from broadcast channel, but found no data`, ) } } } function writeToStorage(value: Schema) { - schema.parse(value) - localStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) -} - -function readFromStorage(): Schema | undefined { - const rawData = localStorage.getItem(BSKY_STORAGE) - const objData = rawData ? JSON.parse(rawData) : undefined - - // new user - if (!objData) return undefined - - // existing user, validate - const parsed = schema.safeParse(objData) - - if (parsed.success) { - return objData - } else { - const errors = - parsed.error?.errors?.map(e => ({ - code: e.code, - // @ts-ignore exists on some types - expected: e?.expected, - path: e.path?.join('.'), - })) || [] - logger.error(`persisted store: data failed validation on read`, {errors}) - return undefined + const rawData = tryStringify(value) + if (rawData) { + try { + localStorage.setItem(BSKY_STORAGE, rawData) + } catch (e) { + // Expected on the web in private mode. + } + } +} + +let lastRawData: string | undefined +let lastResult: Schema | undefined +function readFromStorage(): Schema | undefined { + let rawData: string | null = null + try { + rawData = localStorage.getItem(BSKY_STORAGE) + } catch (e) { + // Expected on the web in private mode. + } + if (rawData) { + if (rawData === lastRawData) { + return lastResult + } else { + const result = tryParse(rawData) + lastRawData = rawData + lastResult = result + return result + } } } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 399a7e7932..0b652a1f00 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,5 +1,6 @@ import {z} from 'zod' +import {logger} from '#/logger' import {deviceLocales} from '#/platform/detection' import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' @@ -43,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({ }) export type PersistedCurrentAccount = z.infer -export const schema = z.object({ +const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), darkTheme: z.enum(['dim', 'dark']).optional(), session: z.object({ @@ -133,3 +134,43 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, } + +export function tryParse(rawData: string): Schema | undefined { + let objData + try { + objData = JSON.parse(rawData) + } catch (e) { + logger.error('persisted state: failed to parse root state from storage', { + message: e, + }) + } + if (!objData) { + return undefined + } + const parsed = schema.safeParse(objData) + if (parsed.success) { + return objData + } else { + const errors = + parsed.error?.errors?.map(e => ({ + code: e.code, + // @ts-ignore exists on some types + expected: e?.expected, + path: e.path?.join('.'), + })) || [] + logger.error(`persisted store: data failed validation on read`, {errors}) + return undefined + } +} + +export function tryStringify(value: Schema): string | undefined { + try { + schema.parse(value) + return JSON.stringify(value) + } catch (e) { + logger.error(`persisted state: failed stringifying root state`, { + message: e, + }) + return undefined + } +} From 686d5ebb535710dd8c96aa694b4cd1f7913ff3fa Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 01:30:52 +0100 Subject: [PATCH 20/30] [Persisted] Make broadcast subscriptions granular by key (#4874) * Add fast path for guaranteed noop updates * Change persisted.onUpdate() API to take a key * Implement granular broadcast listeners --- src/state/invites.tsx | 5 ++- src/state/persisted/index.ts | 5 ++- src/state/persisted/index.web.ts | 41 ++++++++++++++++--- src/state/persisted/types.ts | 5 ++- src/state/preferences/alt-text-required.tsx | 9 ++-- src/state/preferences/autoplay.tsx | 4 +- src/state/preferences/disable-haptics.tsx | 4 +- .../preferences/external-embeds-prefs.tsx | 4 +- src/state/preferences/hidden-posts.tsx | 4 +- src/state/preferences/in-app-browser.tsx | 4 +- src/state/preferences/kawaii.tsx | 4 +- src/state/preferences/languages.tsx | 4 +- src/state/preferences/large-alt-badge.tsx | 9 ++-- src/state/preferences/used-starter-packs.tsx | 9 ++-- src/state/session/index.tsx | 4 +- src/state/shell/color-mode.tsx | 13 ++++-- src/state/shell/onboarding.tsx | 9 ++-- 17 files changed, 95 insertions(+), 42 deletions(-) diff --git a/src/state/invites.tsx b/src/state/invites.tsx index 6a0d1b5900..0d40caf258 100644 --- a/src/state/invites.tsx +++ b/src/state/invites.tsx @@ -1,4 +1,5 @@ import React from 'react' + import * as persisted from '#/state/persisted' type StateContext = persisted.Schema['invites'] @@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('invites')) + return persisted.onUpdate('invites', nextInvites => { + setState(nextInvites) }) }, [setState]) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 95f8148505..6f4beae2ca 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -41,7 +41,10 @@ export async function write( } write satisfies PersistedApi['write'] -export function onUpdate(_cb: () => void): () => void { +export function onUpdate( + _key: K, + _cb: (v: Schema[K]) => void, +): () => void { return () => {} } onUpdate satisfies PersistedApi['onUpdate'] diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index d71b59096b..7521776bc0 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -47,18 +47,36 @@ export async function write( // Don't fire the update listeners yet to avoid a loop. // If there was a change, we'll receive the broadcast event soon enough which will do that. } + try { + if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) { + // Fast path for updates that are guaranteed to be noops. + // This is good mostly because it avoids useless broadcasts to other tabs. + return + } + } catch (e) { + // Ignore and go through the normal path. + } _state = { ..._state, [key]: value, } writeToStorage(_state) - broadcast.postMessage({event: UPDATE_EVENT}) + broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) + broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading } write satisfies PersistedApi['write'] -export function onUpdate(cb: () => void): () => void { - _emitter.addListener('update', cb) - return () => _emitter.removeListener('update', cb) +export function onUpdate( + key: K, + cb: (v: Schema[K]) => void, +): () => void { + const listener = () => cb(get(key)) + _emitter.addListener('update', listener) // Backcompat while upgrading + _emitter.addListener('update:' + key, listener) + return () => { + _emitter.removeListener('update', listener) // Backcompat while upgrading + _emitter.removeListener('update:' + key, listener) + } } onUpdate satisfies PersistedApi['onUpdate'] @@ -72,12 +90,23 @@ export async function clearStorage() { clearStorage satisfies PersistedApi['clearStorage'] async function onBroadcastMessage({data}: MessageEvent) { - if (typeof data === 'object' && data.event === UPDATE_EVENT) { + if ( + typeof data === 'object' && + (data.event === UPDATE_EVENT || // Backcompat while upgrading + data.event?.type === UPDATE_EVENT) + ) { // read next state, possibly updated by another tab const next = readFromStorage() + if (next === _state) { + return + } if (next) { _state = next - _emitter.emit('update') + if (typeof data.event.key === 'string') { + _emitter.emit('update:' + data.event.key) + } else { + _emitter.emit('update') // Backcompat while upgrading + } } else { logger.error( `persisted state: handled update update from broadcast channel, but found no data`, diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index 95852f7960..fd39079bf8 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -4,6 +4,9 @@ export type PersistedApi = { init(): Promise get(key: K): Schema[K] write(key: K, value: Schema[K]): Promise - onUpdate(_cb: () => void): () => void + onUpdate( + key: K, + cb: (v: Schema[K]) => void, + ): () => void clearStorage: () => Promise } diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx index 642e790fbc..0ddc173ea3 100644 --- a/src/state/preferences/alt-text-required.tsx +++ b/src/state/preferences/alt-text-required.tsx @@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('requireAltTextEnabled')) - }) + return persisted.onUpdate( + 'requireAltTextEnabled', + nextRequireAltTextEnabled => { + setState(nextRequireAltTextEnabled) + }, + ) }, [setStateWrapped]) return ( diff --git a/src/state/preferences/autoplay.tsx b/src/state/preferences/autoplay.tsx index d5aa049f36..141c8161ef 100644 --- a/src/state/preferences/autoplay.tsx +++ b/src/state/preferences/autoplay.tsx @@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(Boolean(persisted.get('disableAutoplay'))) + return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => { + setState(Boolean(nextDisableAutoplay)) }) }, [setStateWrapped]) diff --git a/src/state/preferences/disable-haptics.tsx b/src/state/preferences/disable-haptics.tsx index af2c55a182..367d4f7db4 100644 --- a/src/state/preferences/disable-haptics.tsx +++ b/src/state/preferences/disable-haptics.tsx @@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(Boolean(persisted.get('disableHaptics'))) + return persisted.onUpdate('disableHaptics', nextDisableHaptics => { + setState(Boolean(nextDisableHaptics)) }) }, [setStateWrapped]) diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx index 9ace5d940f..04afb89dd7 100644 --- a/src/state/preferences/external-embeds-prefs.tsx +++ b/src/state/preferences/external-embeds-prefs.tsx @@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('externalEmbeds')) + return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => { + setState(nextExternalEmbeds) }) }, [setStateWrapped]) diff --git a/src/state/preferences/hidden-posts.tsx b/src/state/preferences/hidden-posts.tsx index 2c6a373e15..510af713d3 100644 --- a/src/state/preferences/hidden-posts.tsx +++ b/src/state/preferences/hidden-posts.tsx @@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('hiddenPosts')) + return persisted.onUpdate('hiddenPosts', nextHiddenPosts => { + setState(nextHiddenPosts) }) }, [setStateWrapped]) diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 73c4bbbe78..76c854105e 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('useInAppBrowser')) + return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => { + setState(nextUseInAppBrowser) }) }, [setStateWrapped]) diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx index 4aa95ef8b0..4216891648 100644 --- a/src/state/preferences/kawaii.tsx +++ b/src/state/preferences/kawaii.tsx @@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('kawaii')) + return persisted.onUpdate('kawaii', nextKawaii => { + setState(nextKawaii) }) }, [setStateWrapped]) diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx index b7494c1f93..5093cd725d 100644 --- a/src/state/preferences/languages.tsx +++ b/src/state/preferences/languages.tsx @@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('languagePrefs')) + return persisted.onUpdate('languagePrefs', nextLanguagePrefs => { + setState(nextLanguagePrefs) }) }, [setStateWrapped]) diff --git a/src/state/preferences/large-alt-badge.tsx b/src/state/preferences/large-alt-badge.tsx index b3d597c5cb..9d2c9fa54e 100644 --- a/src/state/preferences/large-alt-badge.tsx +++ b/src/state/preferences/large-alt-badge.tsx @@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('largeAltBadgeEnabled')) - }) + return persisted.onUpdate( + 'largeAltBadgeEnabled', + nextLargeAltBadgeEnabled => { + setState(nextLargeAltBadgeEnabled) + }, + ) }, [setStateWrapped]) return ( diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx index 8d5d9e8283..e4de479d55 100644 --- a/src/state/preferences/used-starter-packs.tsx +++ b/src/state/preferences/used-starter-packs.tsx @@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) { } React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('hasCheckedForStarterPack')) - }) + return persisted.onUpdate( + 'hasCheckedForStarterPack', + nextHasCheckedForStarterPack => { + setState(nextHasCheckedForStarterPack) + }, + ) }, []) return ( diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 3aac19025d..09fcf86642 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -185,8 +185,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [state]) React.useEffect(() => { - return persisted.onUpdate(() => { - const synced = persisted.get('session') + return persisted.onUpdate('session', nextSession => { + const synced = nextSession addSessionDebugLog({type: 'persisted:receive', data: synced}) dispatch({ type: 'synced-accounts', diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index f3339d2406..47b936c0bb 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -1,4 +1,5 @@ import React from 'react' + import * as persisted from '#/state/persisted' type StateContext = { @@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setColorMode(persisted.get('colorMode')) - setDarkTheme(persisted.get('darkTheme')) + const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => { + setDarkTheme(nextDarkTheme) }) + const unsub2 = persisted.onUpdate('colorMode', nextColorMode => { + setColorMode(nextColorMode) + }) + return () => { + unsub1() + unsub2() + } }, []) return ( diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx index 6a18b461f9..d3a8fec466 100644 --- a/src/state/shell/onboarding.tsx +++ b/src/state/shell/onboarding.tsx @@ -1,6 +1,7 @@ import React from 'react' -import * as persisted from '#/state/persisted' + import {track} from '#/lib/analytics/analytics' +import * as persisted from '#/state/persisted' export const OnboardingScreenSteps = { Welcome: 'Welcome', @@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - const next = persisted.get('onboarding').step + return persisted.onUpdate('onboarding', nextOnboarding => { + const next = nextOnboarding.step // TODO we've introduced a footgun if (state.step !== next) { dispatch({ type: 'set', - step: persisted.get('onboarding').step as OnboardingStep, + step: nextOnboarding.step as OnboardingStep, }) } }) From b291a1ed8a1706f30f117d691d85508ffad342f2 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 16:42:42 +0100 Subject: [PATCH 21/30] Show more replies in Following (different heuristic) (#4880) --- src/lib/api/feed-manip.ts | 94 ++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index b8fc586ec4..ae3e84b99d 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -25,6 +25,13 @@ type FeedSliceItem = { isParentBlocked: boolean } +type AuthorContext = { + author: AppBskyActorDefs.ProfileViewBasic + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined +} + export class FeedViewPostsSlice { _reactKey: string _feedPost: FeedViewPost @@ -159,21 +166,29 @@ export class FeedViewPostsSlice { return !!this.items.find(item => item.post.uri === uri) } - getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] { + getAuthors(): AuthorContext { const feedPost = this._feedPost - const authors = [feedPost.post.author] + let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author + let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined if (feedPost.reply) { if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) { - authors.push(feedPost.reply.parent.author) + parentAuthor = feedPost.reply.parent.author } if (feedPost.reply.grandparentAuthor) { - authors.push(feedPost.reply.grandparentAuthor) + grandparentAuthor = feedPost.reply.grandparentAuthor } if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) { - authors.push(feedPost.reply.root.author) + rootAuthor = feedPost.reply.root.author } } - return authors + return { + author, + parentAuthor, + grandparentAuthor, + rootAuthor, + } } } @@ -252,7 +267,7 @@ export class FeedTuner { !slice.isRepost && // This is not perfect but it's close as we can get to // detecting threads without having to peek ahead. - !areSameAuthor(slice.getAllAuthors()) + !areSameAuthor(slice.getAuthors()) ) { slices.splice(i, 1) i-- @@ -333,7 +348,7 @@ export class FeedTuner { if ( slice.isReply && !slice.isRepost && - !isFollowingAll(slice.getAllAuthors(), userDid) + !shouldDisplayReplyInFollowing(slice.getAuthors(), userDid) ) { slices.splice(i, 1) i-- @@ -389,15 +404,64 @@ export class FeedTuner { } } -function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean { - const dids = authors.map(a => a.did) - const set = new Set(dids) - return set.size === 1 +function areSameAuthor(authors: AuthorContext): boolean { + const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors + const authorDid = author.did + if (parentAuthor && parentAuthor.did !== authorDid) { + return false + } + if (grandparentAuthor && grandparentAuthor.did !== authorDid) { + return false + } + if (rootAuthor && rootAuthor.did !== authorDid) { + return false + } + return true } -function isFollowingAll( - authors: AppBskyActorDefs.ProfileViewBasic[], +function shouldDisplayReplyInFollowing( + authors: AuthorContext, userDid: string, ): boolean { - return authors.every(a => a.did === userDid || a.viewer?.following) + const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors + if (!isSelfOrFollowing(author, userDid)) { + // Only show replies from self or people you follow. + return false + } + if (!parentAuthor || !grandparentAuthor || !rootAuthor) { + // Don't surface orphaned reply subthreads. + return false + } + if ( + parentAuthor.did === author.did && + grandparentAuthor.did === author.did && + rootAuthor.did === author.did + ) { + // Always show self-threads. + return true + } + // From this point on we need at least one more reason to show it. + if ( + parentAuthor.did !== author.did && + isSelfOrFollowing(parentAuthor, userDid) + ) { + return true + } + if ( + grandparentAuthor.did !== author.did && + isSelfOrFollowing(grandparentAuthor, userDid) + ) { + return true + } + if (rootAuthor.did !== author.did && isSelfOrFollowing(rootAuthor, userDid)) { + return true + } + return false +} + +function isSelfOrFollowing( + profile: AppBskyActorDefs.ProfileViewBasic, + userDid: string, +) { + return Boolean(profile.did === userDid || profile.viewer?.following) } From 5845e08eeea151deb75bb21ceaa33f7a973870e3 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 17:12:27 +0100 Subject: [PATCH 22/30] Show own replies before follows' replies in threads (#4882) --- src/state/queries/post-thread.ts | 13 ++++++++++++- src/view/com/post-thread/PostThread.tsx | 9 +++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index db85e8a177..c01b96ed81 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -136,6 +136,7 @@ export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, + currentDid: string | undefined, ): ThreadNode { if (node.type !== 'post') { return node @@ -159,6 +160,16 @@ export function sortThread( return 1 // op's own reply } + const aIsBySelf = a.post.author.did === currentDid + const bIsBySelf = b.post.author.did === currentDid + if (aIsBySelf && bIsBySelf) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsBySelf) { + return -1 // current account's reply + } else if (bIsBySelf) { + return 1 // current account's reply + } + const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { @@ -195,7 +206,7 @@ export function sortThread( } return b.post.indexedAt.localeCompare(a.post.indexedAt) }) - node.replies.forEach(reply => sortThread(reply, opts, modCache)) + node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid)) } return node } diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index a6c1a46487..b7eaedd363 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -89,7 +89,7 @@ export function PostThread({ onCanReply: (canReply: boolean) => void onPressReply: () => unknown }) { - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() const {isMobile, isTabletOrMobile} = useWebMediaQueries() @@ -154,6 +154,7 @@ export function PostThread({ // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. const [deferParents, setDeferParents] = React.useState(isNative) + const currentDid = currentAccount?.did const threadModerationCache = React.useMemo(() => { const cache: ThreadModerationCache = new WeakMap() if (thread && moderationOpts) { @@ -167,8 +168,8 @@ export function PostThread({ if (!threadViewPrefs || !thread) return null return createThreadSkeleton( - sortThread(thread, threadViewPrefs, threadModerationCache), - hasSession, + sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), + !!currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, @@ -176,7 +177,7 @@ export function PostThread({ }, [ thread, preferences?.threadViewPrefs, - hasSession, + currentDid, treeView, threadModerationCache, hiddenRepliesState, From 753a2334082d279b504e164a1f50c0b0a8169f2b Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 6 Aug 2024 11:21:59 -0700 Subject: [PATCH 23/30] Tweak feed manip to show cases of A -> B without further children (#4883) --- src/lib/api/feed-manip.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index ae3e84b99d..61de795a14 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -428,32 +428,34 @@ function shouldDisplayReplyInFollowing( // Only show replies from self or people you follow. return false } - if (!parentAuthor || !grandparentAuthor || !rootAuthor) { - // Don't surface orphaned reply subthreads. - return false - } if ( - parentAuthor.did === author.did && - grandparentAuthor.did === author.did && - rootAuthor.did === author.did + (!parentAuthor || parentAuthor.did === author.did) && + (!rootAuthor || rootAuthor.did === author.did) && + (!grandparentAuthor || grandparentAuthor.did === author.did) ) { // Always show self-threads. return true } // From this point on we need at least one more reason to show it. if ( + parentAuthor && parentAuthor.did !== author.did && isSelfOrFollowing(parentAuthor, userDid) ) { return true } if ( + grandparentAuthor && grandparentAuthor.did !== author.did && isSelfOrFollowing(grandparentAuthor, userDid) ) { return true } - if (rootAuthor.did !== author.did && isSelfOrFollowing(rootAuthor, userDid)) { + if ( + rootAuthor && + rootAuthor.did !== author.did && + isSelfOrFollowing(rootAuthor, userDid) + ) { return true } return false From b701e8c68c1122bf138575804af41260ec1c436d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Aug 2024 16:56:12 +0100 Subject: [PATCH 24/30] [Video] Authed video upload (#4885) * add service auth call * update API package --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- package.json | 2 +- src/state/queries/video/video-upload.ts | 26 +++++++++++++++------ src/state/queries/video/video-upload.web.ts | 22 +++++++++++++---- yarn.lock | 8 +++---- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3d053bc83b..faeee448c9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.26", + "@atproto/api": "0.12.29", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index 4d7f7995c5..cf741b2510 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -2,10 +2,11 @@ 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' +import {CompressedVideo} from '#/lib/media/video/compress' +import {UploadVideoResponse} from '#/lib/media/video/types' +import {createVideoEndpointUrl} from '#/state/queries/video/util' +import {useAgent, useSession} from '#/state/session' + const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' export const useUploadVideoMutation = ({ @@ -18,6 +19,7 @@ export const useUploadVideoMutation = ({ setProgress: (progress: number) => void }) => { const {currentAccount} = useSession() + const agent = useAgent() return useMutation({ mutationFn: async (video: CompressedVideo) => { @@ -26,6 +28,17 @@ export const useUploadVideoMutation = ({ name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? }) + // a logged-in agent should have this set, but we'll check just in case + if (!agent.pdsUrl) { + throw new Error('Agent does not have a PDS URL') + } + + const {data: serviceAuth} = + await agent.api.com.atproto.server.getServiceAuth({ + aud: `did:web:${agent.pdsUrl.hostname}`, + lxm: 'com.atproto.repo.uploadBlob', + }) + const uploadTask = createUploadTask( uri, video.uri, @@ -33,13 +46,12 @@ export const useUploadVideoMutation = ({ headers: { 'dev-key': UPLOAD_HEADER, 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? + Authorization: `Bearer ${serviceAuth.token}`, }, httpMethod: 'POST', uploadType: FileSystemUploadType.BINARY_CONTENT, }, - p => { - setProgress(p.totalBytesSent / p.totalBytesExpectedToSend) - }, + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), ) const res = await uploadTask.uploadAsync() diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index b5b9e93bf9..b9b0bacfac 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,10 +1,11 @@ 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' +import {CompressedVideo} from '#/lib/media/video/compress' +import {UploadVideoResponse} from '#/lib/media/video/types' +import {createVideoEndpointUrl} from '#/state/queries/video/util' +import {useAgent, useSession} from '#/state/session' + const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' export const useUploadVideoMutation = ({ @@ -17,6 +18,7 @@ export const useUploadVideoMutation = ({ setProgress: (progress: number) => void }) => { const {currentAccount} = useSession() + const agent = useAgent() return useMutation({ mutationFn: async (video: CompressedVideo) => { @@ -25,6 +27,17 @@ export const useUploadVideoMutation = ({ name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? }) + // a logged-in agent should have this set, but we'll check just in case + if (!agent.pdsUrl) { + throw new Error('Agent does not have a PDS URL') + } + + const {data: serviceAuth} = + await agent.api.com.atproto.server.getServiceAuth({ + aud: `did:web:${agent.pdsUrl.hostname}`, + lxm: 'com.atproto.repo.uploadBlob', + }) + const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) const xhr = new XMLHttpRequest() @@ -53,6 +66,7 @@ export const useUploadVideoMutation = ({ 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.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) xhr.send(bytes) })) as UploadVideoResponse diff --git a/yarn.lock b/yarn.lock index 6fa8805125..16547aa3ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.26": - version "0.12.26" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" - integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== +"@atproto/api@0.12.29": + version "0.12.29" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.29.tgz#95a19202c2f0eec4c955909685be11009ba9b9a1" + integrity sha512-PyzPLjGWR0qNOMrmj3Nt3N5NuuANSgOk/33Bu3j+rFjjPrHvk9CI6iQPU6zuDaDCoyOTRJRafw8X/aMQw+ilgw== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" From fff2c079c2554861764974aaeeb56f79a25ba82a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Aug 2024 18:47:51 +0100 Subject: [PATCH 25/30] [Videos] Video player - PR #2 - better web support (#4732) * attempt some sort of "usurping" system * polling-based active video approach * split into inner component again * click to steal active video * disable findAndActivateVideo on native * new intersectionobserver approach - wip * fix types * disable perf optimisation to allow overflow * make active player indicator subtler, clean up video utils * partially fix double-playing * start working on controls * fullscreen API * get buttons working somewhat * rm source from where it shouldn't be * use video elem as source of truth * fix keyboard nav + mute state * new icons, add fullscreen + time + fix play * unmount when far offscreen + round 2dp * listen globally to clicks rather than blur event * move controls to new file * reduce quality when not active * add hover state to buttons * stop propagation of videoplayer click * move around autoplay effects * increase background contrast * add subtitles button * add stopPropagation to root of video player * clean up VideoWebControls * fix chrome * change quality based on focused state * use autoLevelCapping instead of nextLevel * get subtitle track from stream * always use hlsjs * rework hls into a ref * render player earlier, allowing preload * add error boundary * clean up component structure and organisation * rework fullscreen API * disable fullscreen on iPhone * don't play when ready on pause * debounce buffering * simplify giant list of event listeners * update pref * reduce prop drilling * minimise rerenders in `ActiveViewContext` * restore prop drilling --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey --- ...rowsDiagonalIn_stroke2_corner0_rounded.svg | 1 + ...rowsDiagonalIn_stroke2_corner2_rounded.svg | 1 + ...owsDiagonalOut_stroke2_corner0_rounded.svg | 1 + ...owsDiagonalOut_stroke2_corner2_rounded.svg | 1 + .../cc_filled_stroke2_corner0_rounded.svg | 1 + assets/icons/cc_stroke2_corner0_rounded.svg | 1 + assets/icons/pause_filled_corner0_rounded.svg | 1 + assets/icons/pause_filled_corner2_rounded.svg | 1 + .../icons/pause_stroke2_corner0_rounded.svg | 1 + .../icons/pause_stroke2_corner2_rounded.svg | 1 + assets/icons/play_filled_corner0_rounded.svg | 1 + assets/icons/play_stroke2_corner0_rounded.svg | 1 + src/components/icons/ArrowsDiagonal.tsx | 17 + src/components/icons/CC.tsx | 9 + src/components/icons/Pause.tsx | 17 + src/components/icons/Play.tsx | 8 + src/platform/detection.ts | 1 + .../Messages/Conversation/MessagesList.tsx | 3 - src/state/persisted/schema.ts | 2 + src/state/preferences/index.tsx | 6 +- src/state/preferences/subtitles.tsx | 42 ++ src/view/com/posts/FeedItem.tsx | 1 - src/view/com/util/List.tsx | 2 - src/view/com/util/List.web.tsx | 22 +- .../util/post-embeds/ActiveVideoContext.tsx | 89 ++- src/view/com/util/post-embeds/VideoEmbed.tsx | 12 +- .../com/util/post-embeds/VideoEmbed.web.tsx | 190 ++++++ .../com/util/post-embeds/VideoEmbedInner.tsx | 7 +- .../util/post-embeds/VideoEmbedInner.web.tsx | 121 ++-- .../util/post-embeds/VideoPlayerContext.tsx | 10 +- .../com/util/post-embeds/VideoWebControls.tsx | 16 + .../util/post-embeds/VideoWebControls.web.tsx | 587 ++++++++++++++++++ 32 files changed, 1087 insertions(+), 87 deletions(-) create mode 100644 assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg create mode 100644 assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg create mode 100644 assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg create mode 100644 assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg create mode 100644 assets/icons/cc_filled_stroke2_corner0_rounded.svg create mode 100644 assets/icons/cc_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pause_filled_corner0_rounded.svg create mode 100644 assets/icons/pause_filled_corner2_rounded.svg create mode 100644 assets/icons/pause_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pause_stroke2_corner2_rounded.svg create mode 100644 assets/icons/play_filled_corner0_rounded.svg create mode 100644 assets/icons/play_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/ArrowsDiagonal.tsx create mode 100644 src/components/icons/CC.tsx create mode 100644 src/components/icons/Pause.tsx create mode 100644 src/state/preferences/subtitles.tsx create mode 100644 src/view/com/util/post-embeds/VideoEmbed.web.tsx create mode 100644 src/view/com/util/post-embeds/VideoWebControls.tsx create mode 100644 src/view/com/util/post-embeds/VideoWebControls.web.tsx diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..a9532cd9c6 --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..9b92e533eb --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..9987b34406 --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..36d8e1d67c --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/cc_filled_stroke2_corner0_rounded.svg b/assets/icons/cc_filled_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..58823ca80d --- /dev/null +++ b/assets/icons/cc_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/cc_stroke2_corner0_rounded.svg b/assets/icons/cc_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..fcda1570f9 --- /dev/null +++ b/assets/icons/cc_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_filled_corner0_rounded.svg b/assets/icons/pause_filled_corner0_rounded.svg new file mode 100644 index 0000000000..0037701f90 --- /dev/null +++ b/assets/icons/pause_filled_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_filled_corner2_rounded.svg b/assets/icons/pause_filled_corner2_rounded.svg new file mode 100644 index 0000000000..98726d873e --- /dev/null +++ b/assets/icons/pause_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_stroke2_corner0_rounded.svg b/assets/icons/pause_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d2735ed2bd --- /dev/null +++ b/assets/icons/pause_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_stroke2_corner2_rounded.svg b/assets/icons/pause_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..3a8c0b4379 --- /dev/null +++ b/assets/icons/pause_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_filled_corner0_rounded.svg b/assets/icons/play_filled_corner0_rounded.svg new file mode 100644 index 0000000000..7bee1ae9a3 --- /dev/null +++ b/assets/icons/play_filled_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/play_stroke2_corner0_rounded.svg b/assets/icons/play_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d7321b9b7b --- /dev/null +++ b/assets/icons/play_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/icons/ArrowsDiagonal.tsx b/src/components/icons/ArrowsDiagonal.tsx new file mode 100644 index 0000000000..3f9ae40e0f --- /dev/null +++ b/src/components/icons/ArrowsDiagonal.tsx @@ -0,0 +1,17 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) + +export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/CC.tsx b/src/components/icons/CC.tsx new file mode 100644 index 0000000000..da2e7c5dba --- /dev/null +++ b/src/components/icons/CC.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z', +}) + +export const CC_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z', +}) diff --git a/src/components/icons/Pause.tsx b/src/components/icons/Pause.tsx new file mode 100644 index 0000000000..927f285a00 --- /dev/null +++ b/src/components/icons/Pause.tsx @@ -0,0 +1,17 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z', +}) + +export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z', +}) + +export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z', +}) + +export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z', +}) diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx index acf421d57c..176b24f281 100644 --- a/src/components/icons/Play.tsx +++ b/src/components/icons/Play.tsx @@ -1,5 +1,13 @@ import {createSinglePathSVG} from './TEMPLATE' +export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z', +}) + +export const Play_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z', +}) + export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({ path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z', }) diff --git a/src/platform/detection.ts b/src/platform/detection.ts index f00df0ee4e..c62ae71aae 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -14,6 +14,7 @@ export const isMobileWeb = isWeb && // @ts-ignore we know window exists -prf global.window.matchMedia(isMobileWebMediaQuery)?.matches +export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent) export const deviceLocales = dedupArray( getLocales?.() diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 11b951e99d..c0e78e9789 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -387,9 +387,6 @@ export function MessagesList({ renderItem={renderItem} keyExtractor={keyExtractor} disableFullWindowScroll={true} - // Prevents wrong position in Firefox when sending a message - // as well as scroll getting stuck on Chome when scrolling upwards. - disableContainStyle={true} disableVirtualization={true} style={animatedListStyle} // The extra two items account for the header and the footer components diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 0b652a1f00..331a111a2e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -91,6 +91,7 @@ const schema = z.object({ disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(), + subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -133,6 +134,7 @@ export const defaults: Schema = { disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, + subtitlesEnabled: true, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index e6b53d5be0..c7eaf27261 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' +import {Provider as SubtitlesProvider} from './subtitles' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { @@ -24,6 +25,7 @@ export { export * from './hidden-posts' export {useLabelDefinitions} from './label-defs' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' +export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' export function Provider({children}: React.PropsWithChildren<{}>) { return ( @@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + {children} + diff --git a/src/state/preferences/subtitles.tsx b/src/state/preferences/subtitles.tsx new file mode 100644 index 0000000000..e0e89feb16 --- /dev/null +++ b/src/state/preferences/subtitles.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext( + Boolean(persisted.defaults.subtitlesEnabled), +) +const setContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState( + Boolean(persisted.get('subtitlesEnabled')), + ) + + const setStateWrapped = React.useCallback( + (subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => { + setState(Boolean(subtitlesEnabled)) + persisted.write('subtitlesEnabled', subtitlesEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => { + setState(Boolean(nextSubtitlesEnabled)) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export const useSubtitlesEnabled = () => React.useContext(stateContext) +export const useSetSubtitlesEnabled = () => React.useContext(setContext) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 2c2e2163d7..a6e721d43c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -507,7 +507,6 @@ const styles = StyleSheet.create({ paddingRight: 15, // @ts-ignore web only -prf cursor: 'pointer', - overflow: 'hidden', }, replyLine: { width: 2, diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index e1a10e4741..9d9b1d8026 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -28,8 +28,6 @@ export type ListProps = Omit< // Web only prop to contain the scroll to the container rather than the window disableFullWindowScroll?: boolean sideBorders?: boolean - // Web only prop to disable a perf optimization (which would otherwise be on). - disableContainStyle?: boolean } export type ListRef = React.MutableRefObject diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 5aa699356d..5f89cfbbc9 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean import {batchedUpdates} from '#/lib/batchedUpdates' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useScrollHandlers} from '#/lib/ScrollContext' -import {isSafari} from 'lib/browser' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {addStyle} from 'lib/styles' +import {addStyle} from '#/lib/styles' export type ListMethods = any // TODO: Better types. export type ListProps = Omit< @@ -26,8 +25,6 @@ export type ListProps = Omit< // Web only prop to contain the scroll to the container rather than the window disableFullWindowScroll?: boolean sideBorders?: boolean - // Web only prop to disable a perf optimization (which would otherwise be on). - disableContainStyle?: boolean } export type ListRef = React.MutableRefObject // TODO: Better types. @@ -60,7 +57,6 @@ function ListImpl( extraData, style, sideBorders = true, - disableContainStyle, ...props }: ListProps, ref: React.Ref, @@ -364,7 +360,6 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} - disableContainStyle={disableContainStyle} /> ) })} @@ -442,7 +437,6 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, - disableContainStyle, }: { item: ItemT index: number @@ -452,7 +446,6 @@ let Row = function RowImpl({ | ((data: {index: number; item: any; separators: any}) => React.ReactNode) extraData: any onItemSeen: ((item: any) => void) | undefined - disableContainStyle?: boolean }): React.ReactNode { const rowRef = React.useRef(null) const intersectionTimeout = React.useRef(undefined) @@ -501,11 +494,8 @@ let Row = function RowImpl({ return null } - const shouldDisableContainStyle = disableContainStyle || isSafari return ( - + {renderItem({item, index, separators: null as any})} ) @@ -576,10 +566,6 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, - contain: { - // @ts-ignore web only - contain: 'layout paint', - }, minHeightViewport: { // @ts-ignore web only minHeight: '100vh', diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx index 6804436a7e..d18dfc0908 100644 --- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx @@ -1,37 +1,103 @@ -import React, {useCallback, useId, useMemo, useState} from 'react' +import React, { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' +import {useWindowDimensions} from 'react-native' +import {isNative} from '#/platform/detection' import {VideoPlayerProvider} from './VideoPlayerContext' const ActiveVideoContext = React.createContext<{ activeViewId: string | null setActiveView: (viewId: string, src: string) => void + sendViewPosition: (viewId: string, y: number) => void } | null>(null) export function ActiveVideoProvider({children}: {children: React.ReactNode}) { const [activeViewId, setActiveViewId] = useState(null) + const activeViewLocationRef = useRef(Infinity) const [source, setSource] = useState(null) + const {height: windowHeight} = useWindowDimensions() + + // minimising re-renders by using refs + const manuallySetRef = useRef(false) + const activeViewIdRef = useRef(activeViewId) + useEffect(() => { + activeViewIdRef.current = activeViewId + }, [activeViewId]) + + const setActiveView = useCallback( + (viewId: string, src: string) => { + setActiveViewId(viewId) + setSource(src) + manuallySetRef.current = true + // we don't know the exact position, but it's definitely on screen + // so just guess that it's in the middle. Any value is fine + // so long as it's not offscreen + activeViewLocationRef.current = windowHeight / 2 + }, + [windowHeight], + ) + + const sendViewPosition = useCallback( + (viewId: string, y: number) => { + if (isNative) return + + if (viewId === activeViewIdRef.current) { + activeViewLocationRef.current = y + } else { + if ( + distanceToIdealPosition(y) < + distanceToIdealPosition(activeViewLocationRef.current) + ) { + // if the old view was manually set, only usurp if the old view is offscreen + if ( + manuallySetRef.current && + withinViewport(activeViewLocationRef.current) + ) { + return + } + + setActiveViewId(viewId) + activeViewLocationRef.current = y + manuallySetRef.current = false + } + } + + function distanceToIdealPosition(yPos: number) { + return Math.abs(yPos - windowHeight / 2.5) + } + + function withinViewport(yPos: number) { + return yPos > 0 && yPos < windowHeight + } + }, + [windowHeight], + ) const value = useMemo( () => ({ activeViewId, - setActiveView: (viewId: string, src: string) => { - setActiveViewId(viewId) - setSource(src) - }, + setActiveView, + sendViewPosition, }), - [activeViewId], + [activeViewId, setActiveView, sendViewPosition], ) return ( - + {children} ) } -export function useActiveVideoView() { +export function useActiveVideoView({source}: {source: string}) { const context = React.useContext(ActiveVideoContext) if (!context) { throw new Error('useActiveVideo must be used within a ActiveVideoProvider') @@ -41,7 +107,12 @@ export function useActiveVideoView() { return { active: context.activeViewId === id, setActive: useCallback( - (source: string) => context.setActiveView(id, source), + () => context.setActiveView(id, source), + [context, id, source], + ), + currentActiveView: context.activeViewId, + sendPosition: useCallback( + (y: number) => context.sendViewPosition(id, y), [context, id], ), } diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 5e5293a553..429312d9e1 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -11,10 +11,10 @@ import {VideoEmbedInner} from './VideoEmbedInner' export function VideoEmbed({source}: {source: string}) { const t = useTheme() - const {active, setActive} = useActiveVideoView() + const {active, setActive} = useActiveVideoView({source}) const {_} = useLingui() - const onPress = useCallback(() => setActive(source), [setActive, source]) + const onPress = useCallback(() => setActive(), [setActive]) return ( {active ? ( - + ) : ( + )} + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx index ef06787097..9b1fd54fb6 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx @@ -13,7 +13,12 @@ import {atoms as a} from '#/alf' import {Text} from '#/components/Typography' import {useVideoPlayer} from './VideoPlayerContext' -export const VideoEmbedInner = ({}: {source: string}) => { +export function VideoEmbedInner({}: { + source: string + active: boolean + setActive: () => void + onScreen: boolean +}) { const player = useVideoPlayer() const aref = useAnimatedRef() const {height: windowHeight} = useWindowDimensions() diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx index cb02743c6f..f5f47db506 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx @@ -1,52 +1,93 @@ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' +import {View} from 'react-native' import Hls from 'hls.js' import {atoms as a} from '#/alf' +import {Controls} from './VideoWebControls' -export const VideoEmbedInner = ({source}: {source: string}) => { +export function VideoEmbedInner({ + source, + active, + setActive, + onScreen, +}: { + source: string + active: boolean + setActive: () => void + onScreen: boolean +}) { + const containerRef = useRef(null) const ref = useRef(null) + const [focused, setFocused] = useState(false) + const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + + const hlsRef = useRef(undefined) - // Use HLS.js to play HLS video useEffect(() => { - if (ref.current) { - if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { - ref.current.src = source - } else if (Hls.isSupported()) { - var hls = new Hls() - hls.loadSource(source) - hls.attachMedia(ref.current) - } else { - // TODO: fallback + if (!ref.current) return + if (!Hls.isSupported()) throw new HLSUnsupportedError() + + const hls = new Hls({capLevelToPlayerSize: true}) + hlsRef.current = hls + + hls.attachMedia(ref.current) + hls.loadSource(source) + + // initial value, later on it's managed by Controls + hls.autoLevelCapping = 0 + + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { + if (data.subtitleTracks.length > 0) { + setHasSubtitleTrack(true) } + }) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() } }, [source]) - useEffect(() => { - if (ref.current) { - const observer = new IntersectionObserver( - ([entry]) => { - if (ref.current) { - if (entry.isIntersecting) { - if (ref.current.paused) { - ref.current.play() - } - } else { - if (!ref.current.paused) { - ref.current.pause() - } - } - } - }, - {threshold: 0}, - ) - - observer.observe(ref.current) - - return () => { - observer.disconnect() - } - } - }, []) - - return ) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 46bf4a5fd4..aa45d3acc8 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -180,6 +180,7 @@ let Feed = ({ ListHeaderComponent?: () => JSX.Element extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed + outsideHeaderOffset?: number }): React.ReactNode => { const theme = useTheme() const {track} = useAnalytics() diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index a6e721d43c..6660a8d9d6 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -356,7 +356,7 @@ let FeedItemInner = ({ postAuthor={post.author} onOpenEmbed={onOpenEmbed} /> - {__DEV__ && gate('videos') && ( + {gate('video_debug') && ( )} ( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') + const dedupe = useDedupe() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -77,6 +80,8 @@ function ListImpl( runOnJS(handleScrolledDownChange)(didScrollDown) } } + + runOnJS(dedupe)(updateActiveViewAsync) }, // Note: adding onMomentumBegin here makes simulator scroll // lag on Android. So either don't add it, or figure out why. diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 429312d9e1..887efac1ab 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,21 +1,20 @@ -import React, {useCallback} from 'react' +import React from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {useActiveVideoView} from './ActiveVideoContext' -import {VideoEmbedInner} from './VideoEmbedInner' export function VideoEmbed({source}: {source: string}) { const t = useTheme() const {active, setActive} = useActiveVideoView({source}) const {_} = useLingui() - const onPress = useCallback(() => setActive(), [setActive]) - return ( - {active ? ( - - ) : ( - - )} + { + if (isActive) { + setActive() + } + }}> + {active ? ( + + ) : ( + + )} + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 08932f91f1..70d887283e 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -3,13 +3,15 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, +} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' import {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoView} from './ActiveVideoContext' -import {VideoEmbedInner} from './VideoEmbedInner' -import {HLSUnsupportedError} from './VideoEmbedInner.web' export function VideoEmbed({source}: {source: string}) { const t = useTheme() @@ -60,7 +62,7 @@ export function VideoEmbed({source}: {source: string}) { - void - onScreen: boolean -}) { - const player = useVideoPlayer() - const aref = useAnimatedRef() - const {height: windowHeight} = useWindowDimensions() - const hasLeftView = useSharedValue(false) - const ref = useRef(null) - - const onEnterView = useCallback(() => { - if (player.status === 'readyToPlay') { - player.play() - } - }, [player]) - - const onLeaveView = useCallback(() => { - player.pause() - }, [player]) - - const enterFullscreen = useCallback(() => { - if (ref.current) { - ref.current.enterFullscreen() - } - }, []) - - useFrameCallback(() => { - const measurement = measure(aref) - - if (measurement) { - if (hasLeftView.value) { - // Check if the video is in view - if ( - measurement.pageY >= 0 && - measurement.pageY + measurement.height <= windowHeight - ) { - runOnJS(onEnterView)() - hasLeftView.value = false - } - } else { - // Check if the video is out of view - if ( - measurement.pageY + measurement.height < 0 || - measurement.pageY > windowHeight - ) { - runOnJS(onLeaveView)() - hasLeftView.value = true - } - } - } - }) - - return ( - - - - - ) -} - -function VideoControls({ - player, - enterFullscreen, -}: { - player: VideoPlayer - enterFullscreen: () => void -}) { - const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) - - useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(Math.floor(player.duration - player.currentTime)) - // how often should we update the time? - // 1000 gets out of sync with the video time - }, 250) - - return () => { - clearInterval(interval) - } - }, [player]) - - const minutes = Math.floor(currentTime / 60) - const seconds = String(currentTime % 60).padStart(2, '0') - - return ( - - - - {minutes}:{seconds} - - - - - ) -} - -const styles = StyleSheet.create({ - timeContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - position: 'absolute', - left: 5, - bottom: 5, - }, - timeElapsed: { - color: 'white', - fontSize: 12, - fontWeight: 'bold', - }, -}) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 0000000000..cc356fb069 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -0,0 +1,96 @@ +import React, {useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {VideoPlayer, VideoView} from 'expo-video' + +import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext' +import {android, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' + +export function VideoEmbedInnerNative() { + const player = useVideoPlayer() + const ref = useRef(null) + + return ( + + + ref.current?.enterFullscreen()} + /> + + ) +} + +function Controls({ + player, + enterFullscreen, +}: { + player: VideoPlayer + enterFullscreen: () => void +}) { + const [duration, setDuration] = useState(() => Math.floor(player.duration)) + const [currentTime, setCurrentTime] = useState(() => + Math.floor(player.currentTime), + ) + + const timeRemaining = duration - currentTime + const minutes = Math.floor(timeRemaining / 60) + const seconds = String(timeRemaining % 60).padStart(2, '0') + + useEffect(() => { + const interval = setInterval(() => { + // duration gets reset to 0 on loop + if (player.duration) setDuration(Math.floor(player.duration)) + setCurrentTime(Math.floor(player.currentTime)) + // how often should we update the time? + // 1000 gets out of sync with the video time + }, 250) + + return () => { + clearInterval(interval) + } + }, [player]) + + if (isNaN(timeRemaining)) { + return null + } + + return ( + + + + {minutes}:{seconds} + + + + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 0000000000..59da5be42a --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 0000000000..8664aae142 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx similarity index 88% rename from src/view/com/util/post-embeds/VideoEmbedInner.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index f5f47db506..c0021d9bb7 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -5,17 +5,23 @@ import Hls from 'hls.js' import {atoms as a} from '#/alf' import {Controls} from './VideoWebControls' -export function VideoEmbedInner({ +export function VideoEmbedInnerWeb({ source, active, setActive, onScreen, }: { source: string - active: boolean - setActive: () => void - onScreen: boolean + active?: boolean + setActive?: () => void + onScreen?: boolean }) { + if (active == null || setActive == null || onScreen == null) { + throw new Error( + 'active, setActive, and onScreen are required VideoEmbedInner props on web.', + ) + } + const containerRef = useRef(null) const ref = useRef(null) const [focused, setFocused] = useState(false) diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoWebControls.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx similarity index 99% rename from src/view/com/util/post-embeds/VideoWebControls.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx index 2843664be8..7caaf3abf7 100644 --- a/src/view/com/util/post-embeds/VideoWebControls.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx @@ -11,12 +11,12 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import type Hls from 'hls.js' -import {isIPhoneWeb} from '#/platform/detection' +import {isIPhoneWeb} from 'platform/detection' import { useAutoplayDisabled, useSetSubtitlesEnabled, useSubtitlesEnabled, -} from '#/state/preferences' +} from 'state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' From b3092413dd21b58340e4cec739770f6d10a70248 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 7 Aug 2024 17:13:29 -0700 Subject: [PATCH 27/30] Add logging of selected feed preference when displaying the following feed (#4789) --- src/lib/statsig/events.ts | 6 ++++++ src/view/screens/Home.tsx | 30 ++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 159061eac9..997a366a41 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -211,6 +211,12 @@ export type LogEvents = { 'feed:interstitial:profileCard:press': {} 'feed:interstitial:feedCard:press': {} + 'debug:followingPrefs': { + followingShowRepliesFromPref: 'all' | 'following' | 'off' + followingRepliesMinLikePref: number + } + 'debug:followingDisplayed': {} + 'test:all:always': {} 'test:all:sometimes': {} 'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index f7cecd872d..6ee8b3ada6 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -9,7 +9,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' -import {FeedParams} from '#/state/queries/post-feed' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' @@ -108,6 +108,30 @@ function HomeScreenReady({ } }, [selectedIndex]) + // Temporary, remove when finished debugging + const debugHasLoggedFollowingPrefs = React.useRef(false) + const debugLogFollowingPrefs = React.useCallback( + (feed: FeedDescriptor) => { + if (debugHasLoggedFollowingPrefs.current) return + if (feed !== 'following') return + logEvent('debug:followingPrefs', { + followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies + ? 'off' + : preferences.feedViewPrefs.hideRepliesByUnfollowed + ? 'following' + : 'all', + followingRepliesMinLikePref: + preferences.feedViewPrefs.hideRepliesByLikeCount, + }) + debugHasLoggedFollowingPrefs.current = true + }, + [ + preferences.feedViewPrefs.hideReplies, + preferences.feedViewPrefs.hideRepliesByLikeCount, + preferences.feedViewPrefs.hideRepliesByUnfollowed, + ], + ) + const {hasSession} = useSession() const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -136,6 +160,7 @@ function HomeScreenReady({ feedUrl: selectedFeed, reason: 'focus', }) + debugLogFollowingPrefs(selectedFeed) } }), ) @@ -182,8 +207,9 @@ function HomeScreenReady({ feedUrl: feed, reason, }) + debugLogFollowingPrefs(feed) }, - [allFeeds], + [allFeeds, debugLogFollowingPrefs], ) const onPressSelected = React.useCallback(() => { From 00fea10782676e3bcf7027ca3d037dcf82a25b99 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 05:56:22 +0100 Subject: [PATCH 28/30] Include popcluster in suggestion ranking (#4887) --- src/components/FeedInterstitials.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 2e8724143d..eca1c86f00 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string { tier = 'a' } else if (seenPost.feedContext?.startsWith('cluster')) { tier = 'b' - } else if (seenPost.feedContext?.startsWith('ntpc')) { + } else if (seenPost.feedContext === 'popcluster') { tier = 'c' - } else if (seenPost.feedContext?.startsWith('t-')) { + } else if (seenPost.feedContext?.startsWith('ntpc')) { tier = 'd' - } else if (seenPost.feedContext === 'nettop') { + } else if (seenPost.feedContext?.startsWith('t-')) { tier = 'e' - } else { + } else if (seenPost.feedContext === 'nettop') { tier = 'f' + } else { + tier = 'g' } let score = Math.round( Math.log( From a864f69849387f0dd69251fda92e4e569bd17e94 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 06:20:24 +0100 Subject: [PATCH 29/30] Keep interstitial fresh on refresh (#4888) --- src/view/com/posts/Feed.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index aa45d3acc8..ef46333193 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -212,8 +212,9 @@ let Feed = ({ isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(feed, feedParams, opts) - if (data?.pages[0]) { - lastFetchRef.current = data?.pages[0].fetchedAt + const lastFetchedAt = data?.pages[0].fetchedAt + if (lastFetchedAt) { + lastFetchRef.current = lastFetchedAt } const isEmpty = React.useMemo( () => !isFetching && !data?.pages?.some(page => page.slices.length), @@ -358,7 +359,7 @@ let Feed = ({ ...interstitial, params: {variant}, // overwrite key with unique value - key: [interstitial.type, variant].join(':'), + key: [interstitial.type, variant, lastFetchedAt].join(':'), } if (arr.length > interstitial.slot) { @@ -374,6 +375,7 @@ let Feed = ({ isFetched, isError, isEmpty, + lastFetchedAt, data, feedUri, feedIsDiscover, From af5262682eac63a54fb2f6351a5894b647251ab4 Mon Sep 17 00:00:00 2001 From: Minseo Lee Date: Thu, 8 Aug 2024 21:12:23 +0900 Subject: [PATCH 30/30] Added trans (#4890) --- src/lib/moderation/useModerationCauseDescription.ts | 2 +- src/view/screens/AppPasswords.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts index be9014029c..01ffbe5cf6 100644 --- a/src/lib/moderation/useModerationCauseDescription.ts +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -126,7 +126,7 @@ export function useModerationCauseDescription( } } if (def.identifier === 'porn' || def.identifier === 'sexual') { - strings.name = 'Adult Content' + strings.name = _(msg`Adult Content`) } return { diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 65cbb7374e..5bf9e8a160 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -268,7 +268,7 @@ function AppPassword({ size={14} /> - Allows access to direct messages + Allows access to direct messages )}