From 8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 30 Jul 2024 08:25:31 -0700 Subject: [PATCH 01/15] [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/15] 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/15] 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/15] [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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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{