diff options
Diffstat (limited to 'src/view')
144 files changed, 2476 insertions, 2299 deletions
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index ae18f1390..e205bb540 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg, Trans} from '@lingui/macro' diff --git a/src/view/com/auth/util/HelpTip.tsx b/src/view/com/auth/util/HelpTip.tsx index 0fac86bec..196f30412 100644 --- a/src/view/com/auth/util/HelpTip.tsx +++ b/src/view/com/auth/util/HelpTip.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' diff --git a/src/view/com/auth/util/TextInput.tsx b/src/view/com/auth/util/TextInput.tsx index 0ccbe6ac4..083dda555 100644 --- a/src/view/com/auth/util/TextInput.tsx +++ b/src/view/com/auth/util/TextInput.tsx @@ -1,4 +1,4 @@ -import React, {ComponentProps} from 'react' +import {ComponentProps} from 'react' import {StyleSheet, TextInput as RNTextInput, View} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1899966dc..e4b09cf0f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -56,12 +56,18 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' import {until} from '#/lib/async/until' -import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' +import { + MAX_GRAPHEME_LENGTH, + SUPPORTED_MIME_TYPES, + SupportedMimeTypes, +} from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {mimeToExt} from '#/lib/media/video/util' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {colors, s} from '#/lib/styles' @@ -110,6 +116,8 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -127,6 +135,8 @@ import { ThreadDraft, } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +import {getVideoMetadata} from './videos/pickVideo' +import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' type CancelRef = { onPressCancel: () => void @@ -246,7 +256,8 @@ export const ComposePost = ({ const onClose = useCallback(() => { closeComposer() - }, [closeComposer]) + clearThumbnailCache(queryClient) + }, [closeComposer, queryClient]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -297,6 +308,15 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) + const {needsEmailVerification} = useEmail() + const emailVerificationControl = useDialogControl() + + useEffect(() => { + if (needsEmailVerification) { + emailVerificationControl.open() + } + }, [needsEmailVerification, emailVerificationControl]) + const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { return @@ -570,6 +590,15 @@ export const ComposePost = ({ const isWebFooterSticky = !isNative && thread.posts.length > 1 return ( <BottomSheetPortalProvider> + <VerifyEmailDialog + control={emailVerificationControl} + onCloseWithoutVerifying={() => { + onClose() + }} + reasonText={_( + msg`Before creating a post, you must first verify your email.`, + )} + /> <KeyboardAvoidingView testID="composePostView" behavior={isIOS ? 'padding' : 'height'} @@ -723,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/')) { - onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) + if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if (isNative) return // web only + const [mimeType] = uri.slice('data:'.length).split(';') + if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { + Toast.show(_(msg`Unsupported video type`), 'xmark') + return + } + const name = `pasted.${mimeToExt(mimeType)}` + const file = await fetch(uri) + .then(res => res.blob()) + .then(blob => new File([blob], name, {type: mimeType})) + onSelectVideo(post.id, await getVideoMetadata(file)) } else { const res = await pasteImage(uri) onImageAdd([res]) } }, - [post.id, onSelectVideo, onImageAdd], + [post.id, onSelectVideo, onImageAdd, _], ) return ( @@ -986,17 +1025,6 @@ function ComposerEmbeds({ asset={video.asset} video={video.video} isActivePost={isActivePost} - setDimensions={(width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: video.abortController.signal, - }, - }) - }} clear={clearVideo} /> ) : null)} @@ -1244,12 +1272,12 @@ function useScrollTracker({ const contentHeight = useSharedValue(0) const hasScrolledToTop = useDerivedValue(() => - withTiming(contentOffset.value === 0 ? 1 : 0), + withTiming(contentOffset.get() === 0 ? 1 : 0), ) const hasScrolledToBottom = useDerivedValue(() => withTiming( - contentHeight.value - contentOffset.value - 5 <= scrollViewHeight.value + contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get() ? 1 : 0, ), @@ -1267,11 +1295,11 @@ function useScrollTracker({ }) => { 'worklet' if (typeof newContentHeight === 'number') - contentHeight.value = Math.floor(newContentHeight) + contentHeight.set(Math.floor(newContentHeight)) if (typeof newContentOffset === 'number') - contentOffset.value = Math.floor(newContentOffset) + contentOffset.set(Math.floor(newContentOffset)) if (typeof newScrollViewHeight === 'number') - scrollViewHeight.value = Math.floor(newScrollViewHeight) + scrollViewHeight.set(Math.floor(newScrollViewHeight)) }, [contentHeight, contentOffset, scrollViewHeight], ) @@ -1287,21 +1315,22 @@ function useScrollTracker({ }, }) - const onScrollViewContentSizeChange = useCallback( - (_width: number, height: number) => { - if (stickyBottom && height > contentHeight.value) { + const onScrollViewContentSizeChangeUIThread = useCallback( + (newContentHeight: number) => { + 'worklet' + const oldContentHeight = contentHeight.get() + let shouldScrollToBottom = false + if (stickyBottom && newContentHeight > oldContentHeight) { const isFairlyCloseToBottom = - contentHeight.value - contentOffset.value - 100 <= - scrollViewHeight.value + oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get() if (isFairlyCloseToBottom) { - runOnUI(() => { - scrollTo(scrollViewRef, 0, contentHeight.value, true) - })() + shouldScrollToBottom = true } } - showHideBottomBorder({ - newContentHeight: height, - }) + showHideBottomBorder({newContentHeight}) + if (shouldScrollToBottom) { + scrollTo(scrollViewRef, 0, newContentHeight, true) + } }, [ showHideBottomBorder, @@ -1313,6 +1342,13 @@ function useScrollTracker({ ], ) + const onScrollViewContentSizeChange = useCallback( + (_width: number, height: number) => { + runOnUI(onScrollViewContentSizeChangeUIThread)(height) + }, + [onScrollViewContentSizeChangeUIThread], + ) + const onScrollViewLayout = useCallback( (evt: LayoutChangeEvent) => { showHideBottomBorder({ @@ -1326,7 +1362,7 @@ function useScrollTracker({ return { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( - hasScrolledToTop.value, + hasScrolledToTop.get(), [0, 1], [t.atoms.border_contrast_medium.borderColor, 'transparent'], ), @@ -1336,7 +1372,7 @@ function useScrollTracker({ return { borderTopWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( - hasScrolledToBottom.value, + hasScrolledToBottom.get(), [0, 1], [t.atoms.border_contrast_medium.borderColor, 'transparent'], ), @@ -1581,7 +1617,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) { const animatedStyle = useAnimatedStyle(() => { return { - transform: [{rotateZ: `${rotate.value}deg`}], + transform: [{rotateZ: `${rotate.get()}deg`}], } }) diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx index 3ef9dad47..92102f847 100644 --- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index ef5f8a3a5..bd99b9f28 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import {TouchableOpacity, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index c7d9628d6..f2734e4ec 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, TextStyle, View, ViewStyle} from 'react-native' // @ts-ignore no type definition -prf import ProgressCircle from 'react-native-progress/Circle' diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 75eaa33d7..0718a1928 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Keyboard, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -158,22 +157,26 @@ function DialogInner({ <Toggle.Item name="porn" label={_(msg`Porn`)}> <Toggle.Checkbox /> <Toggle.LabelText> - <Trans>Porn</Trans> + <Trans>Adult</Trans> </Toggle.LabelText> </Toggle.Item> </View> </Toggle.Group> - <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> - {labels.includes('sexual') ? ( - <Trans>Pictures meant for adults.</Trans> - ) : labels.includes('nudity') ? ( - <Trans>Artistic or non-erotic nudity.</Trans> - ) : labels.includes('porn') ? ( - <Trans>Sexual activity or erotic nudity.</Trans> - ) : ( - <Trans>Does not contain adult content.</Trans> - )} - </Text> + {labels.includes('sexual') || + labels.includes('nudity') || + labels.includes('porn') ? ( + <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> + {labels.includes('sexual') ? ( + <Trans>Pictures meant for adults.</Trans> + ) : labels.includes('nudity') ? ( + <Trans>Artistic or non-erotic nudity.</Trans> + ) : labels.includes('porn') ? ( + <Trans>Sexual activity or erotic nudity.</Trans> + ) : ( + '' + )} + </Text> + ) : null} </View> </View> <View> @@ -203,16 +206,14 @@ function DialogInner({ </Toggle.LabelText> </Toggle.Item> </Toggle.Group> - <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> - {labels.includes('graphic-media') ? ( + {labels.includes('graphic-media') ? ( + <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}> <Trans> Media that may be disturbing or inappropriate for some audiences. </Trans> - ) : ( - <Trans>Does not contain graphic or disturbing content.</Trans> - )} - </Text> + </Text> + ) : null} </View> </View> </View> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 79d59a92d..fb3ab5c8f 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import * as MediaLibrary from 'expo-media-library' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index 74f9acdc6..3fb0e00d2 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useRef} from 'react' +import {useCallback, useRef} from 'react' import {Keyboard} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 37bfbafe6..f4c6aa328 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 94dbc35c6..cd3cb608d 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react' +import {useCallback, useMemo} from 'react' import {Keyboard, StyleSheet} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index e915f4c66..6d55aeb53 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react' +import {useEffect, useState} from 'react' import {StyleSheet, View} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 8814a7e61..7ce4a0cf8 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -37,12 +37,6 @@ export type VideoAction = } | {type: 'update_progress'; progress: number; signal: AbortSignal} | { - type: 'update_dimensions' - width: number - height: number - signal: AbortSignal - } - | { type: 'update_alt_text' altText: string signal: AbortSignal @@ -185,13 +179,6 @@ export function videoReducer( progress: action.progress, } } - } else if (action.type === 'update_dimensions') { - if (state.asset) { - return { - ...state, - asset: {...state.asset, width: action.width, height: action.height}, - } - } } else if (action.type === 'update_alt_text') { return { ...state, diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 10cf1a931..96cecb37c 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -31,7 +31,7 @@ import { suggestLinkCardUri, } from '#/view/com/composer/text-input/text-input-util' import {atoms as a, useAlf} from '#/alf' -import {normalizeTextStyles} from '#/components/Typography' +import {normalizeTextStyles} from '#/alf/typography' import {Autocomplete} from './mobile/Autocomplete' export interface TextInputRef { diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index fa742d258..8ec4fefa8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,6 +11,7 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' +import {Fragment, Node, Slice} from '@tiptap/pm/model' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' @@ -23,8 +24,8 @@ import { } from '#/view/com/composer/text-input/text-input-util' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' -import {normalizeTextStyles} from '#/components/Typography' import {Text} from '../../util/text/Text' import {createSuggestion} from './web/Autocomplete' import {Emoji} from './web/EmojiPicker.web' @@ -166,6 +167,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( const editor = useEditor( { extensions, + coreExtensionOptions: { + clipboardTextSerializer: { + blockSeparator: '\n', + }, + }, onFocus() { onFocus?.() }, @@ -173,6 +179,20 @@ export const TextInput = React.forwardRef(function TextInputImpl( attributes: { class: modeClass, }, + clipboardTextParser: (text, context) => { + const blocks = text.split(/(?:\r\n?|\n)/) + const nodes: Node[] = blocks.map(line => { + return Node.fromJSON( + context.doc.type.schema, + line.length > 0 + ? {type: 'paragraph', content: [{type: 'text', text: line}]} + : {type: 'paragraph', content: []}, + ) + }) + + const fragment = Fragment.fromArray(nodes) + return Slice.maxOpen(fragment) + }, handlePaste: (view, event) => { const clipboardData = event.clipboardData let preventDefault = false @@ -205,6 +225,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( autofocus: 'end', editable: true, injectCSS: true, + shouldRerenderOnTransaction: false, onCreate({editor: editorProp}) { // HACK // the 'enter' animation sometimes causes autofocus to fail @@ -297,15 +318,9 @@ export const TextInput = React.forwardRef(function TextInputImpl( style.lineHeight = style.lineHeight ? ((style.lineHeight + 'px') as unknown as number) : undefined + style.minHeight = webForceMinHeight ? 140 : undefined return style - }, [t, fonts]) - - React.useLayoutEffect(() => { - let node = editor?.view.dom - if (node) { - node.style.minHeight = webForceMinHeight ? '140px' : '' - } - }, [editor, webForceMinHeight]) + }, [t, fonts, webForceMinHeight]) return ( <> diff --git a/src/view/com/composer/text-input/hooks/useGrapheme.tsx b/src/view/com/composer/text-input/hooks/useGrapheme.tsx index 01b5b9698..aa375ff47 100644 --- a/src/view/com/composer/text-input/hooks/useGrapheme.tsx +++ b/src/view/com/composer/text-input/hooks/useGrapheme.tsx @@ -13,7 +13,7 @@ export const useGrapheme = () => { if (graphemes.length > length) { remainingCharacters = 0 - name = `${graphemes.slice(0, length).join('')}...` + name = `${graphemes.slice(0, length).join('')}…` } else { remainingCharacters = length - graphemes.length name = graphemes.join('') diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 3d2bcfa61..0fda6843b 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,7 +1,5 @@ -import React, {useRef} from 'react' import {View} from 'react-native' import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated' -import {AppBskyActorDefs} from '@atproto/api' import {Trans} from '@lingui/macro' import {PressableScale} from '#/lib/custom-animations/PressableScale' @@ -11,7 +9,6 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' -import {useGrapheme} from '../hooks/useGrapheme' export function Autocomplete({ prefix, @@ -22,15 +19,11 @@ export function Autocomplete({ }) { const t = useTheme() - const {getGraphemeString} = useGrapheme() const isActive = !!prefix - const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) - const suggestionsRef = useRef< - AppBskyActorDefs.ProfileViewBasic[] | undefined - >(undefined) - if (suggestions) { - suggestionsRef.current = suggestions - } + const {data: suggestions, isFetching} = useActorAutocompleteQuery( + prefix, + true, + ) if (!isActive) return null @@ -46,26 +39,8 @@ export function Autocomplete({ t.atoms.border_contrast_high, {marginLeft: -62}, ]}> - {suggestionsRef.current?.length ? ( - suggestionsRef.current.slice(0, 5).map((item, index, arr) => { - // Eventually use an average length - const MAX_CHARS = 40 - const MAX_HANDLE_CHARS = 20 - - // Using this approach because styling is not respecting - // bounding box wrapping (before converting to ellipsis) - const {name: displayHandle, remainingCharacters} = getGraphemeString( - item.handle, - MAX_HANDLE_CHARS, - ) - - const {name: displayName} = getGraphemeString( - item.displayName || item.handle, - MAX_CHARS - - MAX_HANDLE_CHARS + - (remainingCharacters > 0 ? remainingCharacters : 0), - ) - + {suggestions?.length ? ( + suggestions.slice(0, 5).map((item, index, arr) => { return ( <View style={[ @@ -93,15 +68,23 @@ export function Autocomplete({ type={item.associated?.labeler ? 'labeler' : 'user'} /> <Text - style={[a.text_md, a.font_bold]} - emoji={true} + style={[a.flex_1, a.text_md, a.font_bold]} + emoji + numberOfLines={1}> + {sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + )} + </Text> + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_right, + {maxWidth: '50%'}, + ]} numberOfLines={1}> - {sanitizeDisplayName(displayName)} + {sanitizeHandle(item.handle, '@')} </Text> </View> - <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}> - {sanitizeHandle(displayHandle, '@')} - </Text> </PressableScale> </View> ) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index a43e67c04..f40c2ee8d 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -1,9 +1,4 @@ -import React, { - forwardRef, - useEffect, - useImperativeHandle, - useState, -} from 'react' +import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' import {Pressable, StyleSheet, View} from 'react-native' import {Trans} from '@lingui/macro' import {ReactRenderer} from '@tiptap/react' @@ -15,6 +10,8 @@ import { import tippy, {Instance as TippyInstance} from 'tippy.js' import {usePalette} from '#/lib/hooks/usePalette' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' @@ -153,7 +150,9 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( {items.length > 0 ? ( items.map((item, index) => { const {name: displayName} = getGraphemeString( - item.displayName ?? item.handle, + sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + ), 30, // Heuristic value; can be modified ) const isSelected = selectedIndex === index @@ -186,7 +185,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( </Text> </View> <Text type="xs" style={pal.textLight} numberOfLines={1}> - @{item.handle} + {sanitizeHandle(item.handle, '@')} </Text> </Pressable> ) diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 78bf8c06f..4130cc7e4 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Keyboard, StyleProp, ViewStyle} from 'react-native' import {AnimatedStyle} from 'react-native-reanimated' import {AppBskyFeedPostgate} from '@atproto/api' diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 2ba003a6d..1b052ccdd 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,11 +1,6 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import {Keyboard} from 'react-native' -import { - ImagePickerAsset, - launchImageLibraryAsync, - MediaTypeOptions, - UIImagePickerPreferredAssetRepresentationMode, -} from 'expo-image-picker' +import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import * as Prompt from '#/components/Prompt' +import {pickVideo} from './pickVideo' const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds @@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { Keyboard.dismiss() control.open() } else { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Videos, - quality: 1, - legacy: true, - preferredAssetRepresentationMode: - UIImagePickerPreferredAssetRepresentationMode.Current, - }) + const response = await pickVideo() if (response.assets && response.assets.length > 0) { const asset = response.assets[0] try { if (isWeb) { + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) + if (asset.duration && asset.duration > VIDEO_MAX_DURATION) { + throw Error(_(msg`Videos must be less than 60 seconds long`)) + } // compression step on native converts to mp4, so no need to check there - const mimeType = getMimeType(asset) if ( - !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) + !SUPPORTED_MIME_TYPES.includes( + asset.mimeType as SupportedMimeTypes, + ) ) { - throw Error(_(msg`Unsupported video type: ${mimeType}`)) + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) } } else { if (typeof asset.duration !== 'number') { @@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { </> ) } - -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType - } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') - } - return asset.mimeType -} diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index 27c3de02b..e907dc41c 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' import RNPickerSelect from 'react-native-picker-select' import {msg, Trans} from '@lingui/macro' diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index fff7545a5..255174bea 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -20,7 +20,6 @@ export function VideoPreview({ asset: ImagePickerAsset video: CompressedVideo isActivePost: boolean - setDimensions: (width: number, height: number) => void clear: () => void }) { const t = useTheme() diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx index ccb3391c2..f20f8b383 100644 --- a/src/view/com/composer/videos/VideoPreview.web.tsx +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -1,4 +1,3 @@ -import React, {useEffect, useRef} from 'react' import {View} from 'react-native' import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' @@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -const MAX_DURATION = 60 - export function VideoPreview({ asset, video, - setDimensions, + clear, }: { asset: ImagePickerAsset video: CompressedVideo - setDimensions: (width: number, height: number) => void + clear: () => void }) { - const ref = useRef<HTMLVideoElement>(null) const {_} = useLingui() + // TODO: figure out how to pause a GIF for reduced motion + // it's not possible using an img tag -sfn const autoplayDisabled = useAutoplayDisabled() - useEffect(() => { - if (!ref.current) return - - const abortController = new AbortController() - const {signal} = abortController - ref.current.addEventListener( - 'loadedmetadata', - function () { - setDimensions(this.videoWidth, this.videoHeight) - if (!isNaN(this.duration)) { - if (this.duration > MAX_DURATION) { - Toast.show( - _(msg`Videos must be less than 60 seconds long`), - 'xmark', - ) - clear() - } - } - }, - {signal}, - ) - ref.current.addEventListener( - 'error', - () => { - Toast.show(_(msg`Could not process your video`), 'xmark') - clear() - }, - {signal}, - ) - - return () => { - abortController.abort() - } - }, [setDimensions, _, clear]) - let aspectRatio = asset.width / asset.height if (isNaN(aspectRatio)) { @@ -83,19 +46,34 @@ export function VideoPreview({ a.relative, ]}> <ExternalEmbedRemoveBtn onRemove={clear} /> - <video - ref={ref} - src={video.uri} - style={{width: '100%', height: '100%', objectFit: 'cover'}} - autoPlay={!autoplayDisabled} - loop - muted - playsInline - /> - {autoplayDisabled && ( - <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> - <PlayButtonIcon /> - </View> + {video.mimeType === 'image/gif' ? ( + <img + src={video.uri} + style={{width: '100%', height: '100%', objectFit: 'cover'}} + alt="GIF" + /> + ) : ( + <> + <video + src={video.uri} + style={{width: '100%', height: '100%', objectFit: 'cover'}} + autoPlay={!autoplayDisabled} + loop + muted + playsInline + onError={err => { + console.error('Error loading video', err) + Toast.show(_(msg`Could not process your video`), 'xmark') + clear() + }} + /> + {autoplayDisabled && ( + <View + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <PlayButtonIcon /> + </View> + )} + </> )} </View> ) diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx index ef38e62af..caf0b38e2 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -1,25 +1,25 @@ -import React, {useEffect} from 'react' import {clearCache, createVideoThumbnail} from 'react-native-compressor' import Animated, {FadeIn} from 'react-native-reanimated' import {Image} from 'expo-image' -import {useQuery} from '@tanstack/react-query' +import {QueryClient, useQuery} from '@tanstack/react-query' import {atoms as a} from '#/alf' +export const RQKEY = 'video-thumbnail' + +export function clearThumbnailCache(queryClient: QueryClient) { + clearCache() + queryClient.resetQueries({queryKey: [RQKEY]}) +} + export function VideoTranscodeBackdrop({uri}: {uri: string}) { const {data: thumbnail} = useQuery({ - queryKey: ['thumbnail', uri], + queryKey: [RQKEY, uri], queryFn: async () => { return await createVideoThumbnail(uri) }, }) - useEffect(() => { - return () => { - clearCache() - } - }, []) - return ( thumbnail && ( <Animated.View style={a.flex_1} entering={FadeIn}> diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx index d4090d853..a04200f53 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -1,3 +1,7 @@ +export function clearThumbnailCache() { + // no-op +} + export function VideoTranscodeBackdrop() { return null } diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index f6f0f7ccf..f408be720 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' // @ts-expect-error no type definition import ProgressPie from 'react-native-progress/Pie' diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts new file mode 100644 index 000000000..0edf7d0de --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.ts @@ -0,0 +1,21 @@ +import { + ImagePickerAsset, + launchImageLibraryAsync, + MediaTypeOptions, + UIImagePickerPreferredAssetRepresentationMode, +} from 'expo-image-picker' + +export async function pickVideo() { + return await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Videos, + quality: 1, + legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + }) +} + +export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => { + throw new Error('getVideoMetadata is web only') +} diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts new file mode 100644 index 000000000..56a38fa56 --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.web.ts @@ -0,0 +1,94 @@ +import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker' + +import {SUPPORTED_MIME_TYPES} from '#/lib/constants' + +// mostly copied from expo-image-picker and adapted to support gifs +// also adds support for reading video metadata + +export async function pickVideo(): Promise<ImagePickerResult> { + const input = document.createElement('input') + input.style.display = 'none' + input.setAttribute('type', 'file') + // TODO: do we need video/* here? -sfn + input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(',')) + input.setAttribute('id', String(Math.random())) + + document.body.appendChild(input) + + return new Promise(resolve => { + input.addEventListener('change', async () => { + if (input.files) { + const file = input.files[0] + resolve({ + canceled: false, + assets: [await getVideoMetadata(file)], + }) + } else { + resolve({canceled: true, assets: null}) + } + document.body.removeChild(input) + }) + + const event = new MouseEvent('click') + input.dispatchEvent(event) + }) +} + +// TODO: we're converting to a dataUrl here, and then converting back to an +// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it +// lets us use the ImagePickerAsset type, which the rest of the code expects. +// We should unwind this and just pass the ArrayBuffer/objectUrl through the system +// instead of a string -sfn +export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const uri = reader.result as string + + if (file.type === 'image/gif') { + const img = new Image() + img.onload = () => { + resolve({ + uri, + mimeType: 'image/gif', + width: img.width, + height: img.height, + // todo: calculate gif duration. seems possible if you read the bytes + // https://codepen.io/Ryman/pen/nZpYwY + // for now let's just let the server reject it, since that seems uncommon -sfn + duration: null, + }) + } + img.onerror = (_ev, _source, _lineno, _colno, error) => { + console.log('Failed to grab GIF metadata', error) + reject(new Error('Failed to grab GIF metadata')) + } + img.src = uri + } else { + const video = document.createElement('video') + const blobUrl = URL.createObjectURL(file) + + video.preload = 'metadata' + video.src = blobUrl + + video.onloadedmetadata = () => { + URL.revokeObjectURL(blobUrl) + resolve({ + uri, + mimeType: file.type, + width: video.videoWidth, + height: video.videoHeight, + // convert seconds to ms + duration: video.duration * 1000, + }) + } + video.onerror = (_ev, _source, _lineno, _colno, error) => { + URL.revokeObjectURL(blobUrl) + console.log('Failed to grab video metadata', error) + reject(new Error('Failed to grab video metadata')) + } + } + } + reader.readAsDataURL(file) + }) +} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index d61a81498..1028d7e64 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -74,7 +74,7 @@ export function FeedPage({ scrollToTop() truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) setHasNew(false) - logEvent('feed:refresh:sampled', { + logEvent('feed:refresh', { feedType: feed.split('|')[0], feedUrl: feed, reason: 'soft-reset', @@ -98,7 +98,7 @@ export function FeedPage({ scrollToTop() truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) setHasNew(false) - logEvent('feed:refresh:sampled', { + logEvent('feed:refresh', { feedType: feed.split('|')[0], feedUrl: feed, reason: 'load-latest', diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 3276cf882..707aad7fb 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -162,7 +162,10 @@ export function FeedSourceCardLoaded({ style={[ pal.border, { - borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1, + borderTopWidth: + showMinimalPlaceholder || hideTopBorder + ? 0 + : StyleSheet.hairlineWidth, flexDirection: 'row', alignItems: 'center', flex: 1, diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 693a8e361..64705ded8 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -1,8 +1,10 @@ import React from 'react' import { + ActivityIndicator, findNodeHandle, ListRenderItemInfo, StyleProp, + StyleSheet, View, ViewStyle, } from 'react-native' @@ -10,6 +12,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' @@ -57,6 +60,7 @@ export const ProfileFeedgens = React.forwardRef< data, isFetching, isFetched, + isFetchingNextPage, hasNextPage, fetchNextPage, isError, @@ -65,6 +69,7 @@ export const ProfileFeedgens = React.forwardRef< } = useProfileFeedgensQuery(did, opts) const isEmpty = !isFetching && !data?.pages[0]?.feeds.length const {data: preferences} = usePreferencesQuery() + const {isMobile} = useWebMediaQueries() const items = React.useMemo(() => { let items: any[] = [] @@ -180,6 +185,12 @@ export const ProfileFeedgens = React.forwardRef< } }, [enabled, scrollElRef, setScrollViewTag]) + const ProfileFeedgensFooter = React.useCallback(() => { + return isFetchingNextPage ? ( + <ActivityIndicator style={[styles.footer]} /> + ) : null + }, [isFetchingNextPage]) + return ( <View testID={testID} style={style}> <List @@ -188,11 +199,12 @@ export const ProfileFeedgens = React.forwardRef< data={items} keyExtractor={(item: any) => item._reactKey || item.uri} renderItem={renderItem} + ListFooterComponent={ProfileFeedgensFooter} refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} progressViewOffset={ios(0)} - contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}} + contentContainerStyle={isMobile && {paddingBottom: headerOffset + 100}} indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} // @ts-ignore our .web version only -prf @@ -202,3 +214,7 @@ export const ProfileFeedgens = React.forwardRef< </View> ) }) + +const styles = StyleSheet.create({ + footer: {paddingTop: 20}, +}) diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 7049306eb..bdfc2c7ff 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -93,7 +93,7 @@ function HomeHeaderLayoutDesktopAndTablet({ {tabBarAnchor} <Animated.View onLayout={e => { - headerHeight.value = e.nativeEvent.layout.height + headerHeight.set(e.nativeEvent.layout.height) }} style={[ t.atoms.bg, diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index f5397d717..832396092 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -41,12 +41,12 @@ export function HomeHeaderLayoutMobile({ return ( <Animated.View - style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} + style={[pal.border, styles.tabBar, headerMinimalShellTransform]} onLayout={e => { - headerHeight.value = e.nativeEvent.layout.height + headerHeight.set(e.nativeEvent.layout.height) }}> <View style={[pal.view, styles.topBar]}> - <View style={[pal.view, {width: 100}]}> + <View style={[{width: 100}]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} @@ -68,7 +68,6 @@ export function HomeHeaderLayoutMobile({ atoms.justify_end, atoms.align_center, atoms.gap_md, - pal.view, {width: 100}, ]}> {IS_DEV && ( diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index e7caa58a8..7a37c7e41 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. * */ -import React from 'react' import { SafeAreaView, StyleSheet, diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index 260787d2f..8e046e5ba 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -87,11 +87,11 @@ const ImageItem = ({ // Note: DO NOT move any logic reading animated values outside this function. useAnimatedReaction( () => { - if (pinchScale.value !== 1) { + if (pinchScale.get() !== 1) { // We're currently pinching. return true } - const [, , committedScale] = readTransform(committedTransform.value) + const [, , committedScale] = readTransform(committedTransform.get()) if (committedScale !== 1) { // We started from a pinched in state. return true @@ -147,10 +147,10 @@ const ImageItem = ({ .onStart(e => { 'worklet' const screenSize = measureSafeArea() - pinchOrigin.value = { + pinchOrigin.set({ x: e.focalX - screenSize.width / 2, y: e.focalY - screenSize.height / 2, - } + }) }) .onChange(e => { 'worklet' @@ -160,7 +160,7 @@ const ImageItem = ({ } // Don't let the picture zoom in so close that it gets blurry. // Also, like in stock Android apps, don't let the user zoom out further than 1:1. - const [, , committedScale] = readTransform(committedTransform.value) + const [, , committedScale] = readTransform(committedTransform.get()) const maxCommittedScale = Math.max( MIN_SCREEN_ZOOM, (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM, @@ -171,20 +171,21 @@ const ImageItem = ({ Math.max(minPinchScale, e.scale), maxPinchScale, ) - pinchScale.value = nextPinchScale + pinchScale.set(nextPinchScale) // Zooming out close to the corner could push us out of bounds, which we don't want on Android. // Calculate where we'll end up so we know how much to translate back to stay in bounds. const t = createTransform() - prependPan(t, panTranslation.value) - prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) - prependTransform(t, committedTransform.value) + prependPan(t, panTranslation.get()) + prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get()) + prependTransform(t, committedTransform.get()) const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) if (dx !== 0 || dy !== 0) { - pinchTranslation.value = { - x: pinchTranslation.value.x + dx, - y: pinchTranslation.value.y + dy, - } + const pt = pinchTranslation.get() + pinchTranslation.set({ + x: pt.x + dx, + y: pt.y + dy, + }) } }) .onEnd(() => { @@ -193,18 +194,18 @@ const ImageItem = ({ let t = createTransform() prependPinch( t, - pinchScale.value, - pinchOrigin.value, - pinchTranslation.value, + pinchScale.get(), + pinchOrigin.get(), + pinchTranslation.get(), ) - prependTransform(t, committedTransform.value) + prependTransform(t, committedTransform.get()) applyRounding(t) - committedTransform.value = t + committedTransform.set(t) // Reset just the pinch. - pinchScale.value = 1 - pinchOrigin.value = {x: 0, y: 0} - pinchTranslation.value = {x: 0, y: 0} + pinchScale.set(1) + pinchOrigin.set({x: 0, y: 0}) + pinchTranslation.set({x: 0, y: 0}) }) const pan = Gesture.Pan() @@ -223,29 +224,29 @@ const ImageItem = ({ prependPan(t, nextPanTranslation) prependPinch( t, - pinchScale.value, - pinchOrigin.value, - pinchTranslation.value, + pinchScale.get(), + pinchOrigin.get(), + pinchTranslation.get(), ) - prependTransform(t, committedTransform.value) + prependTransform(t, committedTransform.get()) // Prevent panning from going out of bounds. const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) nextPanTranslation.x += dx nextPanTranslation.y += dy - panTranslation.value = nextPanTranslation + panTranslation.set(nextPanTranslation) }) .onEnd(() => { 'worklet' // Commit just the pan. let t = createTransform() - prependPan(t, panTranslation.value) - prependTransform(t, committedTransform.value) + prependPan(t, panTranslation.get()) + prependTransform(t, committedTransform.get()) applyRounding(t) - committedTransform.value = t + committedTransform.set(t) // Reset just the pan. - panTranslation.value = {x: 0, y: 0} + panTranslation.set({x: 0, y: 0}) }) const singleTap = Gesture.Tap().onEnd(() => { @@ -261,11 +262,11 @@ const ImageItem = ({ if (!imageDimensions || !imageAspect) { return } - const [, , committedScale] = readTransform(committedTransform.value) + const [, , committedScale] = readTransform(committedTransform.get()) if (committedScale !== 1) { // Go back to 1:1 using the identity vector. let t = createTransform() - committedTransform.value = withClampedSpring(t) + committedTransform.set(withClampedSpring(t)) return } @@ -299,7 +300,7 @@ const ImageItem = ({ ) const finalTransform = createTransform() prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) - committedTransform.value = withClampedSpring(finalTransform) + committedTransform.set(withClampedSpring(finalTransform)) }) const composedGesture = isScrollViewBeingDragged @@ -313,13 +314,13 @@ const ImageItem = ({ ) const containerStyle = useAnimatedStyle(() => { - const {scaleAndMoveTransform, isHidden} = transforms.value + const {scaleAndMoveTransform, isHidden} = transforms.get() // Apply the active adjustments on top of the committed transform before the gestures. // This is matrix multiplication, so operations are applied in the reverse order. let t = createTransform() - prependPan(t, panTranslation.value) - prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) - prependTransform(t, committedTransform.value) + prependPan(t, panTranslation.get()) + prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get()) + prependTransform(t, committedTransform.get()) const [translateX, translateY, scale] = readTransform(t) const manipulationTransform = [ {translateX}, @@ -338,7 +339,7 @@ const ImageItem = ({ }) const imageCropStyle = useAnimatedStyle(() => { - const {cropFrameTransform} = transforms.value + const {cropFrameTransform} = transforms.get() return { flex: 1, overflow: 'hidden', @@ -347,7 +348,7 @@ const ImageItem = ({ }) const imageStyle = useAnimatedStyle(() => { - const {cropContentTransform} = transforms.value + const {cropContentTransform} = transforms.get() return { flex: 1, transform: cropContentTransform, @@ -359,13 +360,13 @@ const ImageItem = ({ const [hasLoaded, setHasLoaded] = useState(false) useAnimatedReaction( () => { - return transforms.value.isResting && !hasLoaded + return transforms.get().isResting && !hasLoaded }, (show, prevShow) => { - if (show && !prevShow) { - runOnJS(setShowLoader)(false) - } else if (!prevShow && show) { + if (!prevShow && show) { runOnJS(setShowLoader)(true) + } else if (prevShow && !show) { + runOnJS(setShowLoader)(false) } }, ) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index f06a59ed6..c103e131b 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -16,9 +16,11 @@ import { import Animated, { runOnJS, SharedValue, + useAnimatedProps, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, + useSharedValue, } from 'react-native-reanimated' import {useSafeAreaFrame} from 'react-native-safe-area-context' import {Image} from 'expo-image' @@ -75,6 +77,7 @@ const ImageItem = ({ }: Props) => { const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const [scaled, setScaled] = useState(false) + const isDragging = useSharedValue(false) const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() const maxZoomScale = Math.max( MIN_SCREEN_ZOOM, @@ -86,11 +89,20 @@ const ImageItem = ({ const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { + 'worklet' const nextIsScaled = e.zoomScale > 1 if (scaled !== nextIsScaled) { runOnJS(handleZoom)(nextIsScaled) } }, + onBeginDrag() { + 'worklet' + isDragging.value = true + }, + onEndDrag() { + 'worklet' + isDragging.value = false + }, }) function handleZoom(nextIsScaled: boolean) { @@ -148,7 +160,7 @@ const ImageItem = ({ ) const containerStyle = useAnimatedStyle(() => { - const {scaleAndMoveTransform, isHidden} = transforms.value + const {scaleAndMoveTransform, isHidden} = transforms.get() return { flex: 1, transform: scaleAndMoveTransform, @@ -158,7 +170,7 @@ const ImageItem = ({ const imageCropStyle = useAnimatedStyle(() => { const screenSize = measureSafeArea() - const {cropFrameTransform} = transforms.value + const {cropFrameTransform} = transforms.get() return { overflow: 'hidden', transform: cropFrameTransform, @@ -171,7 +183,7 @@ const ImageItem = ({ }) const imageStyle = useAnimatedStyle(() => { - const {cropContentTransform} = transforms.value + const {cropContentTransform} = transforms.get() return { transform: cropContentTransform, width: '100%', @@ -184,13 +196,13 @@ const ImageItem = ({ const [hasLoaded, setHasLoaded] = useState(false) useAnimatedReaction( () => { - return transforms.value.isResting && !hasLoaded + return transforms.get().isResting && !hasLoaded }, (show, prevShow) => { - if (show && !prevShow) { - runOnJS(setShowLoader)(false) - } else if (!prevShow && show) { + if (!prevShow && show) { runOnJS(setShowLoader)(true) + } else if (prevShow && !show) { + runOnJS(setShowLoader)(false) } }, ) @@ -199,6 +211,11 @@ const ImageItem = ({ const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + const scrollViewProps = useAnimatedProps(() => ({ + // Don't allow bounce at 1:1 rest so it can be swiped away. + bounces: scaled || isDragging.value, + })) + return ( <GestureDetector gesture={composedGesture}> <Animated.ScrollView @@ -210,8 +227,7 @@ const ImageItem = ({ maximumZoomScale={maxZoomScale} onScroll={scrollHandler} style={containerStyle} - bounces={scaled} - bouncesZoom={true} + animatedProps={scrollViewProps} centerContent> {showLoader && ( <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index ab8306b36..4ba056eb0 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -32,6 +32,7 @@ import Animated, { useSharedValue, withDecay, withSpring, + WithSpringConfig, } from 'react-native-reanimated' import { Edge, @@ -62,8 +63,18 @@ const EDGES = ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area -const SLOW_SPRING = {stiffness: 120} -const FAST_SPRING = {stiffness: 700} +const SLOW_SPRING: WithSpringConfig = { + mass: isIOS ? 1.25 : 0.75, + damping: 300, + stiffness: 800, + restDisplacementThreshold: 0.01, +} +const FAST_SPRING: WithSpringConfig = { + mass: isIOS ? 1.25 : 0.75, + damping: 150, + stiffness: 900, + restDisplacementThreshold: 0.01, +} export default function ImageViewRoot({ lightbox: nextLightbox, @@ -98,18 +109,22 @@ export default function ImageViewRoot({ // https://github.com/software-mansion/react-native-reanimated/issues/6677 requestAnimationFrame(() => { - openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 + openProgress.set(() => + canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1, + ) }) return () => { // https://github.com/software-mansion/react-native-reanimated/issues/6677 requestAnimationFrame(() => { - openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0 + openProgress.set(() => + canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0, + ) }) } }, [nextLightbox, openProgress]) useAnimatedReaction( - () => openProgress.value === 0, + () => openProgress.get() === 0, (isGone, wasGone) => { if (isGone && !wasGone) { runOnJS(setActiveLightbox)(null) @@ -119,7 +134,7 @@ export default function ImageViewRoot({ const onFlyAway = React.useCallback(() => { 'worklet' - openProgress.value = 0 + openProgress.set(0) runOnJS(onRequestClose)() }, [onRequestClose, openProgress]) @@ -176,7 +191,7 @@ function ImageView({ const isFlyingAway = useSharedValue(false) const containerStyle = useAnimatedStyle(() => { - if (openProgress.value < 1 || isFlyingAway.value) { + if (openProgress.get() < 1 || isFlyingAway.get()) { return {pointerEvents: 'none'} } return {pointerEvents: 'auto'} @@ -185,11 +200,12 @@ function ImageView({ const backdropStyle = useAnimatedStyle(() => { const screenSize = measure(safeAreaRef) let opacity = 1 - if (openProgress.value < 1) { - opacity = Math.sqrt(openProgress.value) + const openProgressValue = openProgress.get() + if (openProgressValue < 1) { + opacity = Math.sqrt(openProgressValue) } else if (screenSize) { const dragProgress = Math.min( - Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), + Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2), 1, ) opacity -= dragProgress @@ -201,11 +217,11 @@ function ImageView({ }) const animatedHeaderStyle = useAnimatedStyle(() => { - const show = showControls && dismissSwipeTranslateY.value === 0 + const show = showControls && dismissSwipeTranslateY.get() === 0 return { pointerEvents: show ? 'box-none' : 'none', opacity: withClampedSpring( - show && openProgress.value === 1 ? 1 : 0, + show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING, ), transform: [ @@ -216,12 +232,12 @@ function ImageView({ } }) const animatedFooterStyle = useAnimatedStyle(() => { - const show = showControls && dismissSwipeTranslateY.value === 0 + const show = showControls && dismissSwipeTranslateY.get() === 0 return { flexGrow: 1, pointerEvents: show ? 'box-none' : 'none', opacity: withClampedSpring( - show && openProgress.value === 1 ? 1 : 0, + show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING, ), transform: [ @@ -248,7 +264,7 @@ function ImageView({ const screenSize = measure(safeAreaRef) return ( !screenSize || - Math.abs(dismissSwipeTranslateY.value) > screenSize.height + Math.abs(dismissSwipeTranslateY.get()) > screenSize.height ) }, (isOut, wasOut) => { @@ -386,10 +402,11 @@ function LightboxImage({ const transforms = useDerivedValue(() => { 'worklet' const safeArea = measureSafeArea() + const openProgressValue = openProgress.get() const dismissTranslateY = - isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 + isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0 - if (openProgress.value === 0 && isFlyingAway.value) { + if (openProgressValue === 0 && isFlyingAway.get()) { return { isHidden: true, isResting: false, @@ -399,9 +416,9 @@ function LightboxImage({ } } - if (isActive && thumbRect && imageAspect && openProgress.value < 1) { + if (isActive && thumbRect && imageAspect && openProgressValue < 1) { return interpolateTransform( - openProgress.value, + openProgressValue, thumbRect, safeArea, imageAspect, @@ -423,33 +440,37 @@ function LightboxImage({ .maxPointers(1) .onUpdate(e => { 'worklet' - if (openProgress.value !== 1 || isFlyingAway.value) { + if (openProgress.get() !== 1 || isFlyingAway.get()) { return } - dismissSwipeTranslateY.value = e.translationY + dismissSwipeTranslateY.set(e.translationY) }) .onEnd(e => { 'worklet' - if (openProgress.value !== 1 || isFlyingAway.value) { + if (openProgress.get() !== 1 || isFlyingAway.get()) { return } - if (Math.abs(e.velocityY) > 1000) { - isFlyingAway.value = true - if (dismissSwipeTranslateY.value === 0) { + if (Math.abs(e.velocityY) > 200) { + isFlyingAway.set(true) + if (dismissSwipeTranslateY.get() === 0) { // HACK: If the initial value is 0, withDecay() animation doesn't start. // This is a bug in Reanimated, but for now we'll work around it like this. - dismissSwipeTranslateY.value = 1 + dismissSwipeTranslateY.set(1) } - dismissSwipeTranslateY.value = withDecay({ - velocity: e.velocityY, - velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. - deceleration: 1, // Danger! This relies on the reaction below stopping it. - }) + dismissSwipeTranslateY.set(() => + withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. + deceleration: 1, // Danger! This relies on the reaction below stopping it. + }), + ) } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 700, - damping: 50, - }) + dismissSwipeTranslateY.set(() => + withSpring(0, { + stiffness: 700, + damping: 50, + }), + ) } }) @@ -706,7 +727,7 @@ function interpolateTransform( } } -function withClampedSpring(value: any, {stiffness}: {stiffness: number}) { +function withClampedSpring(value: any, config: WithSpringConfig) { 'worklet' - return withSpring(value, {overshootClamping: true, stiffness}) + return withSpring(value, {...config, overshootClamping: true}) } diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index 27b7f94df..2f63fd172 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -1,8 +1,10 @@ import React from 'react' import { + ActivityIndicator, findNodeHandle, ListRenderItemInfo, StyleProp, + StyleSheet, View, ViewStyle, } from 'react-native' @@ -10,6 +12,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' @@ -56,10 +59,12 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( isFetched, hasNextPage, fetchNextPage, + isFetchingNextPage, isError, error, refetch, } = useProfileListsQuery(did, opts) + const {isMobile} = useWebMediaQueries() const isEmpty = !isFetching && !data?.pages[0]?.lists.length const items = React.useMemo(() => { @@ -176,6 +181,12 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( } }, [enabled, scrollElRef, setScrollViewTag]) + const ProfileListsFooter = React.useCallback(() => { + return isFetchingNextPage ? ( + <ActivityIndicator style={[styles.footer]} /> + ) : null + }, [isFetchingNextPage]) + return ( <View testID={testID} style={style}> <List @@ -184,12 +195,13 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( data={items} keyExtractor={(item: any) => item._reactKey || item.uri} renderItem={renderItemInner} + ListFooterComponent={ProfileListsFooter} refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} progressViewOffset={ios(0)} contentContainerStyle={ - isNative && {paddingBottom: headerOffset + 100} + isMobile && {paddingBottom: headerOffset + 100} } indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} @@ -201,3 +213,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( ) }, ) + +const styles = StyleSheet.create({ + footer: {paddingTop: 20}, +}) diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index dc450705e..647a08c0e 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx index c40fcb5e3..d68b4e453 100644 --- a/src/view/com/modals/ChangePassword.tsx +++ b/src/view/com/modals/ChangePassword.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import { ActivityIndicator, SafeAreaView, diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 8f5487733..a9acd4c62 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import { ActivityIndicator, KeyboardAvoidingView, diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 1e94f483e..af55e4b7f 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import { ActivityIndicator, KeyboardAvoidingView, diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 78f4a0117..1cbb9dd9d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,4 +1,4 @@ -import React, {Fragment, useEffect, useRef} from 'react' +import {Fragment, useEffect, useRef} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import BottomSheet from '@discord/bottom-sheet/src' diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e9d9c01dd..8d93c21b4 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx index 8755a2fbb..f61fe73fe 100644 --- a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx +++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Pressable, StyleSheet, Text, View} from 'react-native' import {LinearGradient} from 'expo-linear-gradient' import {msg, Trans} from '@lingui/macro' diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx index b8c0121e6..165a70ba2 100644 --- a/src/view/com/modals/lang-settings/LanguageToggle.tsx +++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet} from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 5473fff85..b90f2ecd6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -467,7 +467,12 @@ let FeedItem = ({ {item.type === 'feedgen-like' && item.subjectUri ? ( <FeedSourceCard feedUri={item.subjectUri} - style={[pal.view, pal.border, styles.feedcard]} + style={[ + t.atoms.bg, + t.atoms.border_contrast_low, + a.border, + styles.feedcard, + ]} showLikes /> ) : null} @@ -778,7 +783,6 @@ const styles = StyleSheet.create({ opacity: 0.8, }, feedcard: { - borderWidth: 1, borderRadius: 8, paddingVertical: 12, marginTop: 6, diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 4d5da960c..de0409991 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,5 +1,5 @@ import React, {forwardRef} from 'react' -import {Animated, View} from 'react-native' +import {View} from 'react-native' import PagerView, { PagerViewOnPageScrollEvent, PagerViewOnPageSelectedEvent, @@ -10,12 +10,11 @@ import {LogEvents} from '#/lib/statsig/events' import {atoms as a, native} from '#/alf' export type PageSelectedEvent = PagerViewOnPageSelectedEvent -const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) export interface PagerRef { setPage: ( index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], + reason: LogEvents['home:feedDisplayed']['reason'], ) => void } @@ -32,7 +31,7 @@ interface Props { onPageSelected?: (index: number) => void onPageSelecting?: ( index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], + reason: LogEvents['home:feedDisplayed']['reason'], ) => void onPageScrollStateChanged?: ( scrollState: 'idle' | 'dragging' | 'settling', @@ -61,7 +60,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( React.useImperativeHandle(ref, () => ({ setPage: ( index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], + reason: LogEvents['home:feedDisplayed']['reason'], ) => { pagerView.current?.setPage(index) onPageSelecting?.(index, reason) @@ -138,7 +137,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( selectedPage, onSelect: onTabBarSelect, })} - <AnimatedPagerView + <PagerView ref={pagerView} style={[a.flex_1]} initialPage={initialPage} @@ -146,7 +145,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( onPageSelected={onPageSelectedInner} onPageScroll={onPageScroll}> {children} - </AnimatedPagerView> + </PagerView> </View> ) }, diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 2cce727c0..e6909fe10 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -18,7 +18,7 @@ interface Props { onPageSelected?: (index: number) => void onPageSelecting?: ( index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], + reason: LogEvents['home:feedDisplayed']['reason'], ) => void } export const Pager = React.forwardRef(function PagerImpl( @@ -38,17 +38,14 @@ export const Pager = React.forwardRef(function PagerImpl( React.useImperativeHandle(ref, () => ({ setPage: ( index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], + reason: LogEvents['home:feedDisplayed']['reason'], ) => { onTabBarSelect(index, reason) }, })) const onTabBarSelect = React.useCallback( - ( - index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], - ) => { + (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { const scrollY = window.scrollY // We want to determine if the tabbar is already "sticking" at the top (in which // case we should preserve and restore scroll), or if it is somewhere below in the diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 6d601c289..92b98dc2e 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -131,11 +131,11 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( const lastForcedScrollY = useSharedValue(0) const adjustScrollForOtherPages = () => { 'worklet' - const currentScrollY = scrollY.value + const currentScrollY = scrollY.get() const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight) - if (lastForcedScrollY.value !== forcedScrollY) { - lastForcedScrollY.value = forcedScrollY - const refs = scrollRefs.value + if (lastForcedScrollY.get() !== forcedScrollY) { + lastForcedScrollY.set(forcedScrollY) + const refs = scrollRefs.get() for (let i = 0; i < refs.length; i++) { const scollRef = refs[i] if (i !== currentPage && scollRef != null) { @@ -167,7 +167,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( const isPossiblyInvalid = headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight if (!isPossiblyInvalid) { - scrollY.value = nextScrollY + scrollY.set(nextScrollY) runOnJS(queueThrottledOnScroll)() } }, @@ -246,7 +246,7 @@ let PagerTabBar = ({ allowHeaderOverScroll?: boolean }): React.ReactNode => { const headerTransform = useAnimatedStyle(() => { - const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1 + const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 return { transform: [ { diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 69e04e046..4c0d973a9 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx index 56cf81a3e..10a51166c 100644 --- a/src/view/com/post-thread/PostQuotes.tsx +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import { AppBskyFeedDefs, AppBskyFeedPost, diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 67a89e435..dfaa69780 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 89993beec..705572c06 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5044f9621..035f7a681 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,5 +1,10 @@ import React, {memo, useMemo} from 'react' -import {StyleSheet, View} from 'react-native' +import { + GestureResponderEvent, + StyleSheet, + Text as RNText, + View, +} from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, @@ -8,7 +13,6 @@ import { ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -21,6 +25,7 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {countLines} from '#/lib/strings/helpers' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' +import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useLanguagePrefs} from '#/state/preferences' import {ThreadPost} from '#/state/queries/post-thread' @@ -28,26 +33,31 @@ import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {Link, TextLink} from '#/view/com/util/Link' +import {formatCount} from '#/view/com/util/numeric/format' +import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' +import {colors} from '#/components/Admonition' +import {Button} from '#/components/Button' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {InlineLinkText} from '#/components/Link' +import {ContentHider} from '#/components/moderation/ContentHider' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' import {AppModerationCause} from '#/components/Pills' +import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' -import {Text as NewText} from '#/components/Typography' -import {ContentHider} from '../../../components/moderation/ContentHider' -import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' -import {PostAlerts} from '../../../components/moderation/PostAlerts' -import {PostHider} from '../../../components/moderation/PostHider' -import {WhoCanReply} from '../../../components/WhoCanReply' -import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {Link, TextLink} from '../util/Link' -import {formatCount} from '../util/numeric/format' -import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds' -import {PostMeta} from '../util/PostMeta' -import {Text} from '../util/text/Text' -import {PreviewableUserAvatar} from '../util/UserAvatar' +import {Text} from '#/components/Typography' +import {WhoCanReply} from '#/components/WhoCanReply' export function PostThreadItem({ post, @@ -125,19 +135,20 @@ export function PostThreadItem({ } function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { - const pal = usePalette('default') + const t = useTheme() return ( <View style={[ - styles.outer, - pal.border, - pal.view, - s.p20, - s.flexRow, - hideTopBorder && styles.noTopBorder, + t.atoms.bg, + t.atoms.border_contrast_low, + a.p_xl, + a.pl_lg, + a.flex_row, + a.gap_md, + !hideTopBorder && a.border_t, ]}> - <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> - <Text style={[pal.textLight, s.ml10]}> + <TrashIcon style={[t.atoms.text]} /> + <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> <Trans>This post has been deleted.</Trans> </Text> </View> @@ -308,7 +319,7 @@ let PostThreadItemLoaded = ({ /> <View style={[a.flex_1]}> <Link style={s.flex1} href={authorHref} title={authorTitle}> - <NewText + <Text emoji style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]} numberOfLines={1}> @@ -317,10 +328,10 @@ let PostThreadItemLoaded = ({ sanitizeHandle(post.author.handle), moderation.ui('displayName'), )} - </NewText> + </Text> </Link> <Link style={s.flex1} href={authorHref} title={authorTitle}> - <NewText + <Text emoji style={[ a.text_md, @@ -329,7 +340,7 @@ let PostThreadItemLoaded = ({ ]} numberOfLines={1}> {sanitizeHandle(post.author.handle, '@')} - </NewText> + </Text> </Link> </View> {currentAccount?.did !== post.author.did && ( @@ -393,48 +404,48 @@ let PostThreadItemLoaded = ({ ]}> {post.repostCount != null && post.repostCount !== 0 ? ( <Link href={repostsHref} title={repostsTitle}> - <NewText + <Text testID="repostCount-expanded" style={[a.text_md, t.atoms.text_contrast_medium]}> - <NewText style={[a.text_md, a.font_bold, t.atoms.text]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> {formatCount(i18n, post.repostCount)} - </NewText>{' '} + </Text>{' '} <Plural value={post.repostCount} one="repost" other="reposts" /> - </NewText> + </Text> </Link> ) : null} {post.quoteCount != null && post.quoteCount !== 0 && !post.viewer?.embeddingDisabled ? ( <Link href={quotesHref} title={quotesTitle}> - <NewText + <Text testID="quoteCount-expanded" style={[a.text_md, t.atoms.text_contrast_medium]}> - <NewText style={[a.text_md, a.font_bold, t.atoms.text]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> {formatCount(i18n, post.quoteCount)} - </NewText>{' '} + </Text>{' '} <Plural value={post.quoteCount} one="quote" other="quotes" /> - </NewText> + </Text> </Link> ) : null} {post.likeCount != null && post.likeCount !== 0 ? ( <Link href={likesHref} title={likesTitle}> - <NewText + <Text testID="likeCount-expanded" style={[a.text_md, t.atoms.text_contrast_medium]}> - <NewText style={[a.text_md, a.font_bold, t.atoms.text]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> {formatCount(i18n, post.likeCount)} - </NewText>{' '} + </Text>{' '} <Plural value={post.likeCount} one="like" other="likes" /> - </NewText> + </Text> </Link> ) : null} </View> @@ -617,13 +628,13 @@ let PostThreadItemLoaded = ({ href={postHref} title={itemTitle} noFeedback> - <Text type="sm-medium" style={pal.textLight}> + <Text + style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}> <Trans>More</Trans> </Text> - <FontAwesomeIcon - icon="angle-right" - color={pal.colors.textLight} - size={14} + <ChevronRightIcon + size="xs" + style={[t.atoms.text_contrast_medium]} /> </Link> ) : undefined} @@ -651,26 +662,24 @@ function PostOuterWrapper({ hideTopBorder?: boolean }>) { const t = useTheme() - const [hover, setHover] = React.useState(false) + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() if (treeView && depth > 0) { return ( <View style={[ a.flex_row, a.px_sm, + a.flex_row, t.atoms.border_contrast_low, styles.cursor, - { - flexDirection: 'row', - borderTopWidth: depth === 1 ? a.border_t.borderTopWidth : 0, - }, + depth === 1 && a.border_t, ]} - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut}> {Array.from(Array(depth - 1)).map((_, n: number) => ( <View key={`${post.uri}-padding-${n}`} @@ -684,18 +693,23 @@ function PostOuterWrapper({ ]} /> ))} - <View style={{flex: 1}}>{children}</View> + <View style={a.flex_1}> + <SubtleWebHover + hover={hover} + style={{ + left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft, + right: -a.pr_sm.paddingRight, + }} + /> + {children} + </View> </View> ) } return ( <View - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }} + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut} style={[ a.border_t, a.px_sm, @@ -727,37 +741,134 @@ function ExpandedPostDetails({ const openLink = useOpenLink() const isRootPost = !('reply' in post.record) - const onTranslatePress = React.useCallback(() => { - openLink(translatorUrl) - }, [openLink, translatorUrl]) + const onTranslatePress = React.useCallback( + (e: GestureResponderEvent) => { + e.preventDefault() + openLink(translatorUrl, true) + return false + }, + [openLink, translatorUrl], + ) return ( - <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm, a.pt_md]}> - <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}> - {niceDate(i18n, post.indexedAt)} - </NewText> - {isRootPost && ( - <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> - )} - {needsTranslation && ( - <> - <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}> - · - </NewText> + <View style={[a.gap_md, a.pt_md, a.align_start]}> + <BackdatedPostIndicator post={post} /> + <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> + {niceDate(i18n, post.indexedAt)} + </Text> + {isRootPost && ( + <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> + )} + {needsTranslation && ( + <> + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> + · + </Text> - <InlineLinkText - to="#" - label={_(msg`Translate`)} - style={[a.text_sm, pal.link]} - onPress={onTranslatePress}> - <Trans>Translate</Trans> - </InlineLinkText> - </> - )} + <InlineLinkText + to={translatorUrl} + label={_(msg`Translate`)} + style={[a.text_sm, pal.link]} + onPress={onTranslatePress}> + <Trans>Translate</Trans> + </InlineLinkText> + </> + )} + </View> </View> ) } +function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() + const {_, i18n} = useLingui() + const control = Prompt.usePromptControl() + + const indexedAt = new Date(post.indexedAt) + const createdAt = AppBskyFeedPost.isRecord(post.record) + ? new Date(post.record.createdAt) + : new Date(post.indexedAt) + + // backdated if createdAt is 24 hours or more before indexedAt + const isBackdated = + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 + + if (!isBackdated) return null + + const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light + + return ( + <> + <Button + label={_(msg`Archived post`)} + accessibilityHint={_( + msg`Show information about when this post was created`, + )} + onPress={e => { + e.preventDefault() + e.stopPropagation() + control.open() + }}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_row, + a.align_center, + a.rounded_full, + t.atoms.bg_contrast_25, + (hovered || pressed) && t.atoms.bg_contrast_50, + { + gap: 3, + paddingHorizontal: 6, + paddingVertical: 3, + }, + ]}> + <CalendarClockIcon fill={orange} size="sm" aria-hidden /> + <Text + style={[ + a.text_xs, + a.font_bold, + a.leading_tight, + t.atoms.text_contrast_medium, + ]}> + <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> + </Text> + </View> + )} + </Button> + + <Prompt.Outer control={control}> + <Prompt.TitleText> + <Trans>Archived post</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans> + This post claims to have been created on{' '} + <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, + but was first seen by Bluesky on{' '} + <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. + </Trans> + </Prompt.DescriptionText> + <Text + style={[ + a.text_md, + a.leading_snug, + t.atoms.text_contrast_high, + a.pb_xl, + ]}> + <Trans> + Bluesky cannot confirm the authenticity of the claimed date. + </Trans> + </Text> + <Prompt.Actions> + <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + function getThreadAuthor( post: AppBskyFeedDefs.PostView, record: AppBskyFeedPost.Record, diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx index 7c021d88b..030a92bc2 100644 --- a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx +++ b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index 269d4eb5a..1c894bffe 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -84,25 +84,29 @@ export function AviFollowButton({ {!isFollowing && ( <Button label={_(msg`Open ${name} profile shortcut menu`)} - hitSlop={{ - top: 0, - left: 0, - right: 5, - bottom: 5, - }} style={[ a.rounded_full, a.absolute, { - height: 30, - width: 30, bottom: -7, right: -7, }, ]}> <NativeDropdown items={items}> <View - style={[a.h_full, a.w_full, a.justify_center, a.align_center]}> + style={[ + { + // An asymmetric hit slop + // to prioritize bottom right taps. + paddingTop: 2, + paddingLeft: 2, + paddingBottom: 6, + paddingRight: 6, + }, + a.align_center, + a.justify_center, + a.rounded_full, + ]}> <View style={[ a.rounded_full, diff --git a/src/view/com/posts/DiscoverFallbackHeader.tsx b/src/view/com/posts/DiscoverFallbackHeader.tsx index 0153cf5f4..e35a33aaf 100644 --- a/src/view/com/posts/DiscoverFallbackHeader.tsx +++ b/src/view/com/posts/DiscoverFallbackHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {Trans} from '@lingui/macro' diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 905c1e0e0..c623234b8 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -16,10 +16,10 @@ import {useQueryClient} from '@tanstack/react-query' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {logEvent, useGate} from '#/lib/statsig/statsig' +import {logEvent} from '#/lib/statsig/statsig' import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' +import {isIOS, isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {STALE} from '#/state/queries' @@ -32,20 +32,17 @@ import { usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' -import { - ProgressGuide, - SuggestedFeeds, - SuggestedFollows, -} from '#/components/FeedInterstitials' +import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import {List, ListRef} from '../util/List' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedErrorMessage} from './FeedErrorMessage' +import {FeedItem} from './FeedItem' import {FeedShutdownMsg} from './FeedShutdownMsg' -import {FeedSlice} from './FeedSlice' +import {ViewFullThread} from './ViewFullThread' -type FeedItem = +type FeedRow = | { type: 'loading' key: string @@ -72,76 +69,29 @@ type FeedItem = slice: FeedPostSlice } | { - type: 'interstitialFeeds' + type: 'sliceItem' key: string - params: { - variant: 'default' | string - } - slot: number + slice: FeedPostSlice + indexInSlice: number + showReplyTo: boolean + } + | { + type: 'sliceViewFullThread' + key: string + uri: string } | { type: 'interstitialFollows' key: string - params: { - variant: 'default' | string - } - slot: number } | { type: 'interstitialProgressGuide' key: string - params: { - variant: 'default' | string - } - slot: number } -const feedInterstitialType = 'interstitialFeeds' -const followInterstitialType = 'interstitialFollows' -const progressGuideInterstitialType = 'interstitialProgressGuide' -const interstials: Record< - 'following' | 'discover' | 'profile', - (FeedItem & { - type: - | 'interstitialFeeds' - | 'interstitialFollows' - | 'interstitialProgressGuide' - })[] -> = { - following: [], - discover: [ - { - type: progressGuideInterstitialType, - params: { - variant: 'default', - }, - key: progressGuideInterstitialType, - slot: 0, - }, - { - type: followInterstitialType, - params: { - variant: 'default', - }, - key: followInterstitialType, - slot: 20, - }, - ], - profile: [ - { - type: followInterstitialType, - params: { - variant: 'default', - }, - key: followInterstitialType, - slot: 5, - }, - ], -} - -export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null { - if (feedItem.type === 'slice') { - return feedItem.slice +export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { + if (feedRow.type === 'sliceItem') { + return feedRow.slice } else { return null } @@ -204,7 +154,6 @@ let Feed = ({ const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef<number>(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') - const gate = useGate() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -303,8 +252,21 @@ let Feed = ({ } }, [pollInterval]) - const feedItems: FeedItem[] = React.useMemo(() => { - let arr: FeedItem[] = [] + const feedItems: FeedRow[] = React.useMemo(() => { + let feedKind: 'following' | 'discover' | 'profile' | undefined + if (feedType === 'following') { + feedKind = 'following' + } else if (feedUri === DISCOVER_FEED_URI) { + feedKind = 'discover' + } else if ( + feedType === 'author' && + (feedTab === 'posts_and_author_threads' || + feedTab === 'posts_with_replies') + ) { + feedKind = 'profile' + } + + let arr: FeedRow[] = [] if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) { arr.push({ type: 'feedShutdownMsg', @@ -323,14 +285,77 @@ let Feed = ({ key: 'empty', }) } else if (data) { + let sliceIndex = -1 for (const page of data?.pages) { - arr = arr.concat( - page.slices.map(s => ({ - type: 'slice', - slice: s, - key: s._reactKey, - })), - ) + for (const slice of page.slices) { + sliceIndex++ + + if (hasSession) { + if (feedKind === 'discover') { + if (sliceIndex === 0) { + arr.push({ + type: 'interstitialProgressGuide', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } else if (sliceIndex === 20) { + arr.push({ + type: 'interstitialFollows', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } else if (feedKind === 'profile') { + if (sliceIndex === 5) { + arr.push({ + type: 'interstitialFollows', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } + } + + if (slice.isIncompleteThread && slice.items.length >= 3) { + const beforeLast = slice.items.length - 2 + const last = slice.items.length - 1 + arr.push({ + type: 'sliceItem', + key: slice.items[0]._reactKey, + slice: slice, + indexInSlice: 0, + showReplyTo: false, + }) + arr.push({ + type: 'sliceViewFullThread', + key: slice._reactKey + '-viewFullThread', + uri: slice.items[0].uri, + }) + arr.push({ + type: 'sliceItem', + key: slice.items[beforeLast]._reactKey, + slice: slice, + indexInSlice: beforeLast, + showReplyTo: + slice.items[beforeLast].parentAuthor?.did !== + slice.items[beforeLast].post.author.did, + }) + arr.push({ + type: 'sliceItem', + key: slice.items[last]._reactKey, + slice: slice, + indexInSlice: last, + showReplyTo: false, + }) + } else { + for (let i = 0; i < slice.items.length; i++) { + arr.push({ + type: 'sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + }) + } + } + } } } if (isError && !isEmpty) { @@ -346,45 +371,6 @@ let Feed = ({ }) } - if (hasSession) { - let feedKind: 'following' | 'discover' | 'profile' | undefined - if (feedType === 'following') { - feedKind = 'following' - } else if (feedUri === DISCOVER_FEED_URI) { - feedKind = 'discover' - } else if ( - feedType === 'author' && - (feedTab === 'posts_and_author_threads' || - feedTab === 'posts_with_replies') - ) { - feedKind = 'profile' - } - - if (feedKind) { - for (const interstitial of interstials[feedKind]) { - const shouldShow = - (interstitial.type === feedInterstitialType && - gate('suggested_feeds_interstitial')) || - interstitial.type === followInterstitialType || - interstitial.type === progressGuideInterstitialType - - if (shouldShow) { - const variant = 'default' // replace with experiment variant - const int = { - ...interstitial, - params: {variant}, - // overwrite key with unique value - key: [interstitial.type, variant, lastFetchedAt].join(':'), - } - - if (arr.length > interstitial.slot) { - arr.splice(interstitial.slot, 0, int) - } - } - } - } - } - return arr }, [ isFetched, @@ -395,7 +381,6 @@ let Feed = ({ feedType, feedUri, feedTab, - gate, hasSession, ]) @@ -403,7 +388,7 @@ let Feed = ({ // = const onRefresh = React.useCallback(async () => { - logEvent('feed:refresh:sampled', { + logEvent('feed:refresh', { feedType: feedType, feedUrl: feed, reason: 'pull-to-refresh', @@ -421,7 +406,7 @@ let Feed = ({ const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return - logEvent('feed:endReached:sampled', { + logEvent('feed:endReached', { feedType: feedType, feedUrl: feed, itemCount: feedItems.length, @@ -454,10 +439,10 @@ let Feed = ({ // = const renderItem = React.useCallback( - ({item, index}: ListRenderItemInfo<FeedItem>) => { - if (item.type === 'empty') { + ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { + if (row.type === 'empty') { return renderEmptyState() - } else if (item.type === 'error') { + } else if (row.type === 'error') { return ( <FeedErrorMessage feedDesc={feed} @@ -466,7 +451,7 @@ let Feed = ({ savedFeedConfig={savedFeedConfig} /> ) - } else if (item.type === 'loadMoreError') { + } else if (row.type === 'loadMoreError') { return ( <LoadMoreRetryBtn label={_( @@ -475,25 +460,48 @@ let Feed = ({ onPress={onPressRetryLoadMore} /> ) - } else if (item.type === 'loading') { + } else if (row.type === 'loading') { return <PostFeedLoadingPlaceholder /> - } else if (item.type === 'feedShutdownMsg') { + } else if (row.type === 'feedShutdownMsg') { return <FeedShutdownMsg feedUri={feedUri} /> - } else if (item.type === feedInterstitialType) { - return <SuggestedFeeds /> - } else if (item.type === followInterstitialType) { + } else if (row.type === 'interstitialFollows') { return <SuggestedFollows feed={feed} /> - } else if (item.type === progressGuideInterstitialType) { + } else if (row.type === 'interstitialProgressGuide') { return <ProgressGuide /> - } else if (item.type === 'slice') { - if (item.slice.isFallbackMarker) { + } else if (row.type === 'sliceItem') { + const slice = row.slice + if (slice.isFallbackMarker) { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info // -prf return <DiscoverFallbackHeader /> } - return <FeedSlice slice={item.slice} hideTopBorder={index === 0} /> + const indexInSlice = row.indexInSlice + const item = slice.items[indexInSlice] + return ( + <FeedItem + post={item.post} + record={item.record} + reason={indexInSlice === 0 ? slice.reason : undefined} + feedContext={slice.feedContext} + moderation={item.moderation} + parentAuthor={item.parentAuthor} + showReplyTo={row.showReplyTo} + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} + isThreadLastChild={ + isThreadChildAt(slice.items, indexInSlice) && + slice.items.length === indexInSlice + 1 + } + isParentBlocked={item.isParentBlocked} + isParentNotFound={item.isParentNotFound} + hideTopBorder={rowIndex === 0 && indexInSlice === 0} + rootPost={slice.items[0].post} + /> + ) + } else if (row.type === 'sliceViewFullThread') { + return <ViewFullThread uri={row.uri} /> } else { return null } @@ -561,7 +569,7 @@ let Feed = ({ } initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} windowSize={9} - maxToRenderPerBatch={5} + maxToRenderPerBatch={isIOS ? 5 : 1} updateCellsBatchingPeriod={40} onItemSeen={feedFeedback.onItemSeen} /> @@ -574,3 +582,17 @@ export {Feed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, }) + +function isThreadParentAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index cc7b34750..a58216233 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -25,7 +25,7 @@ export enum KnownError { FeedgenBadResponse = 'FeedgenBadResponse', FeedgenOffline = 'FeedgenOffline', FeedgenUnknown = 'FeedgenUnknown', - FeedNSFPublic = 'FeedNSFPublic', + FeedSignedInOnly = 'FeedSignedInOnly', FeedTooManyRequests = 'FeedTooManyRequests', Unknown = 'Unknown', } @@ -110,7 +110,7 @@ function FeedgenErrorMessage({ [KnownError.FeedgenOffline]: _l( msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, ), - [KnownError.FeedNSFPublic]: _l( + [KnownError.FeedSignedInOnly]: _l( msgLingui`This content is not viewable without a Bluesky account.`, ), [KnownError.FeedgenUnknown]: _l( @@ -152,7 +152,7 @@ function FeedgenErrorMessage({ const cta = React.useMemo(() => { switch (knownError) { - case KnownError.FeedNSFPublic: { + case KnownError.FeedSignedInOnly: { return null } case KnownError.FeedgenDoesNotExist: @@ -249,8 +249,8 @@ function detectKnownError( if (typeof error !== 'string') { error = error.toString() } - if (error.includes(KnownError.FeedNSFPublic)) { - return KnownError.FeedNSFPublic + if (error.includes(KnownError.FeedSignedInOnly)) { + return KnownError.FeedSignedInOnly } if (!feedDesc.startsWith('feedgen')) { return KnownError.Unknown diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 049748754..c04921c68 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -618,10 +618,10 @@ const styles = StyleSheet.create({ layout: { flexDirection: 'row', marginTop: 1, - gap: 10, }, layoutAvi: { paddingLeft: 8, + paddingRight: 10, position: 'relative', zIndex: 999, }, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx deleted file mode 100644 index dc68ee7a1..000000000 --- a/src/view/com/posts/FeedSlice.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, {memo} from 'react' -import {StyleSheet, View} from 'react-native' -import Svg, {Circle, Line} from 'react-native-svg' -import {AtUri} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {makeProfileLink} from '#/lib/routes/links' -import {FeedPostSlice} from '#/state/queries/post-feed' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {FeedItem} from './FeedItem' - -let FeedSlice = ({ - slice, - hideTopBorder, -}: { - slice: FeedPostSlice - hideTopBorder?: boolean -}): React.ReactNode => { - if (slice.isIncompleteThread && slice.items.length >= 3) { - const beforeLast = slice.items.length - 2 - const last = slice.items.length - 1 - return ( - <> - <FeedItem - key={slice.items[0]._reactKey} - post={slice.items[0].post} - record={slice.items[0].record} - reason={slice.reason} - feedContext={slice.feedContext} - parentAuthor={slice.items[0].parentAuthor} - showReplyTo={false} - moderation={slice.items[0].moderation} - isThreadParent={isThreadParentAt(slice.items, 0)} - isThreadChild={isThreadChildAt(slice.items, 0)} - hideTopBorder={hideTopBorder} - isParentBlocked={slice.items[0].isParentBlocked} - isParentNotFound={slice.items[0].isParentNotFound} - rootPost={slice.items[0].post} - /> - <ViewFullThread uri={slice.items[0].uri} /> - <FeedItem - key={slice.items[beforeLast]._reactKey} - post={slice.items[beforeLast].post} - record={slice.items[beforeLast].record} - reason={undefined} - feedContext={slice.feedContext} - parentAuthor={slice.items[beforeLast].parentAuthor} - showReplyTo={ - slice.items[beforeLast].parentAuthor?.did !== - slice.items[beforeLast].post.author.did - } - moderation={slice.items[beforeLast].moderation} - isThreadParent={isThreadParentAt(slice.items, beforeLast)} - isThreadChild={isThreadChildAt(slice.items, beforeLast)} - isParentBlocked={slice.items[beforeLast].isParentBlocked} - isParentNotFound={slice.items[beforeLast].isParentNotFound} - rootPost={slice.items[0].post} - /> - <FeedItem - key={slice.items[last]._reactKey} - post={slice.items[last].post} - record={slice.items[last].record} - reason={undefined} - feedContext={slice.feedContext} - parentAuthor={slice.items[last].parentAuthor} - showReplyTo={false} - moderation={slice.items[last].moderation} - isThreadParent={isThreadParentAt(slice.items, last)} - isThreadChild={isThreadChildAt(slice.items, last)} - isParentBlocked={slice.items[last].isParentBlocked} - isParentNotFound={slice.items[last].isParentNotFound} - isThreadLastChild - rootPost={slice.items[0].post} - /> - </> - ) - } - - return ( - <> - {slice.items.map((item, i) => ( - <FeedItem - key={item._reactKey} - post={slice.items[i].post} - record={slice.items[i].record} - reason={i === 0 ? slice.reason : undefined} - feedContext={slice.feedContext} - moderation={slice.items[i].moderation} - parentAuthor={slice.items[i].parentAuthor} - showReplyTo={i === 0} - isThreadParent={isThreadParentAt(slice.items, i)} - isThreadChild={isThreadChildAt(slice.items, i)} - isThreadLastChild={ - isThreadChildAt(slice.items, i) && slice.items.length === i + 1 - } - isParentBlocked={slice.items[i].isParentBlocked} - isParentNotFound={slice.items[i].isParentNotFound} - hideTopBorder={hideTopBorder && i === 0} - rootPost={slice.items[0].post} - /> - ))} - </> - ) -} -FeedSlice = memo(FeedSlice) -export {FeedSlice} - -function ViewFullThread({uri}: {uri: string}) { - const pal = usePalette('default') - const itemHref = React.useMemo(() => { - const urip = new AtUri(uri) - return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) - }, [uri]) - - return ( - <Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback> - <View style={styles.viewFullThreadDots}> - <Svg width="4" height="40"> - <Line - x1="2" - y1="0" - x2="2" - y2="15" - stroke={pal.colors.replyLine} - strokeWidth="2" - /> - <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> - <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> - <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> - </Svg> - </View> - - <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> - <Trans>View full thread</Trans> - </Text> - </Link> - ) -} - -const styles = StyleSheet.create({ - viewFullThread: { - flexDirection: 'row', - gap: 10, - paddingLeft: 18, - }, - viewFullThreadDots: { - width: 42, - alignItems: 'center', - }, -}) - -function isThreadParentAt<T>(arr: Array<T>, i: number) { - if (arr.length === 1) { - return false - } - return i < arr.length - 1 -} - -function isThreadChildAt<T>(arr: Array<T>, i: number) { - if (arr.length === 1) { - return false - } - return i > 0 -} diff --git a/src/view/com/posts/ViewFullThread.tsx b/src/view/com/posts/ViewFullThread.tsx new file mode 100644 index 000000000..0b347f22c --- /dev/null +++ b/src/view/com/posts/ViewFullThread.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Svg, {Circle, Line} from 'react-native-svg' +import {AtUri} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' + +export function ViewFullThread({uri}: {uri: string}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const pal = usePalette('default') + const itemHref = React.useMemo(() => { + const urip = new AtUri(uri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [uri]) + + return ( + <Link + style={[styles.viewFullThread]} + href={itemHref} + asAnchor + noFeedback + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut}> + <SubtleWebHover + hover={hover} + // adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn + style={{top: 8, bottom: -5}} + /> + <View style={styles.viewFullThreadDots}> + <Svg width="4" height="40"> + <Line + x1="2" + y1="0" + x2="2" + y2="15" + stroke={pal.colors.replyLine} + strokeWidth="2" + /> + <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> + </Svg> + </View> + + <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> + <Trans>View full thread</Trans> + </Text> + </Link> + ) +} + +const styles = StyleSheet.create({ + viewFullThread: { + flexDirection: 'row', + gap: 10, + paddingLeft: 18, + }, + viewFullThreadDots: { + width: 42, + alignItems: 'center', + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index aaa5d3454..c2d76316e 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index d73b322f2..0e25fe5e6 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,18 +1,13 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import Animated, { - measure, - MeasuredDimensions, - runOnJS, - runOnUI, - useAnimatedRef, -} from 'react-native-reanimated' +import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {BACK_HITSLOP} from '#/lib/constants' +import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeProfileLink} from '#/lib/routes/links' @@ -60,7 +55,7 @@ export function ProfileSubpageHeader({ const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() - const aviRef = useAnimatedRef() + const aviRef = useHandleRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -101,9 +96,10 @@ export function ProfileSubpageHeader({ if ( avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) ) { + const aviHandle = aviRef.current runOnUI(() => { 'worklet' - const rect = measure(aviRef) + const rect = measureHandle(aviHandle) runOnJS(_openLightbox)(avatar, rect) })() } @@ -155,7 +151,7 @@ export function ProfileSubpageHeader({ paddingBottom: 6, paddingHorizontal: isMobile ? 12 : 14, }}> - <Animated.View ref={aviRef} collapsable={false}> + <View ref={aviRef} collapsable={false}> <Pressable testID="headerAviButton" onPress={onPressAvi} @@ -169,7 +165,7 @@ export function ProfileSubpageHeader({ <UserAvatar type={avatarType} size={58} avatar={avatar} /> )} </Pressable> - </Animated.View> + </View> <View style={{flex: 1}}> {isLoading ? ( <LoadingPlaceholder diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 71c5f1da1..5c8b21373 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {LogBox, Pressable, View} from 'react-native' import {useQueryClient} from '@tanstack/react-query' diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index 25e882e87..86751861f 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -18,7 +18,7 @@ export function createCustomBackdrop( // animated variables const opacity = useAnimatedStyle(() => ({ opacity: interpolate( - animatedIndex.value, // current snap index + animatedIndex.get(), // current snap index [-1, 0], // input range [0, 0.5], // output range Extrapolation.CLAMP, diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 587d84462..7f1632936 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' import { diff --git a/src/view/com/util/EmptyStateWithButton.tsx b/src/view/com/util/EmptyStateWithButton.tsx index 7b7aa129e..fcac6df08 100644 --- a/src/view/com/util/EmptyStateWithButton.tsx +++ b/src/view/com/util/EmptyStateWithButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' import { diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 46b94932b..c4211ffbc 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, {Component, ErrorInfo, ReactNode} from 'react' +import {Component, ErrorInfo, ReactNode} from 'react' import {StyleProp, ViewStyle} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/util/FeedInfoText.tsx b/src/view/com/util/FeedInfoText.tsx index da5c48af7..55eb1bad4 100644 --- a/src/view/com/util/FeedInfoText.tsx +++ b/src/view/com/util/FeedInfoText.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {sanitizeDisplayName} from '#/lib/strings/display-names' diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 2cc3e30ca..f83258e45 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -18,6 +18,7 @@ import { useNavigationDeduped, } from '#/lib/hooks/useNavigationDeduped' import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getTabState, TabState} from '#/lib/routes/helpers' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -25,6 +26,7 @@ import { } from '#/lib/strings/url-helpers' import {TypographyVariant} from '#/lib/ThemeContext' import {isAndroid, isWeb} from '#/platform/detection' +import {emitSoftReset} from '#/state/events' import {useModalControls} from '#/state/modals' import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper' import {useTheme} from '#/alf' @@ -254,7 +256,7 @@ export const TextLink = memo(function TextLink({ if (isExternal) { return { target: '_blank', - // rel: 'noopener noreferrer', + // rel: 'noopener', } } return {} @@ -400,15 +402,22 @@ function onPressInner( } else { closeModal() // close any active modals + const [routeName, params] = router.matchPath(href) if (navigationAction === 'push') { // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.push(...router.matchPath(href))) + navigation.dispatch(StackActions.push(routeName, params)) } else if (navigationAction === 'replace') { // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.replace(...router.matchPath(href))) + navigation.dispatch(StackActions.replace(routeName, params)) } else if (navigationAction === 'navigate') { - // @ts-ignore we're not able to type check on this one -prf - navigation.navigate(...router.matchPath(href)) + const state = navigation.getState() + const tabState = getTabState(state, routeName) + if (tabState === TabState.InsideAtRoot) { + emitSoftReset() + } else { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(routeName, params) + } } else { throw Error('Unsupported navigator action.') } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 0425514e4..fa93ec5e6 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -7,7 +7,8 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX import {useDedupe} from '#/lib/hooks/useDedupe' import {useScrollHandlers} from '#/lib/ScrollContext' import {addStyle} from '#/lib/styles' -import {isIOS} from '#/platform/detection' +import {isAndroid, isIOS} from '#/platform/detection' +import {useLightbox} from '#/state/lightbox' import {useTheme} from '#/alf' import {FlatList_INTERNAL} from './Views' @@ -52,6 +53,7 @@ function ListImpl<ItemT>( const isScrolledDown = useSharedValue(false) const t = useTheme() const dedupe = useDedupe(400) + const {activeLightbox} = useLightbox() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -77,8 +79,8 @@ function ListImpl<ItemT>( onScrollFromContext?.(e, ctx) const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT - if (isScrolledDown.value !== didScrollDown) { - isScrolledDown.value = didScrollDown + if (isScrolledDown.get() !== didScrollDown) { + isScrolledDown.set(didScrollDown) if (onScrolledDownChange != null) { runOnJS(handleScrolledDownChange)(didScrollDown) } @@ -143,9 +145,11 @@ function ListImpl<ItemT>( contentOffset={contentOffset} refreshControl={refreshControl} onScroll={scrollHandler} + scrollsToTop={!activeLightbox} scrollEventThrottle={1} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} + showsVerticalScrollIndicator={!isAndroid} style={style} ref={ref} /> diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index d9a2e351e..f112d2d0a 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -46,9 +46,9 @@ function ListImpl<ItemT>( keyExtractor, refreshing: _unsupportedRefreshing, onStartReached, - onStartReachedThreshold = 0, + onStartReachedThreshold = 2, onEndReached, - onEndReachedThreshold = 0, + onEndReachedThreshold = 2, onRefresh: _unsupportedOnRefresh, onScrolledDownChange, onContentSizeChange, diff --git a/src/view/com/util/LoadMoreRetryBtn.tsx b/src/view/com/util/LoadMoreRetryBtn.tsx index 863e8e2f5..07bd733ea 100644 --- a/src/view/com/util/LoadMoreRetryBtn.tsx +++ b/src/view/com/util/LoadMoreRetryBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 6620eb8e2..25ce460d4 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { DimensionValue, StyleProp, @@ -140,7 +139,7 @@ export function NotificationLoadingPlaceholder({ const pal = usePalette('default') return ( <View style={[styles.notification, pal.view, style]}> - <View style={[{width: 70}, a.align_end, a.pr_sm, a.pt_2xs]}> + <View style={[{width: 60}, a.align_end, a.pr_sm, a.pt_2xs]}> <HeartIconFilled size="xl" style={{color: pal.colors.backgroundLight}} @@ -149,8 +148,8 @@ export function NotificationLoadingPlaceholder({ <View style={{flex: 1}}> <View style={[a.flex_row, s.mb10]}> <LoadingPlaceholder - width={30} - height={30} + width={35} + height={35} style={styles.smallAvatar} /> </View> @@ -310,7 +309,7 @@ const styles = StyleSheet.create({ padding: 5, }, avatar: { - borderRadius: 26, + borderRadius: 999, marginRight: 10, marginLeft: 8, }, @@ -324,11 +323,11 @@ const styles = StyleSheet.create({ margin: 1, }, profileCardAvi: { - borderRadius: 20, + borderRadius: 999, marginRight: 10, }, smallAvatar: { - borderRadius: 15, + borderRadius: 999, marginRight: 10, }, }) diff --git a/src/view/com/util/LoadingScreen.tsx b/src/view/com/util/LoadingScreen.tsx index 15066d625..5d2aeb38f 100644 --- a/src/view/com/util/LoadingScreen.tsx +++ b/src/view/com/util/LoadingScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {ActivityIndicator, View} from 'react-native' import {s} from '#/lib/styles' diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 23dffc561..0d084993b 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native' import { cancelAnimation, interpolate, + makeMutable, useSharedValue, withSpring, } from 'react-native-reanimated' @@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) { return Math.min(Math.max(num, min), max) } +const V0 = makeMutable( + withSpring(0, { + overshootClamping: true, + }), +) + +const V1 = makeMutable( + withSpring(1, { + overshootClamping: true, + }), +) + export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() @@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (v: boolean) => { 'worklet' cancelAnimation(headerMode) - headerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + headerMode.set(v ? V1.get() : V0.get()) }, [headerMode], ) @@ -41,9 +52,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { useEffect(() => { if (isWeb) { return listenToForcedWindowScroll(() => { - startDragOffset.value = null - startMode.value = null - didJustRestoreScroll.value = true + startDragOffset.set(null) + startMode.set(null) + didJustRestoreScroll.set(true) }) } }) @@ -52,13 +63,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - if (startDragOffset.value === null) { + const startDragOffsetValue = startDragOffset.get() + if (startDragOffsetValue === null) { return } - const didScrollDown = e.contentOffset.y > startDragOffset.value - startDragOffset.value = null - startMode.value = null - if (e.contentOffset.y < headerHeight.value) { + const didScrollDown = e.contentOffset.y > startDragOffsetValue + startDragOffset.set(null) + startMode.set(null) + if (e.contentOffset.y < headerHeight.get()) { // If we're close to the top, show the shell. setMode(false) } else if (didScrollDown) { @@ -66,7 +78,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { setMode(true) } else { // Snap to whichever state is the closest. - setMode(Math.round(headerMode.value) === 1) + setMode(Math.round(headerMode.get()) === 1) } } }, @@ -77,8 +89,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - startDragOffset.value = e.contentOffset.y - startMode.value = headerMode.value + startDragOffset.set(e.contentOffset.y) + startMode.set(headerMode.get()) } }, [headerMode, startDragOffset, startMode], @@ -112,10 +124,12 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - if (startDragOffset.value === null || startMode.value === null) { + const startDragOffsetValue = startDragOffset.get() + const startModeValue = startMode.get() + if (startDragOffsetValue === null || startModeValue === null) { if ( - headerMode.value !== 0 && - e.contentOffset.y < headerHeight.value + headerMode.get() !== 0 && + e.contentOffset.y < headerHeight.get() ) { // If we're close enough to the top, always show the shell. // Even if we're not dragging. @@ -126,29 +140,29 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. - const dy = e.contentOffset.y - startDragOffset.value + const dy = e.contentOffset.y - startDragOffsetValue const dProgress = interpolate( dy, - [-headerHeight.value, headerHeight.value], + [-headerHeight.get(), headerHeight.get()], [-1, 1], ) - const newValue = clamp(startMode.value + dProgress, 0, 1) - if (newValue !== headerMode.value) { + const newValue = clamp(startModeValue + dProgress, 0, 1) + if (newValue !== headerMode.get()) { // Manually adjust the value. This won't be (and shouldn't be) animated. // Cancel any any existing animation cancelAnimation(headerMode) - headerMode.value = newValue + headerMode.set(newValue) } } else { - if (didJustRestoreScroll.value) { - didJustRestoreScroll.value = false + if (didJustRestoreScroll.get()) { + didJustRestoreScroll.set(false) // Don't hide/show navbar based on scroll restoratoin. return } // On the web, we don't try to follow the drag because we don't know when it ends. // Instead, show/hide immediately based on whether we're scrolling up or down. - const dy = e.contentOffset.y - (startDragOffset.value ?? 0) - startDragOffset.value = e.contentOffset.y + const dy = e.contentOffset.y - (startDragOffset.get() ?? 0) + startDragOffset.set(e.contentOffset.y) if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { setMode(false) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c0166a16e..5384f6827 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { precacheProfile(queryClient, opts.author) }, [queryClient, opts.author]) + const timestampLabel = niceDate(i18n, opts.timestamp) + return ( <View style={[ @@ -115,8 +117,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { {({timeElapsed}) => ( <WebOnlyInlineLinkText to={opts.postHref} - label={niceDate(i18n, opts.timestamp)} - title={niceDate(i18n, opts.timestamp)} + label={timestampLabel} + title={timestampLabel} disableMismatchWarning disableUnderline onPress={onBeforePressPost} diff --git a/src/view/com/util/PressableWithHover.tsx b/src/view/com/util/PressableWithHover.tsx index 48659e229..19a1968cc 100644 --- a/src/view/com/util/PressableWithHover.tsx +++ b/src/view/com/util/PressableWithHover.tsx @@ -1,4 +1,4 @@ -import React, {forwardRef, PropsWithChildren} from 'react' +import {forwardRef, PropsWithChildren} from 'react' import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' import {View} from 'react-native' diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx deleted file mode 100644 index cf9d347af..000000000 --- a/src/view/com/util/Selector.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, {createRef, useMemo, useRef, useState} from 'react' -import {Animated, Pressable, StyleSheet, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import {Text} from './text/Text' - -interface Layout { - x: number - width: number -} - -export function Selector({ - selectedIndex, - items, - panX, - onSelect, -}: { - selectedIndex: number - items: string[] - panX: Animated.Value - onSelect?: (index: number) => void -}) { - const {_} = useLingui() - const containerRef = useRef<View>(null) - const pal = usePalette('default') - const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( - undefined, - ) - const itemRefs = useMemo( - () => Array.from({length: items.length}).map(() => createRef<View>()), - [items.length], - ) - - const currentLayouts = useMemo(() => { - const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0} - const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0} - const right = itemLayouts?.[selectedIndex + 1] || { - x: middle.x + 20, - width: middle.width, - } - return [left, middle, right] - }, [selectedIndex, itemLayouts]) - - const underlineStyle = { - backgroundColor: pal.colors.text, - left: panX.interpolate({ - inputRange: [-1, 0, 1], - outputRange: [ - currentLayouts[0].x, - currentLayouts[1].x, - currentLayouts[2].x, - ], - }), - width: panX.interpolate({ - inputRange: [-1, 0, 1], - outputRange: [ - currentLayouts[0].width, - currentLayouts[1].width, - currentLayouts[2].width, - ], - }), - } - - const onLayout = () => { - const promises = [] - for (let i = 0; i < items.length; i++) { - promises.push( - new Promise<Layout>(resolve => { - if (!containerRef.current || !itemRefs[i].current) { - return resolve({x: 0, width: 0}) - } - itemRefs[i].current?.measureLayout( - containerRef.current, - (x: number, _y: number, width: number) => { - resolve({x, width}) - }, - ) - }), - ) - } - Promise.all(promises).then((layouts: Layout[]) => { - setItemLayouts(layouts) - }) - } - - const onPressItem = (index: number) => { - onSelect?.(index) - } - - const numItems = items.length - - return ( - <View - style={[pal.view, styles.outer]} - onLayout={onLayout} - ref={containerRef}> - <Animated.View style={[styles.underline, underlineStyle]} /> - {items.map((item, i) => { - const selected = i === selectedIndex - return ( - <Pressable - testID={`selector-${i}`} - key={item} - onPress={() => onPressItem(i)} - accessibilityLabel={_(msg`Select ${item}`)} - accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}> - <View style={styles.item} ref={itemRefs[i]}> - <Text - style={ - selected - ? [styles.labelSelected, pal.text] - : [styles.label, pal.textLight] - }> - {item} - </Text> - </View> - </Pressable> - ) - })} - </View> - ) -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - paddingTop: 8, - paddingBottom: 12, - paddingHorizontal: 14, - }, - item: { - marginRight: 14, - paddingHorizontal: 10, - }, - label: { - fontWeight: '600', - }, - labelSelected: { - fontWeight: '600', - }, - underline: { - position: 'absolute', - height: 4, - bottom: 0, - }, -}) diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 51e76bdc3..b57e676ae 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,20 @@ -import React, {useEffect, useState} from 'react' -import {View} from 'react-native' -import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated' +import {useEffect, useMemo, useRef, useState} from 'react' +import {AccessibilityInfo, View} from 'react-native' +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler' +import Animated, { + FadeInUp, + FadeOutUp, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' import RootSiblings from 'react-native-root-siblings' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { @@ -8,6 +22,7 @@ import { Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {IS_TEST} from '#/env' @@ -19,74 +34,174 @@ export function show( icon: FontAwesomeProps['icon'] = 'check', ) { if (IS_TEST) return - const item = new RootSiblings(<Toast message={message} icon={icon} />) - // timeout has some leeway to account for the animation - setTimeout(() => { - item.destroy() - }, TIMEOUT + 1e3) + AccessibilityInfo.announceForAccessibility(message) + const item = new RootSiblings( + <Toast message={message} icon={icon} destroy={() => item.destroy()} />, + ) } function Toast({ message, icon, + destroy, }: { message: string icon: FontAwesomeProps['icon'] + destroy: () => void }) { const t = useTheme() const {top} = useSafeAreaInsets() + const isPanning = useSharedValue(false) + const dismissSwipeTranslateY = useSharedValue(0) + const [cardHeight, setCardHeight] = useState(0) // for the exit animation to work on iOS the animated component // must not be the root component // so we need to wrap it in a view and unmount the toast ahead of time const [alive, setAlive] = useState(true) - useEffect(() => { + const hideAndDestroyImmediately = () => { + setAlive(false) setTimeout(() => { - setAlive(false) - }, TIMEOUT) - }, []) + destroy() + }, 1e3) + } + + const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) + }) + const pauseDestroy = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + }) + + useEffect(() => { + hideAndDestroyAfterTimeout() + }, [hideAndDestroyAfterTimeout]) + + const panGesture = useMemo(() => { + return Gesture.Pan() + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onStart(() => { + 'worklet' + if (!alive) return + isPanning.set(true) + runOnJS(pauseDestroy)() + }) + .onUpdate(e => { + 'worklet' + if (!alive) return + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + 'worklet' + if (!alive) return + runOnJS(hideAndDestroyAfterTimeout)() + isPanning.set(false) + if (e.velocityY < -100) { + if (dismissSwipeTranslateY.value === 0) { + // HACK: If the initial value is 0, withDecay() animation doesn't start. + // This is a bug in Reanimated, but for now we'll work around it like this. + dismissSwipeTranslateY.value = 1 + } + dismissSwipeTranslateY.value = withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), + deceleration: 1, + }) + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 500, + damping: 50, + }) + } + }) + }, [ + dismissSwipeTranslateY, + isPanning, + alive, + hideAndDestroyAfterTimeout, + pauseDestroy, + ]) + + const topOffset = top + 10 + + useAnimatedReaction( + () => + !isPanning.get() && + dismissSwipeTranslateY.get() < -topOffset - cardHeight, + (isSwipedAway, prevIsSwipedAway) => { + 'worklet' + if (isSwipedAway && !prevIsSwipedAway) { + runOnJS(destroy)() + } + }, + ) + + const animatedStyle = useAnimatedStyle(() => { + const translation = dismissSwipeTranslateY.get() + return { + transform: [ + { + translateY: translation > 0 ? translation ** 0.7 : translation, + }, + ], + } + }) return ( - <View - style={[a.absolute, {top: top + 15, left: 16, right: 16}]} - pointerEvents="none"> + <GestureHandlerRootView + style={[a.absolute, {top: topOffset, left: 16, right: 16}]} + pointerEvents="box-none"> {alive && ( <Animated.View entering={FadeInUp} exiting={FadeOutUp} - style={[ - a.flex_1, - t.atoms.bg, - a.shadow_lg, - t.atoms.border_contrast_medium, - a.rounded_sm, - a.px_md, - a.py_lg, - a.border, - a.flex_row, - a.gap_md, - ]}> - <View + style={[a.flex_1]}> + <Animated.View + onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} + accessibilityRole="alert" + accessible={true} + accessibilityLabel={message} + accessibilityHint="" + onAccessibilityEscape={hideAndDestroyImmediately} style={[ - a.flex_shrink_0, - a.rounded_full, - {width: 32, height: 32}, - t.atoms.bg_contrast_25, - a.align_center, - a.justify_center, + a.flex_1, + t.atoms.bg, + a.shadow_lg, + t.atoms.border_contrast_medium, + a.rounded_sm, + a.border, + animatedStyle, ]}> - <FontAwesomeIcon - icon={icon} - size={16} - style={t.atoms.text_contrast_low} - /> - </View> - <View style={[a.h_full, a.justify_center, a.flex_1]}> - <Text style={a.text_md}>{message}</Text> - </View> + <GestureDetector gesture={panGesture}> + <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_shrink_0, + a.rounded_full, + {width: 32, height: 32}, + {backgroundColor: t.palette.primary_50}, + a.align_center, + a.justify_center, + ]}> + <FontAwesomeIcon + icon={icon} + size={16} + style={t.atoms.text_contrast_medium} + /> + </View> + <View style={[a.h_full, a.justify_center, a.flex_1]}> + <Text style={a.text_md}>{message}</Text> + </View> + </View> + </GestureDetector> + </Animated.View> </Animated.View> )} - </View> + </GestureHandlerRootView> ) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index 1f9eb479b..96798e61c 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -3,7 +3,7 @@ */ import React, {useEffect, useState} from 'react' -import {StyleSheet, Text, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -43,6 +43,14 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { style={styles.icon as FontAwesomeIconStyle} /> <Text style={styles.text}>{activeToast.text}</Text> + <Pressable + style={styles.dismissBackdrop} + accessibilityLabel="Dismiss" + accessibilityHint="" + onPress={() => { + setActiveToast(undefined) + }} + /> </View> )} </> @@ -77,6 +85,13 @@ const styles = StyleSheet.create({ backgroundColor: '#000c', borderRadius: 10, }, + dismissBackdrop: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + }, icon: { color: '#fff', flexShrink: 0, diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index 8a444d590..64aa37ff2 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx deleted file mode 100644 index 97605fb46..000000000 --- a/src/view/com/util/anim/TriggerableAnimated.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import {Animated, StyleProp, View, ViewStyle} from 'react-native' - -import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' - -type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation -type FinishCb = () => void - -interface TriggeredAnimation { - start: CreateAnimFn - style: ( - interp: Animated.Value, - ) => Animated.WithAnimatedValue<StyleProp<ViewStyle>> -} - -export interface TriggerableAnimatedRef { - trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void -} - -type TriggerableAnimatedProps = React.PropsWithChildren<{}> - -type PropsInner = TriggerableAnimatedProps & { - anim: TriggeredAnimation - onFinish: () => void -} - -export const TriggerableAnimated = React.forwardRef< - TriggerableAnimatedRef, - TriggerableAnimatedProps ->(function TriggerableAnimatedImpl({children, ...props}, ref) { - const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>( - undefined, - ) - const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>( - undefined, - ) - React.useImperativeHandle(ref, () => ({ - trigger(v: TriggeredAnimation, cb?: FinishCb) { - setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate - setAnim(v) - }, - })) - const onFinish = () => { - finishCb?.() - setAnim(undefined) - setFinishCb(undefined) - } - return ( - <View key="triggerable"> - {anim ? ( - <AnimatingView anim={anim} onFinish={onFinish} {...props}> - {children} - </AnimatingView> - ) : ( - children - )} - </View> - ) -}) - -function AnimatingView({ - anim, - onFinish, - children, -}: React.PropsWithChildren<PropsInner>) { - const interp = useAnimatedValue(0) - React.useEffect(() => { - anim?.start(interp).start(() => { - onFinish() - }) - }) - const animStyle = anim?.style(interp) - return <Animated.View style={animStyle}>{children}</Animated.View> -} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index f0ef3a40f..c09d1b2e6 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { StyleProp, StyleSheet, diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 1b23141f3..b66f43789 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx index 601d505a8..b9f3a0b07 100644 --- a/src/view/com/util/fab/FAB.web.tsx +++ b/src/view/com/util/fab/FAB.web.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 48e0005bc..77e283625 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,4 +1,4 @@ -import React, {ComponentProps} from 'react' +import {ComponentProps} from 'react' import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index 9df53f116..594bb48f6 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import DatePicker from 'react-native-date-picker' import { diff --git a/src/view/com/util/forms/DateInput.web.tsx b/src/view/com/util/forms/DateInput.web.tsx index ea6102356..988d8aee6 100644 --- a/src/view/com/util/forms/DateInput.web.tsx +++ b/src/view/com/util/forms/DateInput.web.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' // @ts-ignore types not available -prf import {unstable_createElement} from 'react-native-web' diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx index 22237f5e1..8fc9be6da 100644 --- a/src/view/com/util/forms/NativeDropdown.tsx +++ b/src/view/com/util/forms/NativeDropdown.tsx @@ -5,10 +5,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as DropdownMenu from 'zeego/dropdown-menu' import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' -import {HITSLOP_10} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {useTheme} from '#/lib/ThemeContext' -import {isIOS, isWeb} from '#/platform/detection' +import {isIOS} from '#/platform/detection' import {Portal} from '#/components/Portal' // Custom Dropdown Menu Components @@ -30,31 +29,18 @@ export const DropdownMenuTrigger = DropdownMenu.create( (props: TriggerProps) => { const theme = useTheme() const defaultCtrlColor = theme.palette.default.postCtrl - const ref = React.useRef<View>(null) - - // HACK - // fire a click event on the keyboard press to trigger the dropdown - // -prf - const onPress = isWeb - ? (evt: any) => { - if (evt instanceof KeyboardEvent) { - // @ts-ignore web only -prf - ref.current?.click() - } - } - : undefined return ( + // This Pressable doesn't actually do anything other than + // provide the "pressed state" visual feedback. <Pressable testID={props.testID} accessibilityRole="button" accessibilityLabel={props.accessibilityLabel} accessibilityHint={props.accessibilityHint} - style={({pressed}) => [{opacity: pressed ? 0.5 : 1}]} - hitSlop={HITSLOP_10} - onPress={onPress}> + style={({pressed}) => [{opacity: pressed ? 0.8 : 1}]}> <DropdownMenu.Trigger action="press"> - <View ref={ref}> + <View> {props.children ? ( props.children ) : ( diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 22751d8bf..fd577605a 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,83 +1,27 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useMemo, useState} from 'react' import { - Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle, } from 'react-native' -import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedThreadgate, - AtUri, RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {getCurrentRoute} from '#/lib/routes/helpers' -import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {logEvent} from '#/lib/statsig/statsig' -import {richTextToString} from '#/lib/strings/rich-text-helpers' -import {toShareUrl} from '#/lib/strings/url-helpers' import {useTheme} from '#/lib/ThemeContext' -import {getTranslatorLink} from '#/locale/helpers' -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {Shadow} from '#/state/cache/post-shadow' -import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useLanguagePrefs} from '#/state/preferences' -import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' -import {usePinnedPostMutation} from '#/state/queries/pinned-post' -import { - usePostDeleteMutation, - useThreadMuteMutationQueue, -} from '#/state/queries/post' -import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' -import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' -import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' -import {useSession} from '#/state/session' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {EmbedDialog} from '#/components/dialogs/Embed' -import { - PostInteractionSettingsDialog, - usePrefetchPostInteractionSettings, -} from '#/components/dialogs/PostInteractionSettingsDialog' -import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' -import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' +import {atoms as a, useTheme as useAlf} from '#/alf' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' -import { - EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, - EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, -} from '#/components/icons/Emoji' -import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' -import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' -import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' -import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' -import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' -import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' -import {Loader} from '#/components/Loader' +import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' -import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {EventStopper} from '../EventStopper' -import * as Toast from '../Toast' +import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' let PostDropdownBtn = ({ testID, @@ -102,266 +46,27 @@ let PostDropdownBtn = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { - const {hasSession, currentAccount} = useSession() const theme = useTheme() const alf = useAlf() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl - const langPrefs = useLanguagePrefs() - const {mutateAsync: deletePostMutate} = usePostDeleteMutation() - const {mutateAsync: pinPostMutate, isPending: isPinPending} = - usePinnedPostMutation() - const hiddenPosts = useHiddenPosts() - const {hidePost} = useHiddenPostsApi() - const feedFeedback = useFeedFeedbackContext() - const openLink = useOpenLink() - const navigation = useNavigation<NavigationProp>() - const {mutedWordsDialogControl} = useGlobalDialogsControlContext() - const reportDialogControl = useReportDialogControl() - const deletePromptControl = useDialogControl() - const hidePromptControl = useDialogControl() - const loggedOutWarningPromptControl = useDialogControl() - const embedPostControl = useDialogControl() - const sendViaChatControl = useDialogControl() - const postInteractionSettingsDialogControl = useDialogControl() - const quotePostDetachConfirmControl = useDialogControl() - const hideReplyConfirmControl = useDialogControl() - const {mutateAsync: toggleReplyVisibility} = - useToggleReplyVisibilityMutation() - - const postUri = post.uri - const postCid = post.cid - const postAuthor = post.author - const quoteEmbed = React.useMemo(() => { - if (!currentAccount || !post.embed) return - return getMaybeDetachedQuoteEmbed({ - viewerDid: currentAccount.did, - post, - }) - }, [post, currentAccount]) - - const rootUri = record.reply?.root?.uri || postUri - const isReply = Boolean(record.reply) - const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( - post, - rootUri, - ) - const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) - const isAuthor = postAuthor.did === currentAccount?.did - const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) - const isPinned = post.viewer?.pinned - - const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = - useToggleQuoteDetachmentMutation() - - const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ - postUri: post.uri, - rootPostUri: rootUri, - }) - - const href = React.useMemo(() => { - const urip = new AtUri(postUri) - return makeProfileLink(postAuthor, 'post', urip.rkey) - }, [postUri, postAuthor]) - - const translatorUrl = getTranslatorLink( - record.text, - langPrefs.primaryLanguage, - ) - - const onDeletePost = React.useCallback(() => { - deletePostMutate({uri: postUri}).then( - () => { - Toast.show(_(msg`Post deleted`)) - - const route = getCurrentRoute(navigation.getState()) - if (route.name === 'PostThread') { - const params = route.params as CommonNavigatorParams['PostThread'] - if ( - currentAccount && - isAuthor && - (params.name === currentAccount.handle || - params.name === currentAccount.did) - ) { - const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) - if (currentHref === href && navigation.canGoBack()) { - navigation.goBack() - } - } - } - }, - e => { - logger.error('Failed to delete post', {message: e}) - Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) }, - ) - }, [ - navigation, - postUri, - deletePostMutate, - postAuthor, - currentAccount, - isAuthor, - href, - _, - ]) - - const onToggleThreadMute = React.useCallback(() => { - try { - if (isThreadMuted) { - unmuteThread() - Toast.show(_(msg`You will now receive notifications for this thread`)) - } else { - muteThread() - Toast.show( - _(msg`You will no longer receive notifications for this thread`), - ) - } - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to toggle thread mute', {message: e}) - Toast.show( - _(msg`Failed to toggle thread mute, please try again`), - 'xmark', - ) - } - } - }, [isThreadMuted, unmuteThread, _, muteThread]) - - const onCopyPostText = React.useCallback(() => { - const str = richTextToString(richText, true) - - Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - }, [_, richText]) - - const onPressTranslate = React.useCallback(async () => { - await openLink(translatorUrl) - }, [openLink, translatorUrl]) - - const onHidePost = React.useCallback(() => { - hidePost({uri: postUri}) - }, [postUri, hidePost]) - - const hideInPWI = React.useMemo(() => { - return !!postAuthor.labels?.find( - label => label.val === '!no-unauthenticated', - ) - }, [postAuthor]) - - const showLoggedOutWarning = - postAuthor.did !== currentAccount?.did && hideInPWI - - const onSharePost = React.useCallback(() => { - const url = toShareUrl(href) - shareUrl(url) - }, [href]) - - const onPressShowMore = React.useCallback(() => { - feedFeedback.sendInteraction({ - event: 'app.bsky.feed.defs#requestMore', - item: postUri, - feedContext: postFeedContext, - }) - Toast.show(_(msg`Feedback sent!`)) - }, [feedFeedback, postUri, postFeedContext, _]) - - const onPressShowLess = React.useCallback(() => { - feedFeedback.sendInteraction({ - event: 'app.bsky.feed.defs#requestLess', - item: postUri, - feedContext: postFeedContext, - }) - Toast.show(_(msg`Feedback sent!`)) - }, [feedFeedback, postUri, postFeedContext, _]) - - const onSelectChatToShareTo = React.useCallback( - (conversation: string) => { - navigation.navigate('MessagesConversation', { - conversation, - embed: postUri, - }) - }, - [navigation, postUri], + }), + [menuControl, setHasBeenOpen], ) - - const onToggleQuotePostAttachment = React.useCallback(async () => { - if (!quoteEmbed) return - - const action = quoteEmbed.isDetached ? 'reattach' : 'detach' - const isDetach = action === 'detach' - - try { - await toggleQuoteDetachment({ - post, - quoteUri: quoteEmbed.uri, - action: quoteEmbed.isDetached ? 'reattach' : 'detach', - }) - Toast.show( - isDetach - ? _(msg`Quote post was successfully detached`) - : _(msg`Quote post was re-attached`), - ) - } catch (e: any) { - Toast.show(_(msg`Updating quote attachment failed`)) - logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) - } - }, [_, quoteEmbed, post, toggleQuoteDetachment]) - - const canHidePostForMe = !isAuthor && !isPostHidden - const canEmbed = isWeb && gtMobile && !hideInPWI - const canHideReplyForEveryone = - !isAuthor && isRootPostAuthor && !isPostHidden && isReply - const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer - - const onToggleReplyVisibility = React.useCallback(async () => { - // TODO no threadgate? - if (!canHideReplyForEveryone) return - - const action = isReplyHiddenByThreadgate ? 'show' : 'hide' - const isHide = action === 'hide' - - try { - await toggleReplyVisibility({ - postUri: rootUri, - replyUri: postUri, - action, - }) - Toast.show( - isHide - ? _(msg`Reply was successfully hidden`) - : _(msg`Reply visibility updated`), - ) - } catch (e: any) { - Toast.show(_(msg`Updating reply visibility failed`)) - logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) - } - }, [ - _, - isReplyHiddenByThreadgate, - rootUri, - postUri, - canHideReplyForEveryone, - toggleReplyVisibility, - ]) - - const onPressPin = useCallback(() => { - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) - pinPostMutate({ - postUri, - postCid, - action: isPinned ? 'unpin' : 'pin', - }) - }, [isPinned, pinPostMutate, postCid, postUri]) - return ( <EventStopper onKeyDown={false}> - <Menu.Root> + <Menu.Root control={lazyMenuControl}> <Menu.Trigger label={_(msg`Open post options menu`)}> {({props, state}) => { return ( @@ -385,366 +90,19 @@ let PostDropdownBtn = ({ ) }} </Menu.Trigger> - - <Menu.Outer> - {isAuthor && ( - <> - <Menu.Group> - <Menu.Item - testID="pinPostBtn" - label={ - isPinned - ? _(msg`Unpin from profile`) - : _(msg`Pin to your profile`) - } - disabled={isPinPending} - onPress={onPressPin}> - <Menu.ItemText> - {isPinned - ? _(msg`Unpin from profile`) - : _(msg`Pin to your profile`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isPinPending ? Loader : PinIcon} - position="right" - /> - </Menu.Item> - </Menu.Group> - <Menu.Divider /> - </> - )} - - <Menu.Group> - {(!hideInPWI || hasSession) && ( - <> - <Menu.Item - testID="postDropdownTranslateBtn" - label={_(msg`Translate`)} - onPress={onPressTranslate}> - <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> - <Menu.ItemIcon icon={Translate} position="right" /> - </Menu.Item> - - <Menu.Item - testID="postDropdownCopyTextBtn" - label={_(msg`Copy post text`)} - onPress={onCopyPostText}> - <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> - <Menu.ItemIcon icon={ClipboardIcon} position="right" /> - </Menu.Item> - </> - )} - - {hasSession && ( - <Menu.Item - testID="postDropdownSendViaDMBtn" - label={_(msg`Send via direct message`)} - onPress={() => sendViaChatControl.open()}> - <Menu.ItemText> - <Trans>Send via direct message</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Send} position="right" /> - </Menu.Item> - )} - - <Menu.Item - testID="postDropdownShareBtn" - label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - onPress={() => { - if (showLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onSharePost() - } - }}> - <Menu.ItemText> - {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - - {canEmbed && ( - <Menu.Item - testID="postDropdownEmbedBtn" - label={_(msg`Embed post`)} - onPress={() => embedPostControl.open()}> - <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> - <Menu.ItemIcon icon={CodeBrackets} position="right" /> - </Menu.Item> - )} - </Menu.Group> - - {hasSession && feedFeedback.enabled && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="postDropdownShowMoreBtn" - label={_(msg`Show more like this`)} - onPress={onPressShowMore}> - <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> - <Menu.ItemIcon icon={EmojiSmile} position="right" /> - </Menu.Item> - - <Menu.Item - testID="postDropdownShowLessBtn" - label={_(msg`Show less like this`)} - onPress={onPressShowLess}> - <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> - <Menu.ItemIcon icon={EmojiSad} position="right" /> - </Menu.Item> - </Menu.Group> - </> - )} - - {hasSession && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="postDropdownMuteThreadBtn" - label={ - isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) - } - onPress={onToggleThreadMute}> - <Menu.ItemText> - {isThreadMuted - ? _(msg`Unmute thread`) - : _(msg`Mute thread`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isThreadMuted ? Unmute : Mute} - position="right" - /> - </Menu.Item> - - <Menu.Item - testID="postDropdownMuteWordsBtn" - label={_(msg`Mute words & tags`)} - onPress={() => mutedWordsDialogControl.open()}> - <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> - <Menu.ItemIcon icon={Filter} position="right" /> - </Menu.Item> - </Menu.Group> - </> - )} - - {hasSession && - (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( - <> - <Menu.Divider /> - <Menu.Group> - {canHidePostForMe && ( - <Menu.Item - testID="postDropdownHideBtn" - label={ - isReply - ? _(msg`Hide reply for me`) - : _(msg`Hide post for me`) - } - onPress={() => hidePromptControl.open()}> - <Menu.ItemText> - {isReply - ? _(msg`Hide reply for me`) - : _(msg`Hide post for me`)} - </Menu.ItemText> - <Menu.ItemIcon icon={EyeSlash} position="right" /> - </Menu.Item> - )} - {canHideReplyForEveryone && ( - <Menu.Item - testID="postDropdownHideBtn" - label={ - isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`) - } - onPress={ - isReplyHiddenByThreadgate - ? onToggleReplyVisibility - : () => hideReplyConfirmControl.open() - }> - <Menu.ItemText> - {isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} - position="right" - /> - </Menu.Item> - )} - - {canDetachQuote && ( - <Menu.Item - disabled={isDetachPending} - testID="postDropdownHideBtn" - label={ - quoteEmbed.isDetached - ? _(msg`Re-attach quote`) - : _(msg`Detach quote`) - } - onPress={ - quoteEmbed.isDetached - ? onToggleQuotePostAttachment - : () => quotePostDetachConfirmControl.open() - }> - <Menu.ItemText> - {quoteEmbed.isDetached - ? _(msg`Re-attach quote`) - : _(msg`Detach quote`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={ - isDetachPending - ? Loader - : quoteEmbed.isDetached - ? Eye - : EyeSlash - } - position="right" - /> - </Menu.Item> - )} - </Menu.Group> - </> - )} - - {hasSession && ( - <> - <Menu.Divider /> - <Menu.Group> - {!isAuthor && ( - <Menu.Item - testID="postDropdownReportBtn" - label={_(msg`Report post`)} - onPress={() => reportDialogControl.open()}> - <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> - <Menu.ItemIcon icon={Warning} position="right" /> - </Menu.Item> - )} - - {isAuthor && ( - <> - <Menu.Item - testID="postDropdownEditPostInteractions" - label={_(msg`Edit interaction settings`)} - onPress={() => - postInteractionSettingsDialogControl.open() - } - {...(isAuthor - ? Platform.select({ - web: { - onHoverIn: prefetchPostInteractionSettings, - }, - native: { - onPressIn: prefetchPostInteractionSettings, - }, - }) - : {})}> - <Menu.ItemText> - {_(msg`Edit interaction settings`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Gear} position="right" /> - </Menu.Item> - <Menu.Item - testID="postDropdownDeleteBtn" - label={_(msg`Delete post`)} - onPress={() => deletePromptControl.open()}> - <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> - <Menu.ItemIcon icon={Trash} position="right" /> - </Menu.Item> - </> - )} - </Menu.Group> - </> - )} - </Menu.Outer> - </Menu.Root> - - <Prompt.Basic - control={deletePromptControl} - title={_(msg`Delete this post?`)} - description={_( - msg`If you remove this post, you won't be able to recover it.`, - )} - onConfirm={onDeletePost} - confirmButtonCta={_(msg`Delete`)} - confirmButtonColor="negative" - /> - - <Prompt.Basic - control={hidePromptControl} - title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} - description={_( - msg`This post will be hidden from feeds and threads. This cannot be undone.`, + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <PostDropdownMenuItems + testID={testID} + post={post} + postFeedContext={postFeedContext} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + /> )} - onConfirm={onHidePost} - confirmButtonCta={_(msg`Hide`)} - /> - - <ReportDialog - control={reportDialogControl} - params={{ - type: 'post', - uri: postUri, - cid: postCid, - }} - /> - - <Prompt.Basic - control={loggedOutWarningPromptControl} - title={_(msg`Note about sharing`)} - description={_( - msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`, - )} - onConfirm={onSharePost} - confirmButtonCta={_(msg`Share anyway`)} - /> - - {canEmbed && ( - <EmbedDialog - control={embedPostControl} - postCid={postCid} - postUri={postUri} - record={record} - postAuthor={postAuthor} - timestamp={timestamp} - /> - )} - - <SendViaChatDialog - control={sendViaChatControl} - onSelectChat={onSelectChatToShareTo} - /> - - <PostInteractionSettingsDialog - control={postInteractionSettingsDialogControl} - postUri={post.uri} - rootPostUri={rootUri} - initialThreadgateView={post.threadgate} - /> - - <Prompt.Basic - control={quotePostDetachConfirmControl} - title={_(msg`Detach quote post?`)} - description={_( - msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, - )} - onConfirm={onToggleQuotePostAttachment} - confirmButtonCta={_(msg`Yes, detach`)} - /> - - <Prompt.Basic - control={hideReplyConfirmControl} - title={_(msg`Hide this reply?`)} - description={_( - msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, - )} - onConfirm={onToggleReplyVisibility} - confirmButtonCta={_(msg`Yes, hide`)} - /> + </Menu.Root> </EventStopper> ) } diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx new file mode 100644 index 000000000..149bb9ad2 --- /dev/null +++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx @@ -0,0 +1,751 @@ +import React, {memo, useCallback} from 'react' +import { + Platform, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getCurrentRoute} from '#/lib/routes/helpers' +import {makeProfileLink} from '#/lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {getTranslatorLink} from '#/locale/helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {useLanguagePrefs} from '#/state/preferences' +import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' +import {usePinnedPostMutation} from '#/state/queries/pinned-post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' +import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' +import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' +import {useSession} from '#/state/session' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {useBreakpoints} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {EmbedDialog} from '#/components/dialogs/Embed' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' +import { + EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, +} from '#/components/icons/Emoji' +import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import * as Toast from '../Toast' + +let PostDropdownMenuItems = ({ + post, + postFeedContext, + record, + richText, + timestamp, + threadgateRecord, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + postFeedContext: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + hitSlop?: PressableProps['hitSlop'] + size?: 'lg' | 'md' | 'sm' + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const {mutateAsync: deletePostMutate} = usePostDeleteMutation() + const {mutateAsync: pinPostMutate, isPending: isPinPending} = + usePinnedPostMutation() + const hiddenPosts = useHiddenPosts() + const {hidePost} = useHiddenPostsApi() + const feedFeedback = useFeedFeedbackContext() + const openLink = useOpenLink() + const navigation = useNavigation<NavigationProp>() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const blockPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const deletePromptControl = useDialogControl() + const hidePromptControl = useDialogControl() + const loggedOutWarningPromptControl = useDialogControl() + const embedPostControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const postInteractionSettingsDialogControl = useDialogControl() + const quotePostDetachConfirmControl = useDialogControl() + const hideReplyConfirmControl = useDialogControl() + const {mutateAsync: toggleReplyVisibility} = + useToggleReplyVisibilityMutation() + + const postUri = post.uri + const postCid = post.cid + const postAuthor = useProfileShadow(post.author) + const quoteEmbed = React.useMemo(() => { + if (!currentAccount || !post.embed) return + return getMaybeDetachedQuoteEmbed({ + viewerDid: currentAccount.did, + post, + }) + }, [post, currentAccount]) + + const rootUri = record.reply?.root?.uri || postUri + const isReply = Boolean(record.reply) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) + const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) + const isAuthor = postAuthor.did === currentAccount?.did + const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) + const isPinned = post.viewer?.pinned + + const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = + useToggleQuoteDetachmentMutation() + + const [queueBlock] = useProfileBlockMutationQueue(postAuthor) + + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) + + const href = React.useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = React.useCallback(() => { + deletePostMutate({uri: postUri}).then( + () => { + Toast.show(_(msg`Post deleted`)) + + const route = getCurrentRoute(navigation.getState()) + if (route.name === 'PostThread') { + const params = route.params as CommonNavigatorParams['PostThread'] + if ( + currentAccount && + isAuthor && + (params.name === currentAccount.handle || + params.name === currentAccount.did) + ) { + const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) + if (currentHref === href && navigation.canGoBack()) { + navigation.goBack() + } + } + } + }, + e => { + logger.error('Failed to delete post', {message: e}) + Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + }, + ) + }, [ + navigation, + postUri, + deletePostMutate, + postAuthor, + currentAccount, + isAuthor, + href, + _, + ]) + + const onToggleThreadMute = React.useCallback(() => { + try { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() + Toast.show( + _(msg`You will no longer receive notifications for this thread`), + ) + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show( + _(msg`Failed to toggle thread mute, please try again`), + 'xmark', + ) + } + } + }, [isThreadMuted, unmuteThread, _, muteThread]) + + const onCopyPostText = React.useCallback(() => { + const str = richTextToString(richText, true) + + Clipboard.setStringAsync(str) + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + }, [_, richText]) + + const onPressTranslate = React.useCallback(async () => { + await openLink(translatorUrl, true) + }, [openLink, translatorUrl]) + + const onHidePost = React.useCallback(() => { + hidePost({uri: postUri}) + }, [postUri, hidePost]) + + const hideInPWI = React.useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onSharePost = React.useCallback(() => { + const url = toShareUrl(href) + shareUrl(url) + }, [href]) + + const onPressShowMore = React.useCallback(() => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestMore', + item: postUri, + feedContext: postFeedContext, + }) + Toast.show(_(msg`Feedback sent!`)) + }, [feedFeedback, postUri, postFeedContext, _]) + + const onPressShowLess = React.useCallback(() => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestLess', + item: postUri, + feedContext: postFeedContext, + }) + Toast.show(_(msg`Feedback sent!`)) + }, [feedFeedback, postUri, postFeedContext, _]) + + const onSelectChatToShareTo = React.useCallback( + (conversation: string) => { + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + }, + [navigation, postUri], + ) + + const onToggleQuotePostAttachment = React.useCallback(async () => { + if (!quoteEmbed) return + + const action = quoteEmbed.isDetached ? 'reattach' : 'detach' + const isDetach = action === 'detach' + + try { + await toggleQuoteDetachment({ + post, + quoteUri: quoteEmbed.uri, + action: quoteEmbed.isDetached ? 'reattach' : 'detach', + }) + Toast.show( + isDetach + ? _(msg`Quote post was successfully detached`) + : _(msg`Quote post was re-attached`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating quote attachment failed`)) + logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) + } + }, [_, quoteEmbed, post, toggleQuoteDetachment]) + + const canHidePostForMe = !isAuthor && !isPostHidden + const canEmbed = isWeb && gtMobile && !hideInPWI + const canHideReplyForEveryone = + !isAuthor && isRootPostAuthor && !isPostHidden && isReply + const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer + + const onToggleReplyVisibility = React.useCallback(async () => { + // TODO no threadgate? + if (!canHideReplyForEveryone) return + + const action = isReplyHiddenByThreadgate ? 'show' : 'hide' + const isHide = action === 'hide' + + try { + await toggleReplyVisibility({ + postUri: rootUri, + replyUri: postUri, + action, + }) + Toast.show( + isHide + ? _(msg`Reply was successfully hidden`) + : _(msg`Reply visibility updated`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating reply visibility failed`)) + logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) + } + }, [ + _, + isReplyHiddenByThreadgate, + rootUri, + postUri, + canHideReplyForEveryone, + toggleReplyVisibility, + ]) + + const onPressPin = useCallback(() => { + logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) + pinPostMutate({ + postUri, + postCid, + action: isPinned ? 'unpin' : 'pin', + }) + }, [isPinned, pinPostMutate, postCid, postUri]) + + const onBlockAuthor = useCallback(async () => { + try { + await queueBlock() + Toast.show(_(msg`Account blocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + } + } + }, [_, queueBlock]) + + return ( + <> + <Menu.Outer> + {isAuthor && ( + <> + <Menu.Group> + <Menu.Item + testID="pinPostBtn" + label={ + isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`) + } + disabled={isPinPending} + onPress={onPressPin}> + <Menu.ItemText> + {isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isPinPending ? Loader : PinIcon} + position="right" + /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + </> + )} + + <Menu.Group> + {(!hideInPWI || hasSession) && ( + <> + <Menu.Item + testID="postDropdownTranslateBtn" + label={_(msg`Translate`)} + onPress={onPressTranslate}> + <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> + <Menu.ItemIcon icon={Translate} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownCopyTextBtn" + label={_(msg`Copy post text`)} + onPress={onCopyPostText}> + <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </> + )} + + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => sendViaChatControl.open()}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + + <Menu.Item + testID="postDropdownShareBtn" + label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + onPress={() => { + if (showLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onSharePost() + } + }}> + <Menu.ItemText> + {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + + {canEmbed && ( + <Menu.Item + testID="postDropdownEmbedBtn" + label={_(msg`Embed post`)} + onPress={() => embedPostControl.open()}> + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> + <Menu.ItemIcon icon={CodeBrackets} position="right" /> + </Menu.Item> + )} + </Menu.Group> + + {hasSession && feedFeedback.enabled && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownShowMoreBtn" + label={_(msg`Show more like this`)} + onPress={onPressShowMore}> + <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSmile} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShowLessBtn" + label={_(msg`Show less like this`)} + onPress={onPressShowLess}> + <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSad} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownMuteThreadBtn" + label={ + isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) + } + onPress={onToggleThreadMute}> + <Menu.ItemText> + {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isThreadMuted ? Unmute : Mute} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="postDropdownMuteWordsBtn" + label={_(msg`Mute words & tags`)} + onPress={() => mutedWordsDialogControl.open()}> + <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> + <Menu.ItemIcon icon={Filter} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( + <> + <Menu.Divider /> + <Menu.Group> + {canHidePostForMe && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`) + } + onPress={() => hidePromptControl.open()}> + <Menu.ItemText> + {isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`)} + </Menu.ItemText> + <Menu.ItemIcon icon={EyeSlash} position="right" /> + </Menu.Item> + )} + {canHideReplyForEveryone && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`) + } + onPress={ + isReplyHiddenByThreadgate + ? onToggleReplyVisibility + : () => hideReplyConfirmControl.open() + }> + <Menu.ItemText> + {isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} + position="right" + /> + </Menu.Item> + )} + + {canDetachQuote && ( + <Menu.Item + disabled={isDetachPending} + testID="postDropdownHideBtn" + label={ + quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`) + } + onPress={ + quoteEmbed.isDetached + ? onToggleQuotePostAttachment + : () => quotePostDetachConfirmControl.open() + }> + <Menu.ItemText> + {quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={ + isDetachPending + ? Loader + : quoteEmbed.isDetached + ? Eye + : EyeSlash + } + position="right" + /> + </Menu.Item> + )} + </Menu.Group> + </> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + {!isAuthor && ( + <> + {!postAuthor.viewer?.blocking && ( + <Menu.Item + testID="postDropdownBlockBtn" + label={_(msg`Block account`)} + onPress={() => blockPromptControl.open()}> + <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> + <Menu.ItemIcon icon={PersonX} position="right" /> + </Menu.Item> + )} + <Menu.Item + testID="postDropdownReportBtn" + label={_(msg`Report post`)} + onPress={() => reportDialogControl.open()}> + <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + </> + )} + + {isAuthor && ( + <> + <Menu.Item + testID="postDropdownEditPostInteractions" + label={_(msg`Edit interaction settings`)} + onPress={() => postInteractionSettingsDialogControl.open()} + {...(isAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})}> + <Menu.ItemText> + {_(msg`Edit interaction settings`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Gear} position="right" /> + </Menu.Item> + <Menu.Item + testID="postDropdownDeleteBtn" + label={_(msg`Delete post`)} + onPress={() => deletePromptControl.open()}> + <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + </> + )} + </Menu.Group> + </> + )} + </Menu.Outer> + + <Prompt.Basic + control={deletePromptControl} + title={_(msg`Delete this post?`)} + description={_( + msg`If you remove this post, you won't be able to recover it.`, + )} + onConfirm={onDeletePost} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <Prompt.Basic + control={hidePromptControl} + title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} + description={_( + msg`This post will be hidden from feeds and threads. This cannot be undone.`, + )} + onConfirm={onHidePost} + confirmButtonCta={_(msg`Hide`)} + /> + + <ReportDialog + control={reportDialogControl} + params={{ + type: 'post', + uri: postUri, + cid: postCid, + }} + /> + + <Prompt.Basic + control={loggedOutWarningPromptControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`, + )} + onConfirm={onSharePost} + confirmButtonCta={_(msg`Share anyway`)} + /> + + {canEmbed && ( + <EmbedDialog + control={embedPostControl} + postCid={postCid} + postUri={postUri} + record={record} + postAuthor={postAuthor} + timestamp={timestamp} + /> + )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + + <PostInteractionSettingsDialog + control={postInteractionSettingsDialogControl} + postUri={post.uri} + rootPostUri={rootUri} + initialThreadgateView={post.threadgate} + /> + + <Prompt.Basic + control={quotePostDetachConfirmControl} + title={_(msg`Detach quote post?`)} + description={_( + msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, + )} + onConfirm={onToggleQuotePostAttachment} + confirmButtonCta={_(msg`Yes, detach`)} + /> + + <Prompt.Basic + control={hideReplyConfirmControl} + title={_(msg`Hide this reply?`)} + description={_( + msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, + )} + onConfirm={onToggleReplyVisibility} + confirmButtonCta={_(msg`Yes, hide`)} + /> + + <Prompt.Basic + control={blockPromptControl} + title={_(msg`Block Account?`)} + description={_( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + )} + onConfirm={onBlockAuthor} + confirmButtonCta={_(msg`Block`)} + confirmButtonColor="negative" + /> + </> + ) +} +PostDropdownMenuItems = memo(PostDropdownMenuItems) +export {PostDropdownMenuItems} diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index e2bf3c9ac..7cf0f2d73 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {choose} from '#/lib/functions' diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index c6cf63930..e2a26dc49 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import {View} from 'react-native' import {s} from '#/lib/styles' diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx index 1d74b935a..76161b433 100644 --- a/src/view/com/util/forms/SelectableBtn.tsx +++ b/src/view/com/util/forms/SelectableBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 706796fc4..31222aafe 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {choose} from '#/lib/functions' diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index fe8911e31..617b9bec4 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,11 +1,11 @@ -import React from 'react' +import React, {useRef} from 'react' import {DimensionValue, Pressable, View} from 'react-native' -import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' import type {Dimensions} from '#/lib/media/types' import {isNative} from '#/platform/detection' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' @@ -68,26 +68,27 @@ export function AutoSizedImage({ image: AppBskyEmbedImages.ViewImage crop?: 'none' | 'square' | 'constrained' hideBadge?: boolean - onPress?: ( - containerRef: AnimatedRef<React.Component<{}, {}, any>>, - fetchedDims: Dimensions | null, - ) => void + onPress?: (containerRef: HandleRef, fetchedDims: Dimensions | null) => void onLongPress?: () => void onPressIn?: () => void }) { const t = useTheme() const {_} = useLingui() const largeAlt = useLargeAltBadgeEnabled() - const containerRef = useAnimatedRef() + const containerRef = useHandleRef() + const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) - const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null) - const dims = fetchedDims ?? image.aspectRatio let aspectRatio: number | undefined + const dims = image.aspectRatio if (dims) { aspectRatio = dims.width / dims.height if (Number.isNaN(aspectRatio)) { aspectRatio = undefined } + } else { + // If we don't know it synchronously, treat it like a square. + // We won't use fetched dimensions to avoid a layout shift. + aspectRatio = 1 } let constrained: number | undefined @@ -105,7 +106,7 @@ export function AutoSizedImage({ const hasAlt = !!image.alt const contents = ( - <Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}> + <View ref={containerRef} collapsable={false} style={{flex: 1}}> <Image style={[a.w_full, a.h_full]} source={image.thumb} @@ -113,13 +114,12 @@ export function AutoSizedImage({ accessibilityIgnoresInvertColors accessibilityLabel={image.alt} accessibilityHint="" - onLoad={ - fetchedDims - ? undefined - : e => { - setFetchedDims({width: e.source.width, height: e.source.height}) - } - } + onLoad={e => { + fetchedDimsRef.current = { + width: e.source.width, + height: e.source.height, + } + }} /> <MediaInsetBorder /> @@ -185,13 +185,13 @@ export function AutoSizedImage({ )} </View> ) : null} - </Animated.View> + </View> ) if (cropDisabled) { return ( <Pressable - onPress={() => onPress?.(containerRef, fetchedDims)} + onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -213,7 +213,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> <Pressable - onPress={() => onPress?.(containerRef, fetchedDims)} + onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 9d0817bd2..cc3eda68d 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,11 +1,11 @@ import React from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {AnimatedRef} from 'react-native-reanimated' import {Image, ImageStyle} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HandleRef} from '#/lib/hooks/useHandleRef' import {Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' @@ -20,7 +20,7 @@ interface Props { index: number onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: EventFunction @@ -28,7 +28,7 @@ interface Props { imageStyle?: StyleProp<ImageStyle> viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp<ViewStyle> - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[] + containerRefs: HandleRef[] thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> } @@ -52,10 +52,7 @@ export function GalleryItem({ const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia return ( - <Animated.View - style={a.flex_1} - ref={containerRefs[index]} - collapsable={false}> + <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <Pressable onPress={ onPress @@ -118,6 +115,6 @@ export function GalleryItem({ </Text> </View> ) : null} - </Animated.View> + </View> ) } diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx index e779fa378..94563ef9c 100644 --- a/src/view/com/util/images/Image.tsx +++ b/src/view/com/util/images/Image.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Image, ImageProps, ImageSource} from 'expo-image' interface HighPriorityImageProps extends ImageProps { diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index dcc330dac..16ea9d453 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {AppBskyEmbedImages} from '@atproto/api' +import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useBreakpoints} from '#/alf' import {Dimensions} from '../../lightbox/ImageViewing/@types' @@ -12,7 +12,7 @@ interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void @@ -43,7 +43,7 @@ interface ImageLayoutGridInnerProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void @@ -56,10 +56,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const gap = props.gap const count = props.images.length - const containerRef1 = useAnimatedRef() - const containerRef2 = useAnimatedRef() - const containerRef3 = useAnimatedRef() - const containerRef4 = useAnimatedRef() + const containerRef1 = useHandleRef() + const containerRef2 = useHandleRef() + const containerRef3 = useHandleRef() + const containerRef4 = useHandleRef() const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) switch (count) { diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 2310b1f27..d98aa0fa7 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' diff --git a/src/view/com/util/numeric/__tests__/format-test.ts b/src/view/com/util/numeric/__tests__/format-test.ts new file mode 100644 index 000000000..74df4be4c --- /dev/null +++ b/src/view/com/util/numeric/__tests__/format-test.ts @@ -0,0 +1,92 @@ +import {describe, expect, it} from '@jest/globals' + +import {APP_LANGUAGES} from '#/locale/languages' +import {formatCount} from '../format' + +const formatCountRound = (locale: string, num: number) => { + const options: Intl.NumberFormatOptions = { + notation: 'compact', + maximumFractionDigits: 1, + } + return new Intl.NumberFormat(locale, options).format(num) +} + +const formatCountTrunc = (locale: string, num: number) => { + const options: Intl.NumberFormatOptions = { + notation: 'compact', + maximumFractionDigits: 1, + // @ts-ignore + roundingMode: 'trunc', + } + return new Intl.NumberFormat(locale, options).format(num) +} + +// prettier-ignore +const testNums = [ + 1, + 5, + 9, + 11, + 55, + 99, + 111, + 555, + 999, + 1111, + 5555, + 9999, + 11111, + 55555, + 99999, + 111111, + 555555, + 999999, + 1111111, + 5555555, + 9999999, + 11111111, + 55555555, + 99999999, + 111111111, + 555555555, + 999999999, + 1111111111, + 5555555555, + 9999999999, + 11111111111, + 55555555555, + 99999999999, + 111111111111, + 555555555555, + 999999999999, + 1111111111111, + 5555555555555, + 9999999999999, + 11111111111111, + 55555555555555, + 99999999999999, + 111111111111111, + 555555555555555, + 999999999999999, + 1111111111111111, + 5555555555555555, +] + +describe('formatCount', () => { + for (const appLanguage of APP_LANGUAGES) { + const locale = appLanguage.code2 + it('truncates for ' + locale, () => { + const mockI8nn = { + locale, + number(num: number) { + return formatCountRound(locale, num) + }, + } + for (const num of testNums) { + const formatManual = formatCount(mockI8nn as any, num) + const formatOriginal = formatCountTrunc(locale, num) + expect(formatManual).toEqual(formatOriginal) + } + }) + } +}) diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index cca9fc7e7..0c3d24957 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -1,12 +1,47 @@ -import type {I18n} from '@lingui/core' +import {I18n} from '@lingui/core' + +const truncateRounding = (num: number, factors: Array<number>): number => { + for (let i = factors.length - 1; i >= 0; i--) { + let factor = factors[i] + if (num >= 10 ** factor) { + if (factor === 10) { + // CA and ES abruptly jump from "9999,9 M" to "10 mil M" + factor-- + } + const precision = 1 + const divisor = 10 ** (factor - precision) + return Math.floor(num / divisor) * divisor + } + } + return num +} + +const koFactors = [3, 4, 8, 12] +const hiFactors = [3, 5, 7, 9, 11, 13] +const esCaFactors = [3, 6, 10, 12] +const itDeFactors = [6, 9, 12] +const jaZhFactors = [4, 8, 12] +const restFactors = [3, 6, 9, 12] export const formatCount = (i18n: I18n, num: number) => { - return i18n.number(num, { + const locale = i18n.locale + let truncatedNum: number + if (locale === 'hi') { + truncatedNum = truncateRounding(num, hiFactors) + } else if (locale === 'ko') { + truncatedNum = truncateRounding(num, koFactors) + } else if (locale === 'es' || locale === 'ca') { + truncatedNum = truncateRounding(num, esCaFactors) + } else if (locale === 'ja' || locale === 'zh-CN' || locale === 'zh-TW') { + truncatedNum = truncateRounding(num, jaZhFactors) + } else if (locale === 'it' || locale === 'de') { + truncatedNum = truncateRounding(num, itDeFactors) + } else { + truncatedNum = truncateRounding(num, restFactors) + } + return i18n.number(truncatedNum, { notation: 'compact', maximumFractionDigits: 1, - // `1,953` shouldn't be rounded up to 2k, it should be truncated. - // @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode - roundingMode: 'trunc', + // Ideally we'd use roundingMode: 'trunc' but it isn't supported on RN. }) } diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 28889429f..06b1fcaf6 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -1,6 +1,6 @@ import React, {memo, useCallback} from 'react' import {View} from 'react-native' -import {msg, plural} from '@lingui/macro' +import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {POST_CTRL_HITSLOP} from '#/lib/constants' @@ -36,16 +36,12 @@ let RepostButton = ({ const requireAuth = useRequireAuth() const dialogControl = Dialog.useDialogControl() const playHaptic = useHaptics() - const color = React.useMemo( () => ({ color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, }), [t, isReposted], ) - - const close = useCallback(() => dialogControl.close(), [dialogControl]) - return ( <> <Button @@ -92,84 +88,124 @@ let RepostButton = ({ control={dialogControl} nativeOptions={{preventExpansion: true}}> <Dialog.Handle /> - <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}> - <View style={a.gap_xl}> - <View style={a.gap_xs}> - <Button - style={[a.justify_start, a.px_md]} - label={ - isReposted - ? _(msg`Remove repost`) - : _(msg({message: `Repost`, context: 'action'})) - } - onPress={() => { - if (!isReposted) playHaptic() - - dialogControl.close(() => { - onRepost() - }) - }} - size="large" - variant="ghost" - color="primary"> - <Repost size="lg" fill={t.palette.primary_500} /> - <Text style={[a.font_bold, a.text_xl]}> - {isReposted - ? _(msg`Remove repost`) - : _(msg({message: `Repost`, context: 'action'}))} - </Text> - </Button> - <Button - disabled={embeddingDisabled} - testID="quoteBtn" - style={[a.justify_start, a.px_md]} - label={ - embeddingDisabled - ? _(msg`Quote posts disabled`) - : _(msg`Quote post`) - } - onPress={() => { - playHaptic() - dialogControl.close(() => { - onQuote() - }) - }} - size="large" - variant="ghost" - color="primary"> - <Quote - size="lg" - fill={ - embeddingDisabled - ? t.atoms.text_contrast_low.color - : t.palette.primary_500 - } - /> - <Text - style={[ - a.font_bold, - a.text_xl, - embeddingDisabled && t.atoms.text_contrast_low, - ]}> - {embeddingDisabled - ? _(msg`Quote posts disabled`) - : _(msg`Quote post`)} - </Text> - </Button> - </View> - <Button - label={_(msg`Cancel quote post`)} - onPress={close} - size="large" - variant="solid" - color="primary"> - <ButtonText>{_(msg`Cancel`)}</ButtonText> - </Button> - </View> - </Dialog.ScrollableInner> + <RepostButtonDialogInner + isReposted={isReposted} + onRepost={onRepost} + onQuote={onQuote} + embeddingDisabled={embeddingDisabled} + /> </Dialog.Outer> </> ) } RepostButton = memo(RepostButton) export {RepostButton} + +let RepostButtonDialogInner = ({ + isReposted, + onRepost, + onQuote, + embeddingDisabled, +}: { + isReposted: boolean + onRepost: () => void + onQuote: () => void + embeddingDisabled: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + + const onPressRepost = useCallback(() => { + if (!isReposted) playHaptic() + + control.close(() => { + onRepost() + }) + }, [control, isReposted, onRepost, playHaptic]) + + const onPressQuote = useCallback(() => { + playHaptic() + control.close(() => { + onQuote() + }) + }, [control, onQuote, playHaptic]) + + const onPressClose = useCallback(() => control.close(), [control]) + + return ( + <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}> + <View style={a.gap_xl}> + <View style={a.gap_xs}> + <Button + style={[a.justify_start, a.px_md]} + label={ + isReposted + ? _(msg`Remove repost`) + : _(msg({message: `Repost`, context: 'action'})) + } + onPress={onPressRepost} + size="large" + variant="ghost" + color="primary"> + <Repost size="lg" fill={t.palette.primary_500} /> + <Text style={[a.font_bold, a.text_xl]}> + {isReposted ? ( + <Trans>Remove repost</Trans> + ) : ( + <Trans context="action">Repost</Trans> + )} + </Text> + </Button> + <Button + disabled={embeddingDisabled} + testID="quoteBtn" + style={[a.justify_start, a.px_md]} + label={ + embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`) + } + onPress={onPressQuote} + size="large" + variant="ghost" + color="primary"> + <Quote + size="lg" + fill={ + embeddingDisabled + ? t.atoms.text_contrast_low.color + : t.palette.primary_500 + } + /> + <Text + style={[ + a.font_bold, + a.text_xl, + embeddingDisabled && t.atoms.text_contrast_low, + ]}> + {embeddingDisabled ? ( + <Trans>Quote posts disabled</Trans> + ) : ( + <Trans>Quote post</Trans> + )} + </Text> + </Button> + </View> + <Button + label={_(msg`Cancel quote post`)} + onPress={onPressClose} + size="large" + variant="outline" + color="primary"> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </Dialog.ScrollableInner> + ) +} +RepostButtonDialogInner = memo(RepostButtonDialogInner) +export {RepostButtonDialogInner} diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 111b41dd7..54119b532 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -104,9 +104,7 @@ export const RepostButton = ({ label={_(msg`Repost or quote post`)} style={{padding: 0}} hoverStyle={t.atoms.bg_contrast_25} - shape="round" - variant="ghost" - color="secondary"> + shape="round"> <RepostInner isReposted={isReposted} color={color} diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx index 6db4d6fef..39c1d109e 100644 --- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -1,16 +1,11 @@ import React from 'react' -import { - ActivityIndicator, - GestureResponderEvent, - LayoutChangeEvent, - Pressable, -} from 'react-native' -import {Image, ImageLoadEventData} from 'expo-image' +import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native' +import {Image} from 'expo-image' import {AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {EmbedPlayerParams, getGifDims} from '#/lib/strings/embed-player' +import {EmbedPlayerParams} from '#/lib/strings/embed-player' import {isIOS, isNative, isWeb} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' import {atoms as a, useTheme} from '#/alf' @@ -28,20 +23,15 @@ export function ExternalGifEmbed({ }) { const t = useTheme() const externalEmbedsPrefs = useExternalEmbedsPrefs() - const {_} = useLingui() const consentDialogControl = useDialogControl() - const thumbHasLoaded = React.useRef(false) - const viewWidth = React.useRef(0) - // Tracking if the placer has been activated const [isPlayerActive, setIsPlayerActive] = React.useState(false) // Tracking whether the gif has been loaded yet const [isPrefetched, setIsPrefetched] = React.useState(false) // Tracking whether the image is animating const [isAnimating, setIsAnimating] = React.useState(true) - const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) // Used for controlling animation const imageRef = React.useRef<Image>(null) @@ -93,16 +83,6 @@ export function ExternalGifEmbed({ ], ) - const onLoad = React.useCallback((e: ImageLoadEventData) => { - if (thumbHasLoaded.current) return - setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) - thumbHasLoaded.current = true - }, []) - - const onLayout = React.useCallback((e: LayoutChangeEvent) => { - viewWidth.current = e.nativeEvent.layout.width - }, []) - return ( <> <EmbedConsentDialog @@ -113,7 +93,7 @@ export function ExternalGifEmbed({ <Pressable style={[ - {height: imageDims.height}, + {height: 300}, a.w_full, a.overflow_hidden, { @@ -122,7 +102,6 @@ export function ExternalGifEmbed({ }, ]} onPress={onPlayPress} - onLayout={onLayout} accessibilityRole="button" accessibilityHint={_(msg`Plays the GIF`)} accessibilityLabel={_(msg`Play ${link.title}`)}> @@ -135,7 +114,6 @@ export function ExternalGifEmbed({ }} // Web uses the thumb to control playback style={{flex: 1}} ref={imageRef} - onLoad={onLoad} autoplay={isAnimating} contentFit="contain" accessibilityIgnoresInvertColors diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 24802d188..f268bf8db 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useState} from 'react' -import {View} from 'react-native' +import {ActivityIndicator, View} from 'react-native' import {ImageBackground} from 'expo-image' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -10,7 +10,6 @@ import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner import {atoms as a} from '#/alf' import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' -import {Loader} from '#/components/Loader' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {ErrorBoundary} from '../ErrorBoundary' import * as VideoFallback from './VideoEmbedInner/VideoFallback' @@ -89,12 +88,9 @@ function InnerWrapper({embed}: Props) { source={{uri: embed.thumbnail}} accessibilityIgnoresInvertColors style={[ + a.absolute, + a.inset_0, { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, // the play button won't show up on the first render on android 🥴😮💨 display: showOverlay ? 'flex' : 'none', @@ -102,27 +98,29 @@ function InnerWrapper({embed}: Props) { ]} cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android > - <Button - style={[a.flex_1, a.align_center, a.justify_center]} - onPress={() => { - ref.current?.togglePlayback() - }} - label={_(msg`Play video`)} - color="secondary"> - {showSpinner ? ( - <View - style={[ - a.rounded_full, - a.p_xs, - a.align_center, - a.justify_center, - ]}> - <Loader size="2xl" style={{color: 'white'}} /> - </View> - ) : ( - <PlayButtonIcon /> - )} - </Button> + {showOverlay && ( + <Button + style={[a.flex_1, a.align_center, a.justify_center]} + onPress={() => { + ref.current?.togglePlayback() + }} + label={_(msg`Play video`)} + color="secondary"> + {showSpinner ? ( + <View + style={[ + a.rounded_full, + a.p_xs, + a.align_center, + a.justify_center, + ]}> + <ActivityIndicator size="large" color="white" /> + </View> + ) : ( + <PlayButtonIcon /> + )} + </Button> + )} </ImageBackground> </> ) diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 3180dd99e..a1f4652ac 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -24,6 +24,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { useActiveVideoWeb() const [onScreen, setOnScreen] = useState(false) const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef<number | undefined>() useEffect(() => { if (!ref.current) return @@ -82,6 +83,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { active={active} setActive={setActive} onScreen={onScreen} + lastKnownTime={lastKnownTime} /> </ViewportObserver> </ErrorBoundary> diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx index 66e1df50d..75e544aca 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx @@ -1,8 +1,9 @@ -import React from 'react' import {StyleProp, ViewStyle} from 'react-native' -import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a, native, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' /** @@ -17,6 +18,7 @@ export function TimeIndicator({ style?: StyleProp<ViewStyle> }) { const t = useTheme() + const {_} = useLingui() if (isNaN(time)) { return null @@ -26,10 +28,10 @@ export function TimeIndicator({ const seconds = String(time % 60).padStart(2, '0') return ( - <Animated.View - entering={native(FadeInDown.duration(300))} - exiting={native(FadeOutDown.duration(500))} + <View pointerEvents="none" + accessibilityLabel={_(msg`Time remaining: ${time} seconds`)} + accessibilityHint="" style={[ { backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -52,6 +54,6 @@ export function TimeIndicator({ ]}> {`${minutes}:${seconds}`} </Text> - </Animated.View> + </View> ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 21db54322..215e4c406 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,6 +1,5 @@ import React, {useRef} from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {FadeInDown} from 'react-native-reanimated' import {AppBskyEmbedVideo} from '@atproto/api' import {BlueskyVideoView} from '@haileyok/bluesky-video' import {msg} from '@lingui/macro' @@ -182,8 +181,7 @@ function ControlButton({ style?: StyleProp<ViewStyle> }) { return ( - <Animated.View - entering={FadeInDown.duration(300)} + <View style={[ a.absolute, a.rounded_full, @@ -207,6 +205,6 @@ function ControlButton({ hitSlop={HITSLOP_30}> {children} </Pressable> - </Animated.View> + </View> ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index ef989c4a4..e6882a2f6 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,6 +1,8 @@ import React, {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -13,11 +15,13 @@ export function VideoEmbedInnerWeb({ active, setActive, onScreen, + lastKnownTime, }: { embed: AppBskyEmbedVideo.View active: boolean setActive: () => void onScreen: boolean + lastKnownTime: React.MutableRefObject<number | undefined> }) { const containerRef = useRef<HTMLDivElement>(null) const videoRef = useRef<HTMLVideoElement>(null) @@ -25,6 +29,7 @@ export function VideoEmbedInnerWeb({ const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const [hlsLoading, setHlsLoading] = React.useState(false) const figId = useId() + const {_} = useLingui() // send error up to error boundary const [error, setError] = useState<Error | null>(null) @@ -40,8 +45,17 @@ export function VideoEmbedInnerWeb({ setHlsLoading, }) + useEffect(() => { + if (lastKnownTime.current && videoRef.current) { + videoRef.current.currentTime = lastKnownTime.current + } + }, [lastKnownTime]) + return ( - <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}> + <View + style={[a.flex_1, a.rounded_md, a.overflow_hidden]} + accessibilityLabel={_(msg`Embedded video player`)} + accessibilityHint=""> <div ref={containerRef} style={{height: '100%', width: '100%'}}> <figure style={{margin: 0, position: 'absolute', inset: 0}}> <video @@ -52,6 +66,9 @@ export function VideoEmbedInnerWeb({ preload="none" muted={!focused} aria-labelledby={embed.alt ? figId : undefined} + onTimeUpdate={e => { + lastKnownTime.current = e.currentTarget.currentTime + }} /> {embed.alt && ( <figcaption diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx index 8ffe482a8..651046445 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx @@ -23,7 +23,8 @@ export function ControlButton({ return ( <PressableWithHover accessibilityRole="button" - accessibilityHint={active ? activeLabel : inactiveLabel} + accessibilityLabel={active ? activeLabel : inactiveLabel} + accessibilityHint="" onPress={onPress} style={[ a.p_xs, @@ -32,9 +33,9 @@ export function ControlButton({ ]} hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}> {active ? ( - <ActiveIcon fill={t.palette.white} width={20} /> + <ActiveIcon fill={t.palette.white} width={20} aria-hidden /> ) : ( - <InactiveIcon fill={t.palette.white} width={20} /> + <InactiveIcon fill={t.palette.white} width={20} aria-hidden /> )} </PressableWithHover> ) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx index 44978ad51..74aad64e1 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx @@ -186,7 +186,9 @@ export function Scrubber({ </View> <div ref={circleRef} - aria-label={_(msg`Seek slider`)} + aria-label={_( + msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`, + )} role="slider" aria-valuemax={duration} aria-valuemin={0} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx index acd4d1aae..8e134d221 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx @@ -313,13 +313,14 @@ export function Controls({ onPointerEnter={onPointerMoveEmptySpace} onPointerMove={onPointerMoveEmptySpace} onPointerLeave={onPointerLeaveEmptySpace} - accessibilityHint={_( + accessibilityLabel={_( !focused ? msg`Unmute video` : playing ? msg`Pause video` : msg`Play video`, )} + accessibilityHint="" style={[ a.flex_1, web({cursor: showCursor || !playing ? 'pointer' : 'none'}), @@ -401,7 +402,7 @@ export function Controls({ <ControlButton active={isFullscreen} activeLabel={_(msg`Exit fullscreen`)} - inactiveLabel={_(msg`Fullscreen`)} + inactiveLabel={_(msg`Enter fullscreen`)} activeIcon={ArrowsInIcon} inactiveIcon={ArrowsOutIcon} onPress={onPressFullscreen} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx index 63ac32b10..90ffb9e6b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -77,6 +77,7 @@ export function VolumeControl({ min={0} max={100} value={sliderVolume} + aria-label={_(msg`Volume`)} style={ // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 1351a2cbc..9dc43da8e 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -6,13 +6,7 @@ import { View, ViewStyle, } from 'react-native' -import { - AnimatedRef, - measure, - MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' +import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' import {Image} from 'expo-image' import { AppBskyEmbedExternal, @@ -27,6 +21,7 @@ import { ModerationDecision, } from '@atproto/api' +import {HandleRef, measureHandle} from '#/lib/hooks/useHandleRef' import {usePalette} from '#/lib/hooks/usePalette' import {useLightboxControls} from '#/state/lightbox' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -163,12 +158,13 @@ export function PostEmbeds({ } const onPress = ( index: number, - refs: AnimatedRef<React.Component<{}, {}, any>>[], + refs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => { + const handles = refs.map(r => r.current) runOnUI(() => { 'worklet' - const rects = refs.map(ref => (ref ? measure(ref) : null)) + const rects = handles.map(measureHandle) runOnJS(_openLightbox)(index, rects, fetchedDims) })() } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx deleted file mode 100644 index a4cf517a4..000000000 --- a/src/view/com/util/text/RichText.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react' -import {StyleProp, TextStyle} from 'react-native' -import {AppBskyRichtextFacet, RichText as RichTextObj} from '@atproto/api' - -import {usePalette} from '#/lib/hooks/usePalette' -import {makeTagLink} from '#/lib/routes/links' -import {toShortUrl} from '#/lib/strings/url-helpers' -import {lh} from '#/lib/styles' -import {TypographyVariant, useTheme} from '#/lib/ThemeContext' -import {isNative} from '#/platform/detection' -import {TagMenu, useTagMenuControl} from '#/components/TagMenu' -import {TextLink} from '../Link' -import {Text} from './Text' - -const WORD_WRAP = {wordWrap: 1} - -/** - * @deprecated use `#/components/RichText` - */ -export function RichText({ - testID, - type = 'md', - richText, - lineHeight = 1.2, - style, - numberOfLines, - selectable, - noLinks, -}: { - testID?: string - type?: TypographyVariant - richText?: RichTextObj - lineHeight?: number - style?: StyleProp<TextStyle> - numberOfLines?: number - selectable?: boolean - noLinks?: boolean -}) { - const theme = useTheme() - const pal = usePalette('default') - const lineHeightStyle = lh(theme, type, lineHeight) - - if (!richText) { - return null - } - - const {text, facets} = richText - if (!facets?.length) { - if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { - style = { - fontSize: 26, - lineHeight: 30, - } - return ( - // @ts-ignore web only -prf - <Text - testID={testID} - style={[style, pal.text]} - dataSet={WORD_WRAP} - selectable={selectable}> - {text} - </Text> - ) - } - return ( - <Text - testID={testID} - type={type} - style={[style, pal.text, lineHeightStyle]} - numberOfLines={numberOfLines} - // @ts-ignore web only -prf - dataSet={WORD_WRAP} - selectable={selectable}> - {text} - </Text> - ) - } - if (!style) { - style = [] - } else if (!Array.isArray(style)) { - style = [style] - } - - const els = [] - let key = 0 - for (const segment of richText.segments()) { - const link = segment.link - const mention = segment.mention - const tag = segment.tag - if ( - !noLinks && - mention && - AppBskyRichtextFacet.validateMention(mention).success - ) { - els.push( - <TextLink - key={key} - type={type} - text={segment.text} - href={`/profile/${mention.did}`} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - />, - ) - } else if (link && AppBskyRichtextFacet.validateLink(link).success) { - if (noLinks) { - els.push(toShortUrl(segment.text)) - } else { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={link.uri} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - />, - ) - } - } else if ( - !noLinks && - tag && - AppBskyRichtextFacet.validateTag(tag).success - ) { - els.push( - <RichTextTag - key={key} - text={segment.text} - type={type} - style={style} - lineHeightStyle={lineHeightStyle} - selectable={selectable} - />, - ) - } else { - els.push(segment.text) - } - key++ - } - return ( - <Text - testID={testID} - type={type} - style={[style, pal.text, lineHeightStyle]} - numberOfLines={numberOfLines} - // @ts-ignore web only -prf - dataSet={WORD_WRAP} - selectable={selectable}> - {els} - </Text> - ) -} - -function RichTextTag({ - text: tag, - type, - style, - lineHeightStyle, - selectable, -}: { - text: string - type?: TypographyVariant - style?: StyleProp<TextStyle> - lineHeightStyle?: TextStyle - selectable?: boolean -}) { - const pal = usePalette('default') - const control = useTagMenuControl() - - const open = React.useCallback(() => { - control.open() - }, [control]) - - return ( - <React.Fragment> - <TagMenu control={control} tag={tag}> - {isNative ? ( - <TextLink - type={type} - text={tag} - // segment.text has the leading "#" while tag.tag does not - href={makeTagLink(tag)} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - onPress={open} - /> - ) : ( - <Text - selectable={selectable} - type={type} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}> - {tag} - </Text> - )} - </TagMenu> - </React.Fragment> - ) -} diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index dbf5e2e13..f05274f44 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleSheet, Text as RNText, TextProps} from 'react-native' +import {StyleSheet, TextProps} from 'react-native' import {UITextView} from 'react-native-uitextview' import {lh, s} from '#/lib/styles' @@ -9,10 +9,9 @@ import {isIOS, isWeb} from '#/platform/detection' import {applyFonts, useAlf} from '#/alf' import { childHasEmoji, - childIsString, renderChildrenWithEmoji, StringChild, -} from '#/components/Typography' +} from '#/alf/typography' import {IS_DEV} from '#/env' export type CustomTextProps = Omit<TextProps, 'children'> & { @@ -32,7 +31,11 @@ export type CustomTextProps = Omit<TextProps, 'children'> & { } ) -export function Text({ +export {Text_DEPRECATED as Text} +/** + * @deprecated use Text from Typography instead. + */ +function Text_DEPRECATED({ type = 'md', children, emoji, @@ -52,10 +55,6 @@ export function Text({ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`, ) } - - if (emoji && !childIsString(children)) { - logger.error('Text: when <Text emoji />, children can only be strings.') - } } const textProps = React.useMemo(() => { @@ -103,19 +102,9 @@ export function Text({ type, ]) - if (selectable && isIOS) { - return ( - <UITextView {...textProps}> - {isIOS && emoji - ? renderChildrenWithEmoji(children, textProps) - : children} - </UITextView> - ) - } - return ( - <RNText {...textProps}> - {isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children} - </RNText> + <UITextView {...textProps}> + {renderChildrenWithEmoji(children, textProps, emoji ?? false)} + </UITextView> ) } diff --git a/src/view/icons/Logomark.tsx b/src/view/icons/Logomark.tsx index 5715a1a40..b777992d4 100644 --- a/src/view/icons/Logomark.tsx +++ b/src/view/icons/Logomark.tsx @@ -1,4 +1,3 @@ -import React from 'react' import Svg, {Path, PathProps, SvgProps} from 'react-native-svg' import {usePalette} from '#/lib/hooks/usePalette' diff --git a/src/view/icons/Logotype.tsx b/src/view/icons/Logotype.tsx index d6c35f6d9..8be4980e6 100644 --- a/src/view/icons/Logotype.tsx +++ b/src/view/icons/Logotype.tsx @@ -1,4 +1,3 @@ -import React from 'react' import Svg, {Path, PathProps, SvgProps} from 'react-native-svg' import {usePalette} from '#/lib/hooks/usePalette' diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 237449383..cadfb4890 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -141,7 +141,7 @@ function HomeScreenReady({ useFocusEffect( useNonReactiveCallback(() => { if (selectedFeed) { - logEvent('home:feedDisplayed:sampled', { + logEvent('home:feedDisplayed', { index: selectedIndex, feedType: selectedFeed.split('|')[0], feedUrl: selectedFeed, @@ -163,12 +163,9 @@ function HomeScreenReady({ ) const onPageSelecting = React.useCallback( - ( - index: number, - reason: LogEvents['home:feedDisplayed:sampled']['reason'], - ) => { + (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { const feed = allFeeds[index] - logEvent('home:feedDisplayed:sampled', { + logEvent('home:feedDisplayed', { index, feedType: feed.split('|')[0], feedUrl: feed, diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index b79da6d54..f654f2bd9 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -2,9 +2,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists' import {Button} from '#/view/com/util/forms/Button' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> export function ListsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ListsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#curatelist', @@ -46,7 +58,7 @@ export function ListsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( <Layout.Screen testID="listsScreen"> @@ -87,6 +99,12 @@ export function ListsScreen({}: Props) { </View> </SimpleViewHeader> <MyLists filter="curate" style={s.flexGrow1} /> + <VerifyEmailDialog + reasonText={_( + msg`Before creating a list, you must first verify your email.`, + )} + control={control} + /> </Layout.Screen> ) } diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index b147ba502..c623c5376 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -2,9 +2,11 @@ import React from 'react' import {View} from 'react-native' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists' import {Button} from '#/view/com/util/forms/Button' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> export function ModerationModlistsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ModerationModlistsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#modlist', @@ -46,7 +58,7 @@ export function ModerationModlistsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( <Layout.Screen testID="moderationModlistsScreen"> @@ -83,6 +95,12 @@ export function ModerationModlistsScreen({}: Props) { </View> </SimpleViewHeader> <MyLists filter="mod" style={s.flexGrow1} /> + <VerifyEmailDialog + reasonText={_( + msg`Before creating a list, you must first verify your email.`, + )} + control={control} + /> </Layout.Screen> ) } diff --git a/src/view/screens/Storybook/Admonitions.tsx b/src/view/screens/Storybook/Admonitions.tsx index ca97ebb23..988342f17 100644 --- a/src/view/screens/Storybook/Admonitions.tsx +++ b/src/view/screens/Storybook/Admonitions.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a} from '#/alf' diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx index 5dd8a89fc..6c79e2c9e 100644 --- a/src/view/screens/Storybook/Breakpoints.tsx +++ b/src/view/screens/Storybook/Breakpoints.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useBreakpoints, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx index 9de126d6b..97a588a5a 100644 --- a/src/view/screens/Storybook/Icons.tsx +++ b/src/view/screens/Storybook/Icons.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx index 465ce0d6f..37e316401 100644 --- a/src/view/screens/Storybook/Links.tsx +++ b/src/view/screens/Storybook/Links.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx index 3e5c74d86..28689b727 100644 --- a/src/view/screens/Storybook/Menus.tsx +++ b/src/view/screens/Storybook/Menus.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx index 42000aa81..268ce5935 100644 --- a/src/view/screens/Storybook/Palette.tsx +++ b/src/view/screens/Storybook/Palette.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Settings.tsx b/src/view/screens/Storybook/Settings.tsx index 6bc293c73..fe47b2c74 100644 --- a/src/view/screens/Storybook/Settings.tsx +++ b/src/view/screens/Storybook/Settings.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import * as Toast from '#/view/com/util/Toast' diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx index f92112395..e9c23f03e 100644 --- a/src/view/screens/Storybook/Shadows.tsx +++ b/src/view/screens/Storybook/Shadows.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx index 9b97e92ad..94c62d2f9 100644 --- a/src/view/screens/Storybook/Spacing.tsx +++ b/src/view/screens/Storybook/Spacing.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx index 5b6763370..673425b47 100644 --- a/src/view/screens/Storybook/Theming.tsx +++ b/src/view/screens/Storybook/Theming.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx index 03f86fd46..9286d4b3d 100644 --- a/src/view/screens/Storybook/Typography.tsx +++ b/src/view/screens/Storybook/Typography.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {atoms as a} from '#/alf' diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 4c357acc4..21ab9ec21 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react' +import {useEffect} from 'react' import {Animated, Easing, StyleSheet, View} from 'react-native' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 257506dd0..3dc2b076c 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -122,9 +122,8 @@ let DrawerProfileCard = ({ DrawerProfileCard = React.memo(DrawerProfileCard) export {DrawerProfileCard} -let DrawerContent = ({}: {}): React.ReactNode => { +let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { const t = useTheme() - const {_} = useLingui() const insets = useSafeAreaInsets() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() @@ -137,7 +136,6 @@ let DrawerContent = ({}: {}): React.ReactNode => { isAtMessages, } = useNavigationTabState() const {hasSession, currentAccount} = useSession() - const kawaii = useKawaiiMode() // events // = @@ -277,34 +275,7 @@ let DrawerContent = ({}: {}): React.ReactNode => { <View style={[a.px_xl]}> <Divider style={[a.mb_xl, a.mt_sm]} /> - - <View style={[a.flex_col, a.gap_md, a.flex_wrap]}> - <InlineLinkText - style={[a.text_md]} - label={_(msg`Terms of Service`)} - to="https://bsky.social/about/support/tos"> - <Trans>Terms of Service</Trans> - </InlineLinkText> - <InlineLinkText - style={[a.text_md]} - to="https://bsky.social/about/support/privacy-policy" - label={_(msg`Privacy Policy`)}> - <Trans>Privacy Policy</Trans> - </InlineLinkText> - {kawaii && ( - <Text style={t.atoms.text_contrast_medium}> - <Trans> - Logo by{' '} - <InlineLinkText - style={[a.text_md]} - to="/profile/sawaratsuki.bsky.social" - label="@sawaratsuki.bsky.social"> - @sawaratsuki.bsky.social - </InlineLinkText> - </Trans> - </Text> - )} - </View> + <ExtraLinks /> </View> </ScrollView> @@ -633,3 +604,39 @@ function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) { </Button> ) } + +function ExtraLinks() { + const {_} = useLingui() + const t = useTheme() + const kawaii = useKawaiiMode() + + return ( + <View style={[a.flex_col, a.gap_md, a.flex_wrap]}> + <InlineLinkText + style={[a.text_md]} + label={_(msg`Terms of Service`)} + to="https://bsky.social/about/support/tos"> + <Trans>Terms of Service</Trans> + </InlineLinkText> + <InlineLinkText + style={[a.text_md]} + to="https://bsky.social/about/support/privacy-policy" + label={_(msg`Privacy Policy`)}> + <Trans>Privacy Policy</Trans> + </InlineLinkText> + {kawaii && ( + <Text style={t.atoms.text_contrast_medium}> + <Trans> + Logo by{' '} + <InlineLinkText + style={[a.text_md]} + to="/profile/sawaratsuki.bsky.social" + label="@sawaratsuki.bsky.social"> + @sawaratsuki.bsky.social + </InlineLinkText> + </Trans> + </Text> + )} + </View> + ) +} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 855ba21b2..1d1023c2b 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -134,7 +134,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { footerMinimalShellTransform, ]} onLayout={e => { - footerHeight.value = e.nativeEvent.layout.height + footerHeight.set(e.nativeEvent.layout.height) }}> {hasSession ? ( <> diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 9b34159d7..81855c97d 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -177,7 +177,7 @@ export function BottomBarWeb() { alignItems: 'center', justifyContent: 'space-between', paddingTop: 14, - paddingBottom: 2, + paddingBottom: 14, paddingLeft: 14, paddingRight: 6, gap: 8, diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index bb6b8cadd..383d8f953 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 5f75c220c..4f413211f 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 6dc4f95a5..1ab045d75 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -1,7 +1,6 @@ import React from 'react' import {BackHandler, StyleSheet, useWindowDimensions, View} from 'react-native' import {Drawer} from 'react-native-drawer-layout' -import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import * as NavigationBar from 'expo-navigation-bar' import {StatusBar} from 'expo-status-bar' @@ -95,7 +94,7 @@ function ShellInner() { return ( <> - <Animated.View style={[a.h_full]}> + <View style={[a.h_full]}> <ErrorBoundary style={{paddingTop: insets.top, paddingBottom: insets.bottom}}> <Drawer @@ -105,6 +104,7 @@ function ShellInner() { onOpen={onOpenDrawer} onClose={onCloseDrawer} swipeEdgeWidth={winDim.width / 2} + drawerType={isIOS ? 'slide' : 'front'} swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled} overlayStyle={{ backgroundColor: select(t.name, { @@ -118,7 +118,7 @@ function ShellInner() { <TabsNavigator /> </Drawer> </ErrorBoundary> - </Animated.View> + </View> <Composer winHeight={winDim.height} /> <ModalsContainer /> <MutedWordsDialog /> @@ -132,8 +132,8 @@ function ShellInner() { export const Shell: React.FC = function ShellImpl() { const {fullyExpandedCount} = useDialogStateControlContext() - const pal = usePalette('default') const theme = useTheme() + const pal = usePalette('default') useIntentHandler() React.useEffect(() => { |