diff options
Diffstat (limited to 'src/view/com')
91 files changed, 2241 insertions, 1540 deletions
diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx index 94381e644..c17e41b37 100644 --- a/src/view/com/composer/Autocomplete.tsx +++ b/src/view/com/composer/Autocomplete.tsx @@ -5,9 +5,9 @@ import { StyleSheet, useWindowDimensions, } from 'react-native' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' interface AutocompleteItem { handle: string diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 1144b5e48..6431a11aa 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, + NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, + TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, @@ -16,8 +18,9 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -// import {useAnalytics} from '@segment/analytics-react-native' TODO -import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' +import {useAnalytics} from 'lib/analytics' +import _isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {TextLink} from '../util/Link' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {s, colors, gradients} from '../../lib/styles' -import { - detectLinkables, - extractEntities, - cleanError, -} from '../../../lib/strings' -import {getLinkMeta} from '../../../lib/link-meta' -import {downloadAndResize} from '../../../lib/images' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {ComposerOpts} from 'state/models/shell-ui' +import {s, colors, gradients} from 'lib/styles' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {downloadAndResize} from 'lib/images' import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {SelectedPhoto} from './SelectedPhoto' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const MAX_TEXT_LENGTH = 256 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} +interface Selection { + start: number + end: number +} + export const ComposePost = observer(function ComposePost({ replyTo, imagesOpen, @@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({ onPost?: ComposerOpts['onPost'] onClose: () => void }) { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const textInput = useRef<TextInputRef>(null) + const textInputSelection = useRef<Selection>({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({ const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( undefined, ) - const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([]) + const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>( + new Set(), + ) const [isSelectingPhotos, setIsSelectingPhotos] = useState( imagesOpen || false, ) @@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({ if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { downloadAndResize({ uri: extLink.meta.image, - width: 250, - height: 250, + width: 2000, + height: 2000, mode: 'contain', - maxSize: 100000, + maxSize: 1000000, timeout: 15e3, }) .catch(() => undefined) @@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({ textInput.current?.focus() } const onPressSelectPhotos = () => { + track('ComposePost:SelectPhotos') if (isSelectingPhotos) { setIsSelectingPhotos(false) } else if (selectedPhotos.length < 4) { @@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({ } } const onSelectPhotos = (photos: string[]) => { + track('ComposePost:SelectPhotos:Done') setSelectedPhotos(photos) if (photos.length >= 4) { setIsSelectingPhotos(false) } } + const onPressAddLinkCard = (uri: string) => { + setExtLink({uri, isLoading: true}) + } const onChangeText = (newText: string) => { setText(newText) - const prefix = extractTextAutocompletePrefix(newText) - if (typeof prefix === 'string') { + const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) + if (prefix) { autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix) + autocompleteView.setPrefix(prefix.value) } else { autocompleteView.setActive(false) } - if (!extLink && /\s$/.test(newText)) { - const ents = extractEntities(newText) - const entLink = ents - ?.filter( - ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value), - ) - .pop() // use last - if (entLink) { - setExtLink({ - uri: entLink.value, - isLoading: true, - }) - setAttemptedExtLinks([...attemptedExtLinks, entLink.value]) + if (!extLink) { + const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!_isEqual(set, suggestedExtLinks)) { + setSuggestedExtLinks(set) } } } @@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({ onSelectPhotos([...selectedPhotos, finalImgPath]) } } + const onSelectionChange = ( + evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>, + ) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + } + const onSelectAutocompleteItem = (item: string) => { + setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) + autocompleteView.setActive(false) + } const onPressCancel = () => hackfixOnClose() const onPressPublish = async () => { if (isProcessing) { @@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({ autocompleteView.knownHandles, setProcessingState, ) - // TODO - // track('Create Post', { - // imageCount: selectedPhotos.length, - // }) + track('Create Post', { + imageCount: selectedPhotos.length, + }) } catch (e: any) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) setError(cleanError(e.message)) setIsProcessing(false) return @@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({ hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) } - const onSelectAutocompleteItem = (item: string) => { - setText(replaceTextAutocompletePrefix(text, item)) - autocompleteView.setActive(false) - } const canPost = text.length <= MAX_TEXT_LENGTH @@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({ innerRef={textInput} onChangeText={(str: string) => onChangeText(str)} onPaste={onPaste} + onSelectionChange={onSelectionChange} placeholder={selectTextInputPlaceholder} style={[ pal.text, @@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && selectedPhotos.length < 4 && ( + {isSelectingPhotos && selectedPhotos.length < 4 ? ( <PhotoCarouselPicker selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> - )} + ) : !extLink && + selectedPhotos.length === 0 && + suggestedExtLinks.size > 0 ? ( + <View style={s.mb5}> + {Array.from(suggestedExtLinks).map(url => ( + <TouchableOpacity + key={`suggested-${url}`} + style={[pal.borderDark, styles.addExtLinkBtn]} + onPress={() => onPressAddLinkCard(url)}> + <Text> + Add link card: <Text style={pal.link}>{url}</Text> + </Text> + </TouchableOpacity> + ))} + </View> + ) : null} <View style={[pal.border, styles.bottomBar]}> <TouchableOpacity testID="composerSelectPhotosButton" @@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({ ) }) -const atPrefixRegex = /@([a-z0-9.]*)$/i -function extractTextAutocompletePrefix(text: string) { - const match = atPrefixRegex.exec(text) - if (match) { - return match[1] - } - return undefined -} -function replaceTextAutocompletePrefix(text: string, item: string) { - return text.replace(atPrefixRegex, `@${item} `) -} - const styles = StyleSheet.create({ outer: { flexDirection: 'column', @@ -532,6 +552,13 @@ const styles = StyleSheet.create({ paddingLeft: 13, paddingRight: 8, }, + addExtLinkBtn: { + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 4, + }, bottomBar: { flexDirection: 'row', paddingVertical: 10, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index ed4bfb5ed..23dcaffd5 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -7,12 +7,11 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {BlurView} from '../util/BlurView' -import LinearGradient from 'react-native-linear-gradient' import {AutoSizedImage} from '../util/images/AutoSizedImage' import {Text} from '../util/text/Text' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' -import {ExternalEmbedDraft} from '../../../state/lib/api' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {ExternalEmbedDraft} from 'lib/api/index' export const ExternalEmbed = ({ link, @@ -30,31 +29,12 @@ export const ExternalEmbed = ({ <View style={[styles.outer, pal.view, pal.border]}> {link.isLoading ? ( <View - style={[ - styles.image, - styles.imageFallback, - {backgroundColor: pal.colors.backgroundLight}, - ]}> + style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}> <ActivityIndicator size="large" style={styles.spinner} /> </View> ) : link.localThumb ? ( - <AutoSizedImage - uri={link.localThumb.path} - containerStyle={styles.image} - /> - ) : ( - <LinearGradient - colors={[gradients.blueDark.start, gradients.blueDark.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.image, styles.imageFallback]} - /> - )} - <TouchableWithoutFeedback onPress={onRemove}> - <BlurView style={styles.removeBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> - </BlurView> - </TouchableWithoutFeedback> + <AutoSizedImage uri={link.localThumb.path} style={styles.image} /> + ) : undefined} <View style={styles.inner}> {!!link.meta?.title && ( <Text type="sm-bold" numberOfLines={2} style={[pal.text]}> @@ -81,6 +61,11 @@ export const ExternalEmbed = ({ </Text> )} </View> + <TouchableWithoutFeedback onPress={onRemove}> + <BlurView style={styles.removeBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> + </BlurView> + </TouchableWithoutFeedback> </View> ) } @@ -98,10 +83,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 6, borderTopRightRadius: 6, width: '100%', - height: 200, - }, - imageFallback: { - height: 160, + maxHeight: 200, }, removeBtn: { position: 'absolute', diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index d0c023e25..46a0cec62 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -2,7 +2,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' export function ComposePrompt({ text = "What's up?", diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx index dd508fe1f..6aeda33cd 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/SelectedPhoto.tsx @@ -1,7 +1,8 @@ import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors} from '../../lib/styles' +import Image from 'view/com/util/images/Image' +import {colors} from 'lib/styles' export const SelectedPhoto = ({ selectedPhotos, diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index d4093064b..cd7cb2c4e 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text' import ProgressCircle from 'react-native-progress/Circle' // @ts-ignore no type definition -prf import ProgressPie from 'react-native-progress/Pie' -import {s, colors} from '../../../lib/styles' +import {s, colors} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/char-progress/CharProgress.web.tsx b/src/view/com/composer/char-progress/CharProgress.web.tsx index dfb2fad58..d32d7a72c 100644 --- a/src/view/com/composer/char-progress/CharProgress.web.tsx +++ b/src/view/com/composer/char-progress/CharProgress.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Text} from '../../util/text/Text' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx index 7a5c9f65d..406f8b04c 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics' import { openPicker, openCamera, @@ -12,18 +13,26 @@ import { import { UserLocalPhotosModel, PhotoIdentifier, -} from '../../../../state/models/user-local-photos' -import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' -import {usePalette} from '../../../lib/hooks/usePalette' -import {useStores, RootStoreModel} from '../../../../state' +} from 'state/models/user-local-photos' +import { + compressIfNeeded, + moveToPremanantPath, + scaleDownDimensions, +} from 'lib/images' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores, RootStoreModel} from 'state/index' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' -const MAX_WIDTH = 1000 -const MAX_HEIGHT = 1000 -const MAX_SIZE = 300000 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const MAX_SIZE = 1000000 const IMAGE_PARAMS = { - width: 1000, - height: 1000, + width: 2000, + height: 2000, freeStyleCropEnabled: true, } @@ -46,8 +55,10 @@ export async function cropPhoto( width, height, }) + const img = await compressIfNeeded(cropperRes, MAX_SIZE) - return img.path + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath } export const PhotoCarouselPicker = ({ @@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({ selectedPhotos: string[] onSelectPhotos: (v: string[]) => void }) => { + const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const [localPhotos, setLocalPhotos] = React.useState< - UserLocalPhotosModel | undefined - >(undefined) + const [isSetup, setIsSetup] = React.useState<boolean>(false) + + const localPhotos = React.useMemo<UserLocalPhotosModel>( + () => new UserLocalPhotosModel(store), + [store], + ) - // initial setup React.useEffect(() => { - const photos = new UserLocalPhotosModel(store) - photos.setup().then(() => { - if (photos.photos) { - setLocalPhotos(photos) - } + // initial setup + localPhotos.setup().then(() => { + setIsSetup(true) }) - }, [store]) + }, [localPhotos]) const handleOpenCamera = useCallback(async () => { try { + if (!(await requestCameraAccessIfNeeded())) { + return + } const cameraRes = await openCamera(store, { mediaType: 'photo', ...IMAGE_PARAMS, @@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({ const handleSelectPhoto = useCallback( async (item: PhotoIdentifier) => { + track('PhotoCarouselPicker:PhotoSelected') try { const imgPath = await cropPhoto( store, @@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({ store.log.warn('Error selecting photo', err) } }, - [store, selectedPhotos, onSelectPhotos], + [track, store, onSelectPhotos, selectedPhotos], ) - const handleOpenGallery = useCallback(() => { - openPicker(store, { + const handleOpenGallery = useCallback(async () => { + track('PhotoCarouselPicker:GalleryOpened') + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { multiple: true, maxFiles: 4 - selectedPhotos.length, mediaType: 'photo', - }).then(async items => { - const result = [] - - for (const image of items) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions( - {width: image.width, height: image.height}, - {width: MAX_WIDTH, height: MAX_HEIGHT}, - ) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path: image.path, - freeStyleCropEnabled: true, - width, - height, - }) - const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) - result.push(finalImg.path) - } - onSelectPhotos([...selectedPhotos, ...result]) }) - }, [store, selectedPhotos, onSelectPhotos]) + const result = [] + + for (const image of items) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions( + {width: image.width, height: image.height}, + {width: MAX_WIDTH, height: MAX_HEIGHT}, + ) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path: image.path, + ...IMAGE_PARAMS, + width, + height, + }) + const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) + const permanentPath = await moveToPremanantPath(finalImg.path) + result.push(permanentPath) + } + onSelectPhotos([...selectedPhotos, ...result]) + }, [track, store, selectedPhotos, onSelectPhotos]) return ( <ScrollView @@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({ size={24} /> </TouchableOpacity> - {localPhotos != null && + {isSetup && localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( <TouchableOpacity testID="openSelectPhotoButton" diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx index bb2800026..607f8e724 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx @@ -9,9 +9,9 @@ import { openCamera, openCropper, } from '../../util/images/image-crop-picker/ImageCropPicker' -import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' -import {usePalette} from '../../../lib/hooks/usePalette' -import {useStores, RootStoreModel} from '../../../../state' +import {compressIfNeeded, scaleDownDimensions} from 'lib/images' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores, RootStoreModel} from 'state/index' const MAX_WIDTH = 1000 const MAX_HEIGHT = 1000 diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 3c5dacf80..be6150e11 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,10 +1,15 @@ import React from 'react' -import {StyleProp, TextStyle} from 'react-native' +import { + NativeSyntheticEvent, + StyleProp, + TextInputSelectionChangeEventData, + TextStyle, +} from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' -import {usePalette} from '../../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' export type TextInputRef = PasteInputRef @@ -14,6 +19,9 @@ interface TextInputProps { placeholder: string style: StyleProp<TextStyle> onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -23,6 +31,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, onPaste, children, }: React.PropsWithChildren<TextInputProps>) { @@ -44,6 +53,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} onPaste={onPasteInner} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 395f8e5a2..2b610850c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,14 @@ import React from 'react' import { + NativeSyntheticEvent, StyleProp, StyleSheet, TextInput as RNTextInput, + TextInputSelectionChangeEventData, TextStyle, } from 'react-native' -import {usePalette} from '../../../lib/hooks/usePalette' -import {addStyle} from '../../../lib/addStyle' +import {usePalette} from 'lib/hooks/usePalette' +import {addStyle} from 'lib/styles' export type TextInputRef = RNTextInput @@ -16,6 +18,9 @@ interface TextInputProps { placeholder: string style: StyleProp<TextStyle> onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -25,6 +30,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, children, }: React.PropsWithChildren<TextInputProps>) { const pal = usePalette('default') @@ -36,6 +42,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} style={style}> diff --git a/src/view/com/discover/LiteSuggestedFollows.tsx b/src/view/com/discover/LiteSuggestedFollows.tsx index ce01af7c7..5314e691c 100644 --- a/src/view/com/discover/LiteSuggestedFollows.tsx +++ b/src/view/com/discover/LiteSuggestedFollows.tsx @@ -8,19 +8,18 @@ import { import LinearGradient from 'react-native-linear-gradient' import {observer} from 'mobx-react-lite' import _omit from 'lodash.omit' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const LiteSuggestedFollows = observer(() => { const store = useStores() diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 9ed893148..1e40956ce 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,51 +1,28 @@ -import React, {useEffect, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' -import _omit from 'lodash.omit' +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' +import {observer} from 'mobx-react-lite' import {ErrorScreen} from '../util/error/ErrorScreen' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const SuggestedFollows = observer( - ({ - onNoSuggestions, - asLinks, - }: { - onNoSuggestions?: () => void - asLinks?: boolean - }) => { + ({onNoSuggestions}: {onNoSuggestions?: () => void}) => { const pal = usePalette('default') const store = useStores() - const [follows, setFollows] = useState<Record<string, string>>({}) - // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment const view = React.useMemo<SuggestedActorsViewModel>( () => new SuggestedActorsViewModel(store), [store], ) - useEffect(() => { + React.useEffect(() => { view .loadMore() .catch((err: any) => @@ -53,7 +30,7 @@ export const SuggestedFollows = observer( ) }, [view, store.log]) - useEffect(() => { + React.useEffect(() => { if (!view.isLoading && !view.hasError && !view.hasContent) { onNoSuggestions?.() } @@ -74,46 +51,16 @@ export const SuggestedFollows = observer( ) } - const onPressFollow = async (item: SuggestedActor) => { - try { - const res = await apilib.follow(store, item.did, item.declaration.cid) - setFollows({[item.did]: res.uri, ...follows}) - } catch (e: any) { - store.log.error('Failed fo create follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const onPressUnfollow = async (item: SuggestedActor) => { - try { - await apilib.unfollow(store, follows[item.did]) - setFollows(_omit(follows, [item.did])) - } catch (e: any) { - store.log.error('Failed fo delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const renderItem = ({item}: {item: SuggestedActor}) => { - if (asLinks) { - return ( - <Link - href={`/profile/${item.handle}`} - title={item.displayName || item.handle}> - <User - item={item} - follow={follows[item.did]} - onPressFollow={onPressFollow} - onPressUnfollow={onPressUnfollow} - /> - </Link> - ) - } return ( - <User - item={item} - follow={follows[item.did]} - onPressFollow={onPressFollow} - onPressUnfollow={onPressUnfollow} + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + description={item.description} /> ) } @@ -146,7 +93,6 @@ export const SuggestedFollows = observer( </View> )} contentContainerStyle={s.contentContainer} - style={s.flex1} /> </View> )} @@ -155,128 +101,16 @@ export const SuggestedFollows = observer( }, ) -const User = ({ - item, - follow, - onPressFollow, - onPressUnfollow, -}: { - item: SuggestedActor - follow: string | undefined - onPressFollow: (item: SuggestedActor) => void - onPressUnfollow: (item: SuggestedActor) => void -}) => { - const pal = usePalette('default') - return ( - <View style={[styles.actor, pal.view, pal.border]}> - <View style={styles.actorMeta}> - <View style={styles.actorAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.actorContent}> - <Text type="title-sm" style={pal.text} numberOfLines={1}> - {item.displayName || item.handle} - </Text> - <Text style={pal.textLight} numberOfLines={1}> - @{item.handle} - </Text> - </View> - <View style={styles.actorBtn}> - {follow ? ( - <TouchableOpacity onPress={() => onPressUnfollow(item)}> - <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> - <Text type="button" style={pal.text}> - Unfollow - </Text> - </View> - </TouchableOpacity> - ) : ( - <TouchableOpacity onPress={() => onPressFollow(item)}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn, styles.gradientBtn]}> - <FontAwesomeIcon - icon="plus" - style={[s.white as FontAwesomeIconStyle, s.mr5]} - size={15} - /> - <Text style={[s.white, s.fw600, s.f15]}>Follow</Text> - </LinearGradient> - </TouchableOpacity> - )} - </View> - </View> - {item.description ? ( - <View style={styles.actorDetails}> - <Text style={pal.text} numberOfLines={4}> - {item.description} - </Text> - </View> - ) : undefined} - </View> - ) -} - const styles = StyleSheet.create({ container: { - flex: 1, + height: '100%', }, suggestionsContainer: { - flex: 1, + height: '100%', }, footer: { height: 200, paddingTop: 20, }, - - actor: { - borderTopWidth: 1, - }, - actorMeta: { - flexDirection: 'row', - }, - actorAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - actorContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - }, - actorBtn: { - paddingRight: 10, - paddingTop: 10, - }, - actorDetails: { - paddingLeft: 60, - paddingRight: 10, - paddingBottom: 10, - }, - - gradientBtn: { - paddingHorizontal: 24, - paddingVertical: 6, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, }) diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx new file mode 100644 index 000000000..86a6bd394 --- /dev/null +++ b/src/view/com/discover/SuggestedPosts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedPostsView} from 'state/models/suggested-posts-view' +import {s} from 'lib/styles' +import {FeedItem as Post} from '../posts/FeedItem' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export const SuggestedPosts = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedPostsView = React.useMemo<SuggestedPostsView>( + () => new SuggestedPostsView(store), + [store], + ) + + React.useEffect(() => { + if (!suggestedPostsView.hasLoaded) { + suggestedPostsView.setup() + } + }, [store, suggestedPostsView]) + + return ( + <> + {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( + <Text type="title" style={[styles.heading, pal.text]}> + Recently, on Bluesky... + </Text> + )} + {suggestedPostsView.hasContent && ( + <> + <View style={[pal.border, styles.bottomBorder]}> + {suggestedPostsView.posts.map(item => ( + <Post item={item} key={item._reactKey} /> + ))} + </View> + </> + )} + {suggestedPostsView.isLoading && ( + <View style={s.mt10}> + <ActivityIndicator /> + </View> + )} + </> + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 12, + paddingVertical: 10, + }, +}) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx new file mode 100644 index 000000000..17c10ca7e --- /dev/null +++ b/src/view/com/discover/WhoToFollow.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {Text} from '../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' + +export const WhoToFollow = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>( + () => new SuggestedActorsViewModel(store, {pageSize: 5}), + [store], + ) + + React.useEffect(() => { + suggestedActorsView.loadMore(true) + }, [store, suggestedActorsView]) + + const onPressLoadMoreSuggestedActors = () => { + suggestedActorsView.loadMore() + } + return ( + <> + {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( + <Text type="title" style={[styles.heading, pal.text]}> + Who to follow + </Text> + )} + {suggestedActorsView.hasContent && ( + <> + <View style={[pal.border, styles.bottomBorder]}> + {suggestedActorsView.suggestions.map(item => ( + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + description={item.description} + /> + ))} + </View> + {!suggestedActorsView.isLoading && suggestedActorsView.hasMore && ( + <TouchableOpacity + onPress={onPressLoadMoreSuggestedActors} + style={styles.loadMore}> + <Text type="lg" style={pal.link}> + Show more + </Text> + </TouchableOpacity> + )} + </> + )} + {suggestedActorsView.isLoading && ( + <View style={s.mt10}> + <ActivityIndicator /> + </View> + )} + </> + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index fdaafe737..83259330f 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -86,12 +86,18 @@ function ImageViewing({ [toggleBarsVisible], ) + const onLayout = useCallback(() => { + if (imageIndex) { + imageList.current?.scrollToIndex({index: imageIndex, animated: false}) + } + }, [imageList, imageIndex]) + if (!visible) { return null } return ( - <View style={styles.screen}> + <View style={styles.screen} onLayout={onLayout}> <Modal /> <View style={[styles.container, {opacity, backgroundColor}]}> <Animated.View style={[styles.header, {transform: headerTransform}]}> @@ -108,12 +114,8 @@ function ImageViewing({ data={images} horizontal pagingEnabled - windowSize={2} - initialNumToRender={1} - maxToRenderPerBatch={1} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - initialScrollIndex={imageIndex} getItem={(_, index) => images[index]} getItemCount={() => images.length} getItemLayout={(_, index) => ({ diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index df5839783..894c6b118 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -2,9 +2,9 @@ import React from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import ImageView from './ImageViewing' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {saveImageModal} from '../../../lib/images' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {saveImageModal} from 'lib/images' import {ImageSource} from './ImageViewing/@types' export const Lightbox = observer(function Lightbox() { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 4062ddef7..dfe4f5603 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -8,9 +8,9 @@ import { } from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {colors} from 'lib/styles' interface Img { uri: string diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index dbb3ab52f..6dc93f5e3 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -15,24 +15,22 @@ import { } from '@fortawesome/react-native-fontawesome' import {ComAtprotoAccountCreate} from '@atproto/api' import * as EmailValidator from 'email-validator' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Picker} from '../util/Picker' import {TextLink} from '../util/Link' import {Text} from '../util/text/Text' -import {s, colors} from '../../lib/styles' -import { - makeValidHandle, - createFullHandle, - toNiceDomain, -} from '../../../lib/strings' -import {useStores, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {makeValidHandle, createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) @@ -50,6 +48,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const [is13, setIs13] = useState<boolean>(false) useEffect(() => { + screen('CreateAccount') + }, [screen]) + + useEffect(() => { let aborted = false setError('') setServiceDescription(undefined) @@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { password, inviteCode, }) - // track('Create Account') TODO + track('Create Account') } catch (e: any) { let errMsg = e.toString() if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { @@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { } store.log.error('Failed to create account', e) setIsProcessing(false) - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx index ec53549b1..7601ea31f 100644 --- a/src/view/com/login/Logo.tsx +++ b/src/view/com/login/Logo.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {s, gradients} from '../../lib/styles' +import {s, gradients} from 'lib/styles' import {Text} from '../util/text/Text' export const LogoTextHero = () => { diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 8a260a9ba..eed173119 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -13,19 +13,21 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import AtpAgent from '@atproto/api' +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {s, colors} from '../../lib/styles' -import {createFullHandle, toNiceDomain} from '../../../lib/strings' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {AccountData} from '../../../state/models/session' -import {isNetworkError} from '../../../lib/errors' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {AccountData} from 'state/models/session' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' enum Forms { Login, @@ -38,6 +40,7 @@ enum Forms { export const Signin = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() const [error, setError] = useState<string>('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) @@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { }, [store.session, store.log, serviceUrl, retryDescribeTrigger]) const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } return ( <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}> @@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { setError={setError} setServiceUrl={setServiceUrl} onPressBack={onPressBack} - onPressForgotPassword={gotoForm(Forms.ForgotPassword)} + onPressForgotPassword={onPressForgotPassword} onPressRetryConnect={onPressRetryConnect} /> ) : undefined} @@ -153,15 +160,19 @@ const ChooseAccountForm = ({ onSelectAccount: (account?: AccountData) => void onPressBack: () => void }) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = React.useState(false) + // React.useEffect(() => { + screen('Choose Account') + // }, [screen]) + const onTryAccount = async (account: AccountData) => { if (account.accessJwt && account.refreshJwt) { setIsProcessing(true) if (await store.session.resumeSession(account)) { - // track('Sign In', {resumedSession: true}) TODO + track('Sign In', {resumedSession: true}) setIsProcessing(false) return } @@ -261,15 +272,16 @@ const LoginForm = ({ onPressBack: () => void onPressForgotPassword: () => void }) => { - // const {track} = useAnalytics() TODO + const {track} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [handle, setHandle] = useState<string>(initialHandle) + const [identifier, setIdentifier] = useState<string>(initialHandle) const [password, setPassword] = useState<string>('') const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) Keyboard.dismiss() + track('Signin:PressedSelectService') } const onPressNext = async () => { @@ -278,20 +290,21 @@ const LoginForm = ({ try { // try to guess the handle if the user just gave their own username - let fullHandle = handle + let fullIdent = identifier if ( + !identifier.includes('@') && // not an email serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { let matched = false for (const domain of serviceDescription.availableUserDomains) { - if (fullHandle.endsWith(domain)) { + if (fullIdent.endsWith(domain)) { matched = true } } if (!matched) { - fullHandle = createFullHandle( - handle, + fullIdent = createFullHandle( + identifier, serviceDescription.availableUserDomains[0], ) } @@ -299,10 +312,10 @@ const LoginForm = ({ await store.session.login({ service: serviceUrl, - handle: fullHandle, + identifier: fullIdent, password, }) - // track('Sign In', {resumedSession: false}) TODO + track('Sign In', {resumedSession: false}) } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to login', e) @@ -314,12 +327,12 @@ const LoginForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } - const isReady = !!serviceDescription && !!handle && !!password + const isReady = !!serviceDescription && !!identifier && !!password return ( <View testID="loginForm"> <LogoTextHero /> @@ -361,13 +374,13 @@ const LoginForm = ({ <TextInput testID="loginUsernameInput" style={[pal.text, styles.textInput]} - placeholder="Username" + placeholder="Username or email address" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoFocus autoCorrect={false} - value={handle} - onChangeText={str => setHandle((str || '').toLowerCase())} + value={identifier} + onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} /> </View> @@ -464,6 +477,11 @@ const ForgotPasswordForm = ({ const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState<boolean>(false) const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:ForgotPassword') + // }, [screen]) const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) @@ -478,8 +496,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.requestPasswordReset({email}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -490,7 +508,7 @@ const ForgotPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -604,6 +622,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:SetNewPasswordForm') + // }, [screen]) + const [isProcessing, setIsProcessing] = useState<boolean>(false) const [resetCode, setResetCode] = useState<string>('') const [password, setPassword] = useState<string>('') @@ -613,8 +637,11 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.resetPassword({token: resetCode, password}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.resetPassword({ + token: resetCode, + password, + }) onPasswordSet() } catch (e: any) { const errMsg = e.toString() @@ -625,7 +652,7 @@ const SetNewPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -726,6 +753,12 @@ const SetNewPasswordForm = ({ } const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:PasswordUpdatedForm') + // }, [screen]) + const pal = usePalette('default') return ( <> diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 3e2ad6eea..60c104f99 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -7,9 +7,10 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' export const snapPoints = ['50%'] @@ -33,7 +34,7 @@ export function Component({ store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx new file mode 100644 index 000000000..de29e728d --- /dev/null +++ b/src/view/com/modals/DeleteAccount.tsx @@ -0,0 +1,210 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {BottomSheetTextInput} from '@gorhom/bottom-sheet' +import LinearGradient from 'react-native-linear-gradient' +import * as Toast from '../util/Toast' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['60%'] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) + const [confirmCode, setConfirmCode] = React.useState<string>('') + const [password, setPassword] = React.useState<string>('') + const [isProcessing, setIsProcessing] = React.useState<boolean>(false) + const [error, setError] = React.useState<string>('') + const onPressSendEmail = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.requestDelete() + setIsEmailSent(true) + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onPressConfirmDelete = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.delete({ + did: store.me.did, + password, + token: confirmCode, + }) + Toast.show('Your account has been deleted') + store.nav.tab.fixedTabReset() + store.session.clear() + store.shell.closeModal() + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onCancel = () => { + store.shell.closeModal() + } + return ( + <View + style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}> + <View style={[styles.innerContainer, pal.view]}> + <Text type="title-xl" style={[styles.title, pal.text]}> + Delete account + </Text> + {!isEmailSent ? ( + <> + <Text type="lg" style={[styles.description, pal.text]}> + For security reasons, we'll need to send a confirmation code to + your email. + </Text> + {error ? ( + <View style={s.mt10}> + <ErrorMessage message={error} /> + </View> + ) : undefined} + {isProcessing ? ( + <View style={[styles.btn, s.mt10]}> + <ActivityIndicator /> + </View> + ) : ( + <> + <TouchableOpacity + style={styles.mt20} + onPress={onPressSendEmail}> + <LinearGradient + colors={[ + gradients.blueLight.start, + gradients.blueLight.end, + ]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text type="button-lg" style={[s.white, s.bold]}> + Send email + </Text> + </LinearGradient> + </TouchableOpacity> + <TouchableOpacity + style={[styles.btn, s.mt10]} + onPress={onCancel}> + <Text type="button-lg" style={pal.textLight}> + Cancel + </Text> + </TouchableOpacity> + </> + )} + </> + ) : ( + <> + <Text type="lg" style={styles.description}> + Check your inbox for an email with the confirmation code to enter + below: + </Text> + <BottomSheetTextInput + style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} + placeholder="Confirmation code" + placeholderTextColor={pal.textLight.color} + value={confirmCode} + onChangeText={setConfirmCode} + /> + <Text type="lg" style={styles.description}> + Please enter your password as well: + </Text> + <BottomSheetTextInput + style={[styles.textInput, pal.borderDark, pal.text]} + placeholder="Password" + placeholderTextColor={pal.textLight.color} + secureTextEntry + value={password} + onChangeText={setPassword} + /> + {error ? ( + <View style={styles.mt20}> + <ErrorMessage message={error} /> + </View> + ) : undefined} + {isProcessing ? ( + <View style={[styles.btn, s.mt10]}> + <ActivityIndicator /> + </View> + ) : ( + <> + <TouchableOpacity + style={[styles.btn, styles.evilBtn, styles.mt20]} + onPress={onPressConfirmDelete}> + <Text type="button-lg" style={[s.white, s.bold]}> + Delete my account + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[styles.btn, s.mt10]} + onPress={onCancel}> + <Text type="button-lg" style={pal.textLight}> + Cancel + </Text> + </TouchableOpacity> + </> + )} + </> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + innerContainer: { + paddingBottom: 20, + }, + title: { + textAlign: 'center', + marginTop: 12, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 22, + marginBottom: 10, + }, + mt20: { + marginTop: 20, + }, + mb20: { + marginBottom: 20, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 20, + marginHorizontal: 20, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + marginHorizontal: 20, + }, + evilBtn: { + backgroundColor: colors.red4, + }, +}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 12b72a399..add75e89e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util' import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from '../../../state' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {s, colors, gradients} from '../../lib/styles' -import { - enforceLen, - MAX_DISPLAY_NAME, - MAX_DESCRIPTION, -} from '../../../lib/strings' -import {isNetworkError} from '../../../lib/errors' -import {compressIfNeeded} from '../../../lib/images' +import {useStores} from 'state/index' +import {ProfileViewModel} from 'state/models/profile-view' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' +import {compressIfNeeded} from 'lib/images' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {cleanError, isNetworkError} from 'lib/strings/errors' export const snapPoints = ['80%'] @@ -35,6 +34,9 @@ export function Component({ }) { const store = useStores() const [error, setError] = useState<string>('') + const pal = usePalette('default') + const {track} = useAnalytics() + const [isProcessing, setProcessing] = useState<boolean>(false) const [displayName, setDisplayName] = useState<string>( profileView.displayName || '', @@ -54,24 +56,27 @@ export function Component({ store.shell.closeModal() } const onSelectNewAvatar = async (img: PickedMedia) => { + track('EditProfile:AvatarSelected') try { - const finalImg = await compressIfNeeded(img, 300000) - setNewUserAvatar(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserAvatar({mediaType: 'photo', ...finalImg}) setUserAvatar(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onSelectNewBanner = async (img: PickedMedia) => { + track('EditProfile:BannerSelected') try { - const finalImg = await compressIfNeeded(img, 500000) - setNewUserBanner(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserBanner({mediaType: 'photo', ...finalImg}) setUserBanner(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onPressSave = async () => { + track('EditProfile:Save') setProcessing(true) if (error) { setError('') @@ -94,7 +99,7 @@ export function Component({ 'Failed to save your profile. Check your internet connection and try again.', ) } else { - setError(e.message) + setError(cleanError(e)) } } setProcessing(false) @@ -103,13 +108,13 @@ export function Component({ return ( <View style={s.flex1}> <ScrollView style={styles.inner}> - <Text style={styles.title}>Edit my profile</Text> + <Text style={[styles.title, pal.text]}>Edit my profile</Text> <View style={styles.photos}> <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} /> - <View style={styles.avi}> + <View style={[styles.avi, {borderColor: pal.colors.background}]}> <UserAvatar size={80} avatar={userAvatar} @@ -127,7 +132,7 @@ export function Component({ <View> <Text style={styles.label}>Display Name</Text> <TextInput - style={styles.textInput} + style={[styles.textInput, pal.text]} placeholder="e.g. Alice Roberts" placeholderTextColor={colors.gray4} value={displayName} @@ -135,9 +140,9 @@ export function Component({ /> </View> <View style={s.pb10}> - <Text style={styles.label}>Description</Text> + <Text style={[styles.label, pal.text]}>Description</Text> <TextInput - style={[styles.textArea]} + style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} multiline @@ -162,7 +167,7 @@ export function Component({ )} <TouchableOpacity style={s.mt5} onPress={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> </View> </TouchableOpacity> </ScrollView> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index d0b40d56d..2529d0d5b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' -import {useStores} from '../../../state' +import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as DeleteAccountModal from './DeleteAccount' +import {usePalette} from 'lib/hooks/usePalette' +import {StyleSheet} from 'react-native' const CLOSED_SNAPPOINTS = ['10%'] export const Modal = observer(function Modal() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) - + const pal = usePalette('default') const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() @@ -62,10 +65,21 @@ export const Modal = observer(function Modal() { ) } else if (store.shell.activeModal?.name === 'report-post') { snapPoints = ReportPostModal.snapPoints - element = <ReportPostModal.Component /> + element = ( + <ReportPostModal.Component + {...(store.shell.activeModal as models.ReportPostModal)} + /> + ) } else if (store.shell.activeModal?.name === 'report-account') { snapPoints = ReportAccountModal.snapPoints - element = <ReportAccountModal.Component /> + element = ( + <ReportAccountModal.Component + {...(store.shell.activeModal as models.ReportAccountModal)} + /> + ) + } else if (store.shell.activeModal?.name === 'delete-account') { + snapPoints = DeleteAccountModal.snapPoints + element = <DeleteAccountModal.Component /> } else { element = <View /> } @@ -80,8 +94,17 @@ export const Modal = observer(function Modal() { backdropComponent={ store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined } + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={[styles.handle, pal.view]} onChange={onBottomSheetChange}> {element} </BottomSheet> ) }) + +const styles = StyleSheet.create({ + handle: { + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 44ea95f07..3c6551093 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,10 +1,10 @@ import React from 'react' import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' @@ -48,9 +48,17 @@ export const Modal = observer(function Modal() { /> ) } else if (store.shell.activeModal?.name === 'report-post') { - element = <ReportPostModal.Component /> + element = ( + <ReportPostModal.Component + {...(store.shell.activeModal as models.ReportPostModal)} + /> + ) } else if (store.shell.activeModal?.name === 'report-account') { - element = <ReportAccountModal.Component /> + element = ( + <ReportAccountModal.Component + {...(store.shell.activeModal as models.ReportAccountModal)} + /> + ) } else if (store.shell.activeModal?.name === 'crop-image') { element = ( <CropImageModal.Component diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx index 1385d5711..377a32838 100644 --- a/src/view/com/modals/ReportAccount.tsx +++ b/src/view/com/modals/ReportAccount.tsx @@ -5,12 +5,15 @@ import { TouchableOpacity, View, } from 'react-native' +import {ComAtprotoReportReasonType} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup' import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' const ITEMS: RadioGroupItem[] = [ {key: 'spam', label: 'Spam or excessive repeat posts'}, @@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [ export const snapPoints = ['50%'] -export function Component() { +export function Component({did}: {did: string}) { const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') @@ -28,13 +31,30 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.repoRef', + did, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 8a3a1f758..3d47e7ef0 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -5,12 +5,15 @@ import { TouchableOpacity, View, } from 'react-native' +import {ComAtprotoReportReasonType} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup' import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' const ITEMS: RadioGroupItem[] = [ {key: 'spam', label: 'Spam or excessive repeat posts'}, @@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [ export const snapPoints = ['50%'] -export function Component() { +export function Component({ + postUri, + postCid, +}: { + postUri: string + postCid: string +}) { const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') @@ -29,13 +38,31 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.recordRef', + uri: postUri, + cid: postCid, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index dde836719..5a9a4cfed 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -6,14 +6,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import { - LOCAL_DEV_SERVICE, - STAGING_SERVICE, - PROD_SERVICE, -} from '../../../state/index' -import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] @@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { {LOGIN_INCLUDE_DEV_SERVERS ? ( <> <TouchableOpacity + testID="localDevServerButton" style={styles.btn} onPress={() => doSelect(LOCAL_DEV_SERVICE)}> <Text style={styles.btnText}>Local dev server</Text> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 1f234c4a6..e43f37397 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../../util/text/Text' import {PickedMedia} from '../../util/images/image-crop-picker/types' -import {s, gradients} from '../../../lib/styles' -import {useStores} from '../../../../state' -import {usePalette} from '../../../lib/hooks/usePalette' -import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons' +import {s, gradients} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' enum AspectRatio { Square = 'square', diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 55e958c2b..5c1ee935b 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,31 +1,61 @@ -import React from 'react' +import React, {MutableRefObject} from 'react' import {observer} from 'mobx-react-lite' -import {StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {NotificationsViewModel} from '../../../state/models/notifications-view' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {NotificationsViewModel} from 'state/models/notifications-view' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} export const Feed = observer(function Feed({ view, + scrollElRef, onPressTryAgain, onScroll, }: { view: NotificationsViewModel + scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void onScroll?: OnScrollCb }) { + const data = React.useMemo(() => { + let feedItems + if (view.hasLoaded) { + if (view.isEmpty) { + feedItems = [EMPTY_FEED_ITEM] + } else { + feedItems = view.notifications + } + } + return feedItems + }, [view.hasLoaded, view.isEmpty, view.notifications]) + + const onRefresh = React.useCallback(async () => { + try { + await view.refresh() + await view.markAllRead() + } catch (err) { + view.rootStore.log.error('Failed to refresh notifications feed', err) + } + }, [view]) + const onEndReached = React.useCallback(async () => { + try { + await view.loadMore() + } catch (err) { + view.rootStore.log.error('Failed to load more notifications', err) + } + }, [view]) + // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc - const renderItem = ({item}: {item: any}) => { + const renderItem = React.useCallback(({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { return ( <EmptyState @@ -36,29 +66,20 @@ export const Feed = observer(function Feed({ ) } return <FeedItem item={item} /> - } - const onRefresh = () => { - view - .refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh notifications feed', err), - ) - } - const onEndReached = () => { - view - .loadMore() - .catch(err => - view.rootStore.log.error('Failed to load more notifications', err), - ) - } - let data - if (view.hasLoaded) { - if (view.isEmpty) { - data = [EMPTY_FEED_ITEM] - } else { - data = view.notifications - } - } + }, []) + + const FeedFooter = React.useCallback( + () => + view.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [view], + ) + return ( <View style={s.h100pct}> <CenteredView> @@ -72,9 +93,11 @@ export const Feed = observer(function Feed({ </CenteredView> {data && ( <FlatList + ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} + ListFooterComponent={FeedFooter} refreshing={view.isRefreshing} onRefresh={onRefresh} onEndReached={onEndReached} @@ -87,5 +110,6 @@ export const Feed = observer(function Feed({ }) const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index da6fc7d35..94bedc46f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -14,19 +14,19 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsViewItemModel} from '../../../state/models/notifications-view' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' -import {s, colors} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {HeartIconSolid} from '../../lib/icons' +import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' +import {s, colors} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {HeartIconSolid} from 'lib/icons' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Post} from '../post/Post' import {Link} from '../util/Link' -import {usePalette} from '../../lib/hooks/usePalette' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const MAX_AUTHORS = 5 @@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({ } if (item.isReply || item.isMention) { + if (item.additionalPost?.error) { + // hide errors - it doesnt help the user to show them + return <View /> + } return ( <Link href={itemHref} title={itemTitle} noFeedback> <Post @@ -347,12 +351,13 @@ function AdditionalPostText({ additionalPost?: PostThreadViewModel }) { const pal = usePalette('default') - if (!additionalPost || !additionalPost.thread?.postRecord) { + if ( + !additionalPost || + !additionalPost.thread?.postRecord || + additionalPost.error + ) { return <View /> } - if (additionalPost.error) { - return <ErrorMessage message={additionalPost.error} /> - } const text = additionalPost.thread?.postRecord.text const images = ( additionalPost.thread.post.embed as AppBskyEmbedImages.Presented diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx index d8d502cfb..323b1ba14 100644 --- a/src/view/com/onboard/FeatureExplainer.tsx +++ b/src/view/com/onboard/FeatureExplainer.tsx @@ -14,10 +14,10 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ @@ -127,11 +127,15 @@ export const FeatureExplainer = () => { <View /> )} <View style={styles.footer}> - <TouchableOpacity onPress={onPressSkip}> + <TouchableOpacity + onPress={onPressSkip} + testID="onboardFeatureExplainerSkipBtn"> <Text style={[s.blue3, s.f18]}>Skip</Text> </TouchableOpacity> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity + onPress={onPressNext} + testID="onboardFeatureExplainerNextBtn"> <Text style={[s.blue3, s.f18]}>Next</Text> </TouchableOpacity> </View> diff --git a/src/view/com/onboard/FeatureExplainer.web.tsx b/src/view/com/onboard/FeatureExplainer.web.tsx index 83d460808..177ac58dd 100644 --- a/src/view/com/onboard/FeatureExplainer.web.tsx +++ b/src/view/com/onboard/FeatureExplainer.web.tsx @@ -2,7 +2,6 @@ import React, {useState} from 'react' import { Animated, Image, - SafeAreaView, StyleSheet, TouchableOpacity, useWindowDimensions, @@ -15,10 +14,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx index 76eff3f4b..e7de82b39 100644 --- a/src/view/com/onboard/Follows.tsx +++ b/src/view/com/onboard/Follows.tsx @@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() @@ -18,13 +18,15 @@ export const Follows = observer(() => { return ( <SafeAreaView style={styles.container}> <Text style={styles.title}>Suggested follows</Text> - <SuggestedFollows onNoSuggestions={onNoSuggestions} /> + <View style={s.flex1}> + <SuggestedFollows onNoSuggestions={onNoSuggestions} /> + </View> <View style={styles.footer}> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn"> <Text style={[s.blue3, s.f18]}>Skip</Text> </TouchableOpacity> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn"> <Text style={[s.blue3, s.f18]}>Next</Text> </TouchableOpacity> </View> diff --git a/src/view/com/onboard/Follows.web.tsx b/src/view/com/onboard/Follows.web.tsx index 50e119e49..6b015bb09 100644 --- a/src/view/com/onboard/Follows.web.tsx +++ b/src/view/com/onboard/Follows.web.tsx @@ -1,11 +1,11 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index dacdfa50f..a9fabac3d 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views' import { RepostedByViewModel, RepostedByItem, -} from '../../../state/models/reposted-by-view' -import {UserAvatar} from '../util/UserAvatar' +} from 'state/models/reposted-by-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' export const PostRepostedBy = observer(function PostRepostedBy({ uri, @@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({ // loaded // = const renderItem = ({item}: {item: RepostedByItem}) => ( - <RepostedByItemCom item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -83,57 +88,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({ ) }) -const RepostedByItemCom = ({item}: {item: RepostedByItem}) => { - return ( - <Link - style={styles.outer} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text> - <Text style={[s.f14, s.gray5]}>@{item.handle}</Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - backgroundColor: colors.white, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 59dbf1e16..0a092c46b 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,14 +1,14 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator, View} from 'react-native' +import {ActivityIndicator} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, -} from '../../../state/models/post-thread-view' +} from 'state/models/post-thread-view' import {PostThreadItem} from './PostThreadItem' import {ErrorMessage} from '../util/error/ErrorMessage' -import {s} from '../../lib/styles' +import {s} from 'lib/styles' export const PostThread = observer(function PostThread({ uri, @@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({ view: PostThreadViewModel }) { const ref = useRef<FlatList>(null) - const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] - const onRefresh = () => { - view - ?.refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh posts thread', err), - ) - } - const onLayout = () => { + const [isRefreshing, setIsRefreshing] = React.useState(false) + const posts = React.useMemo( + () => (view.thread ? Array.from(flattenThread(view.thread)) : []), + [view.thread], + ) + + // events + // = + const onRefresh = React.useCallback(async () => { + setIsRefreshing(true) + try { + view?.refresh() + } catch (err) { + view.rootStore.log.error('Failed to refresh posts thread', err) + } + setIsRefreshing(false) + }, [view, setIsRefreshing]) + const onLayout = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ @@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({ viewOffset: 40, }) } - } - const onScrollToIndexFailed = (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - } + }, [posts, ref]) + const onScrollToIndexFailed = React.useCallback( + (info: { + index: number + highestMeasuredFrameIndex: number + averageItemLength: number + }) => { + ref.current?.scrollToOffset({ + animated: false, + offset: info.averageItemLength * info.index, + }) + }, + [ref], + ) // loading // = @@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({ <FlatList ref={ref} data={posts} + initialNumToRender={posts.length} keyExtractor={item => item._reactKey} renderItem={renderItem} - refreshing={view.isRefreshing} + refreshing={isRefreshing} onRefresh={onRefresh} onLayout={onLayout} onScrollToIndexFailed={onScrollToIndexFailed} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index d39296285..cd3a49d64 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' @@ -7,22 +7,23 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' +import {PostThreadViewPostModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' import {PostDropdownBtn} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {useStores} from '../../../state' +import {s} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/PostEmbeds' import {PostCtrls} from '../util/PostCtrls' import {ErrorMessage} from '../util/error/ErrorMessage' import {ComposePrompt} from '../composer/Prompt' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const PARENT_REPLY_LINE_LENGTH = 8 @@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({ }) { const pal = usePalette('default') const store = useStores() - const [deleted, setDeleted] = useState(false) + const [deleted, setDeleted] = React.useState(false) const record = item.postRecord const hasEngagement = item.post.upvoteCount || item.post.repostCount - const itemHref = useMemo(() => { + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` }, [item.post.uri, item.post.author.handle]) const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const authorTitle = item.post.author.handle - const upvotesHref = useMemo(() => { + const upvotesHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by` }, [item.post.uri, item.post.author.handle]) const upvotesTitle = 'Likes on this post' - const repostsHref = useMemo(() => { + const repostsHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` }, [item.post.uri, item.post.author.handle]) const repostsTitle = 'Reposts of this post' - const onPressReply = () => { + const onPressReply = React.useCallback(() => { store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({ }, onPost: onPostReply, }) - } - const onPressToggleRepost = () => { - item + }, [store, item, record, onPostReply]) + const onPressToggleRepost = React.useCallback(() => { + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleUpvote = () => { - item + }, [item, store]) + const onPressToggleUpvote = React.useCallback(() => { + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) - } - const onCopyPostText = () => { + }, [item, store]) + const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') - } - const onDeletePost = () => { + }, [record]) + const onDeletePost = React.useCallback(() => { item.delete().then( () => { setDeleted(true) @@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({ Toast.show('Failed to delete post, please try again') }, ) - } + }, [item, store]) if (!record) { return <ErrorMessage message="Invalid or unsupported post record" /> @@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={s.flex1} /> <PostDropdownBtn style={styles.metaItem} + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -179,7 +184,7 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> </View> <View style={[s.pl10, s.pr10, s.pb10]}> - {record.text ? ( + {item.richText?.text ? ( <View style={[ styles.postTextContainer, @@ -187,8 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <RichText type="post-text-lg" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> @@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[s.pl10, s.pb5]}> <PostCtrls big + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -301,12 +307,11 @@ export const PostThreadItem = observer(function PostThreadItem({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} style={pal.text} lineHeight={1.3} /> @@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ) : undefined} <PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostCtrls + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -341,7 +348,12 @@ export const PostThreadItem = observer(function PostThreadItem({ href={itemHref} title={itemTitle} noFeedback> - <Text style={pal.link}>Load more</Text> + <Text style={pal.link}>Continue thread...</Text> + <FontAwesomeIcon + icon="angle-right" + style={pal.link as FontAwesomeIconStyle} + size={18} + /> </Link> ) : undefined} </> @@ -433,8 +445,12 @@ const styles = StyleSheet.create({ marginRight: 10, }, loadMore: { + flexDirection: 'row', + justifyContent: 'space-between', borderTopWidth: 1, - paddingLeft: 28, + paddingLeft: 80, + paddingRight: 20, paddingVertical: 10, + marginBottom: 8, }, }) diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostVotedBy.tsx index 680bbadf4..2734aaea9 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostVotedBy.tsx @@ -2,14 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {VotesViewModel, VoteItem} from '../../../state/models/votes-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {VotesViewModel, VoteItem} from 'state/models/votes-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' export const PostVotedBy = observer(function PostVotedBy({ uri, @@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VoteItem}) => <LikedByItem item={item} /> + const renderItem = ({item}: {item: VoteItem}) => ( + <ProfileCardWithFollowBtn + key={item.actor.did} + did={item.actor.did} + declarationCid={item.actor.declaration.cid} + handle={item.actor.handle} + displayName={item.actor.displayName} + avatar={item.actor.avatar} + isFollowedBy={!!item.actor.viewer?.followedBy} + /> + ) return ( <FlatList data={view.votes} @@ -77,62 +83,7 @@ export const PostVotedBy = observer(function PostVotedBy({ ) }) -const LikedByItem = ({item}: {item: VoteItem}) => { - const pal = usePalette('default') - - return ( - <Link - style={[styles.outer, pal.view]} - href={`/profile/${item.actor.handle}`} - title={item.actor.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.actor.displayName} - handle={item.actor.handle} - avatar={item.actor.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.f15, s.bold, pal.text]}> - {item.actor.displayName || item.actor.handle} - </Text> - <Text style={[s.f14, s.gray5, pal.textLight]}> - @{item.actor.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index d00cc83c2..8e793ecc0 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -20,9 +20,9 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const Post = observer(function Post({ uri, @@ -80,6 +80,8 @@ export const Post = observer(function Post({ const item = view.thread const record = view.thread.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemUrip = new AtUri(item.post.uri) const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` const itemTitle = `Post by ${item.post.author.handle}` @@ -105,12 +107,12 @@ export const Post = observer(function Post({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -178,18 +180,19 @@ export const Post = observer(function Post({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> ) : undefined} <PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostCtrls + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx index a3bcfed68..a460b57c4 100644 --- a/src/view/com/post/PostText.tsx +++ b/src/view/com/post/PostText.tsx @@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' -import {PostModel} from '../../../state/models/post' -import {useStores} from '../../../state' +import {PostModel} from 'state/models/post' +import {useStores} from 'state/index' export const PostText = observer(function PostText({ uri, diff --git a/src/view/com/posts/ComposerPrompt.tsx b/src/view/com/posts/ComposerPrompt.tsx index 1ddc28756..c367a17fc 100644 --- a/src/view/com/posts/ComposerPrompt.tsx +++ b/src/view/com/posts/ComposerPrompt.tsx @@ -1,47 +1,5 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' - -export function ComposerPrompt({ - onPressCompose, -}: { +export function ComposerPrompt(_opts: { onPressCompose: (imagesOpen?: boolean) => void }) { - const pal = usePalette('default') - return ( - <View style={[pal.view, pal.border, styles.container]}> - <TouchableOpacity - testID="composePromptButton" - onPress={() => onPressCompose(false)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - <Text type="button" style={pal.text}> - New post - </Text> - </TouchableOpacity> - <TouchableOpacity - onPress={() => onPressCompose(true)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - <Text type="button" style={pal.text}> - Share photo - </Text> - </TouchableOpacity> - </View> - ) + return null } - -const styles = StyleSheet.create({ - container: { - paddingVertical: 12, - paddingHorizontal: 16, - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - }, - btn: { - paddingVertical: 6, - paddingHorizontal: 14, - borderRadius: 30, - marginRight: 10, - }, -}) diff --git a/src/view/com/posts/ComposerPrompt.web.tsx b/src/view/com/posts/ComposerPrompt.web.tsx index 96c09f0b3..a87653cf8 100644 --- a/src/view/com/posts/ComposerPrompt.web.tsx +++ b/src/view/com/posts/ComposerPrompt.web.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' -import {s} from '../../lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' export function ComposerPrompt({ onPressCompose, diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 409ce4af2..57363ca51 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {EmptyState} from '../util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' -import {FeedModel} from '../../../state/models/feed-view' +import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {ComposerPrompt} from './ComposerPrompt' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} +const ERROR_FEED_ITEM = {_reactKey: '__error__'} export const Feed = observer(function Feed({ feed, style, scrollElRef, - onPressCompose, onPressTryAgain, + onPressCompose, onScroll, testID, + headerOffset = 0, }: { feed: FeedModel style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onPressCompose: (imagesOpen?: boolean) => void onPressTryAgain?: () => void + onPressCompose: (imagesOpen?: boolean) => void onScroll?: OnScrollCb testID?: string + headerOffset?: number }) { + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let feedItems: any[] = [] + if (feed.hasLoaded) { + feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM]) + if (feed.hasError) { + feedItems = feedItems.concat([ERROR_FEED_ITEM]) + } + if (feed.isEmpty) { + feedItems = feedItems.concat([EMPTY_FEED_ITEM]) + } else { + feedItems = feedItems.concat(feed.feed) + } + } + return feedItems + }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Feed:onRefresh') + setIsRefreshing(true) + try { + await feed.refresh() + } catch (err) { + feed.rootStore.log.error('Failed to refresh posts feed', err) + } + setIsRefreshing(false) + }, [feed, track, setIsRefreshing]) + const onEndReached = React.useCallback(async () => { + track('Feed:onEndReached') + try { + await feed.loadMore() + } catch (err) { + feed.rootStore.log.error('Failed to load more posts', err) + } + }, [feed, track]) + + // rendering + // = + // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc - const renderItem = ({item}: {item: any}) => { - if (item === COMPOSE_PROMPT_ITEM) { - return <ComposerPrompt onPressCompose={onPressCompose} /> - } else if (item === EMPTY_FEED_ITEM) { - return ( - <EmptyState - icon="bars" - message="This feed is empty!" - style={styles.emptyState} - /> - ) - } else { - return <FeedItem item={item} /> - } - } - const onRefresh = () => { - feed - .refresh() - .catch(err => - feed.rootStore.log.error('Failed to refresh posts feed', err), - ) - } - const onEndReached = () => { - feed - .loadMore() - .catch(err => feed.rootStore.log.error('Failed to load more posts', err)) - } - let data - if (feed.hasLoaded) { - if (feed.isEmpty) { - data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM] - } else { - data = [COMPOSE_PROMPT_ITEM].concat(feed.feed) - } - } - const FeedFooter = () => - feed.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ) - return ( - <View testID={testID} style={style}> - <CenteredView> - {!data && <ComposerPrompt onPressCompose={onPressCompose} />} - {feed.isLoading && !data && <PostFeedLoadingPlaceholder />} - {feed.hasError && ( + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === COMPOSE_PROMPT_ITEM) { + return <ComposerPrompt onPressCompose={onPressCompose} /> + } else if (item === EMPTY_FEED_ITEM) { + return ( + <EmptyState + icon="bars" + message="This feed is empty!" + style={styles.emptyState} + /> + ) + } else if (item === ERROR_FEED_ITEM) { + return ( <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> - )} - </CenteredView> - {feed.hasLoaded && data && ( + ) + } + return <FeedItem item={item} /> + }, + [feed, onPressTryAgain, onPressCompose], + ) + + const FeedFooter = React.useCallback( + () => + feed.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [feed], + ) + + return ( + <View testID={testID} style={style}> + {feed.isLoading && data.length === 0 && ( + <CenteredView style={{paddingTop: headerOffset}}> + <PostFeedLoadingPlaceholder /> + </CenteredView> + )} + {data.length > 0 && ( <FlatList ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} - refreshing={feed.isRefreshing} + refreshing={isRefreshing} contentContainerStyle={s.contentContainer} onScroll={onScroll} onRefresh={onRefresh} onEndReached={onEndReached} + removeClippedSubviews={true} + contentInset={{top: headerOffset}} + contentOffset={{x: 0, y: headerOffset * -1}} + progressViewOffset={headerOffset} /> )} </View> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index cda2ac0b0..67807b14e 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {FeedItemModel} from '../../../state/models/feed-view' +import {FeedItemModel} from 'state/models/feed-view' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {s} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const FeedItem = observer(function ({ item, @@ -33,8 +34,11 @@ export const FeedItem = observer(function ({ }) { const store = useStores() const pal = usePalette('default') + const {track} = useAnalytics() const [deleted, setDeleted] = useState(false) const record = item.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemHref = useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` @@ -50,6 +54,7 @@ export const FeedItem = observer(function ({ }, [record?.reply]) const onPressReply = () => { + track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -64,12 +69,14 @@ export const FeedItem = observer(function ({ }) } const onPressToggleRepost = () => { - item + track('FeedItem:PostRepost') + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + track('FeedItem:PostLike') + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -78,6 +85,7 @@ export const FeedItem = observer(function ({ Toast.show('Copied to clipboard') } const onDeletePost = () => { + track('FeedItem:PostDelete') item.delete().then( () => { setDeleted(true) @@ -195,12 +203,11 @@ export const FeedItem = observer(function ({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> @@ -210,6 +217,8 @@ export const FeedItem = observer(function ({ ) : null} <PostCtrls style={styles.ctrls} + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 0cda3ba2a..2f93e59e6 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,23 +1,29 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import * as Toast from '../util/Toast' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' export function ProfileCard({ handle, displayName, avatar, + description, + isFollowedBy, renderButton, - onPressButton, }: { handle: string displayName?: string avatar?: string + description?: string + isFollowedBy?: boolean renderButton?: () => JSX.Element - onPressButton?: () => void }) { const pal = usePalette('default') return ( @@ -36,30 +42,117 @@ export function ProfileCard({ /> </View> <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]} numberOfLines={1}> + <Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}> {displayName || handle} </Text> - <Text type="sm" style={[pal.textLight]} numberOfLines={1}> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> @{handle} </Text> + {isFollowedBy && ( + <View style={s.flexRow}> + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs">Follows You</Text> + </View> + </View> + )} </View> {renderButton ? ( - <View style={styles.layoutButton}> - <TouchableOpacity - onPress={onPressButton} - style={[styles.btn, pal.btn]}> - {renderButton()} - </TouchableOpacity> - </View> + <View style={styles.layoutButton}>{renderButton()}</View> ) : undefined} </View> + {description ? ( + <View style={styles.details}> + <Text style={pal.text} numberOfLines={4}> + {description} + </Text> + </View> + ) : undefined} </Link> ) } +export const ProfileCardWithFollowBtn = observer( + ({ + did, + declarationCid, + handle, + displayName, + avatar, + description, + isFollowedBy, + }: { + did: string + declarationCid: string + handle: string + displayName?: string + avatar?: string + description?: string + isFollowedBy?: boolean + }) => { + const store = useStores() + const isMe = store.me.handle === handle + const isFollowing = store.me.follows.isFollowing(did) + const onToggleFollow = async () => { + if (store.me.follows.isFollowing(did)) { + try { + await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + } catch (e: any) { + store.log.error('Failed fo delete follow', e) + Toast.show('An issue occurred, please try again.') + } + } else { + try { + const res = await apilib.follow(store, did, declarationCid) + store.me.follows.addFollow(did, res.uri) + } catch (e: any) { + store.log.error('Failed fo create follow', e) + Toast.show('An issue occurred, please try again.') + } + } + } + return ( + <ProfileCard + handle={handle} + displayName={displayName} + avatar={avatar} + description={description} + isFollowedBy={isFollowedBy} + renderButton={ + isMe + ? undefined + : () => ( + <FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} /> + ) + } + /> + ) + }, +) + +function FollowBtn({ + isFollowing, + onPress, +}: { + isFollowing: boolean + onPress: () => void +}) { + const pal = usePalette('default') + return ( + <TouchableOpacity onPress={onPress}> + <View style={[styles.btn, pal.btn]}> + <Text type="button" style={[pal.text]}> + {isFollowing ? 'Unfollow' : 'Follow'} + </Text> + </View> + </TouchableOpacity> + ) +} + const styles = StyleSheet.create({ outer: { borderTopWidth: 1, + paddingHorizontal: 6, }, layout: { flexDirection: 'row', @@ -68,7 +161,7 @@ const styles = StyleSheet.create({ layoutAvi: { width: 60, paddingLeft: 10, - paddingTop: 10, + paddingTop: 8, paddingBottom: 10, }, avi: { @@ -80,19 +173,26 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, paddingRight: 10, - paddingTop: 12, + paddingTop: 10, paddingBottom: 10, }, layoutButton: { paddingRight: 10, }, + details: { + paddingLeft: 60, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', paddingVertical: 7, - paddingHorizontal: 14, borderRadius: 50, marginLeft: 6, + paddingHorizontal: 14, }, }) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 13d134c39..7db770e4b 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native' import { UserFollowersViewModel, FollowerItem, -} from '../../../state/models/user-followers-view' +} from 'state/models/user-followers-view' import {CenteredView, FlatList} from '../util/Views' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollowers = observer(function ProfileFollowers({ name, @@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // loaded // = const renderItem = ({item}: {item: FollowerItem}) => ( - <User key={item.did} item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -84,55 +88,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ ) }) -const User = ({item}: {item: FollowerItem}) => { - const pal = usePalette('default') - return ( - <Link - style={[styles.outer, pal.view, pal.border]} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]}> - {item.displayName || item.handle} - </Text> - <Text type="sm" style={[pal.textLight]}> - @{item.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index de2fe3324..fb7f08ed8 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -2,17 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import { - UserFollowsViewModel, - FollowItem, -} from '../../../state/models/user-follows-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollows = observer(function ProfileFollows({ name, @@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({ // loaded // = const renderItem = ({item}: {item: FollowItem}) => ( - <User key={item.did} item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -84,59 +85,7 @@ export const ProfileFollows = observer(function ProfileFollows({ ) }) -const User = ({item}: {item: FollowItem}) => { - const pal = usePalette('default') - return ( - <Link - style={[styles.outer, pal.view, pal.border]} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={ - item.avatar as - | string - | undefined /* HACK: type signature is wrong in the api */ - } - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]}> - {item.displayName || item.handle} - </Text> - <Text type="sm" style={[pal.textLight]}> - @{item.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index cebeea788..0ca6e1e74 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -13,15 +13,16 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {BlurView} from '../util/BlurView' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {useStores} from '../../../state' +import {ProfileViewModel} from 'state/models/profile-view' +import {useStores} from 'state/index' import { EditProfileModal, ReportAccountModal, ProfileImageLightbox, -} from '../../../state/models/shell-ui' -import {pluralize, toShareUrl} from '../../../lib/strings' -import {s, gradients} from '../../lib/styles' +} from 'state/models/shell-ui' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {s, gradients} from 'lib/styles' import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -29,7 +30,8 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const ProfileHeader = observer(function ProfileHeader({ view, @@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({ }) { const pal = usePalette('default') const store = useStores() - + const {track} = useAnalytics() const onPressBack = () => { store.nav.tab.goBack() } @@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({ view?.toggleFollowing().then( () => { Toast.show( - `${view.myState.follow ? 'Following' : 'No longer following'} ${ + `${view.viewer.following ? 'Following' : 'No longer following'} ${ view.displayName || view.handle }`, ) @@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({ ) } const onPressEditProfile = () => { + track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal(new EditProfileModal(view, onRefreshAll)) } const onPressFollowers = () => { + track('ProfileHeader:FollowersButtonClicked') store.nav.navigate(`/profile/${view.handle}/followers`) } const onPressFollows = () => { + track('ProfileHeader:FollowsButtonClicked') store.nav.navigate(`/profile/${view.handle}/follows`) } const onPressShare = () => { + track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) } const onPressMuteAccount = async () => { + track('ProfileHeader:MuteAccountButtonClicked') try { await view.muteAccount() Toast.show('Account muted') @@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressUnmuteAccount = async () => { + track('ProfileHeader:UnmuteAccountButtonClicked') try { await view.unmuteAccount() Toast.show('Account unmuted') @@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressReportAccount = () => { + track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal(new ReportAccountModal(view.did)) } @@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({ <LoadingPlaceholder width={100} height={31} style={styles.br50} /> </View> <View style={styles.displayNameLine}> - <Text type="title-xl" style={[pal.text, styles.title]}> + <Text type="title-2xl" style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> @@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({ let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] if (!isMe) { dropdownItems.push({ - label: view.myState.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount, + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, }) dropdownItems.push({ label: 'Report Account', @@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({ </TouchableOpacity> ) : ( <> - {view.myState.follow ? ( + {store.me.follows.isFollowing(view.did) ? ( <TouchableOpacity onPress={onPressToggleFollow} style={[styles.btn, styles.mainBtn, pal.btn]}> @@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : undefined} </View> <View style={styles.displayNameLine}> - <Text type="title-xl" style={[pal.text, styles.title]}> + <Text type="title-2xl" style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> <View style={styles.handleLine}> + {view.viewer.followedBy ? ( + <View style={[styles.pill, pal.btn, s.mr5]}> + <Text type="xs" style={[pal.text]}> + Follows you + </Text> + </View> + ) : undefined} <Text style={pal.textLight}>@{view.handle}</Text> </View> <View style={styles.metricsLine}> @@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({ </Text> </View> </View> - {view.description ? ( + {view.descriptionRichText ? ( <RichText style={[styles.description, pal.text]} numberOfLines={15} - text={view.description} - entities={view.descriptionEntities} + richText={view.descriptionRichText} /> ) : undefined} - {view.myState.muted ? ( + {view.viewer.muted ? ( <View style={[styles.detailLine, pal.btn, s.p5]}> <FontAwesomeIcon icon={['far', 'eye-slash']} style={[pal.text, s.mr5]} /> <Text type="md" style={[s.mr2, pal.text]}> - Account muted. + Account muted </Text> </View> ) : undefined} @@ -369,6 +384,12 @@ const styles = StyleSheet.create({ marginBottom: 5, }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx index 7e4300c7c..efcf40b9c 100644 --- a/src/view/com/util/BlurView.web.tsx +++ b/src/view/com/util/BlurView.web.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View, ViewProps} from 'react-native' -import {addStyle} from '../../lib/addStyle' +import {addStyle} from 'lib/styles' type BlurViewProps = ViewProps & { blurType?: 'dark' | 'light' diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 6c5c3f342..2b2c4e657 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -6,8 +6,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' -import {UserGroupIcon} from '../../lib/icons' -import {usePalette} from '../../lib/hooks/usePalette' +import {UserGroupIcon} from 'lib/icons' +import {usePalette} from 'lib/hooks/usePalette' export function EmptyState({ icon, diff --git a/src/view/com/util/FAB.tsx b/src/view/com/util/FAB.tsx index 1129f3361..007ca0ee4 100644 --- a/src/view/com/util/FAB.tsx +++ b/src/view/com/util/FAB.tsx @@ -1,41 +1,53 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { + Animated, GestureResponderEvent, StyleSheet, TouchableWithoutFeedback, - View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {colors, gradients} from '../../lib/styles' -import {useStores} from '../../../state' +import {colors, gradients} from 'lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' type OnPress = ((event: GestureResponderEvent) => void) | undefined export const FAB = observer( - ({icon, onPress}: {icon: IconProp; onPress: OnPress}) => { + ({ + testID, + icon, + onPress, + }: { + testID?: string + icon: IconProp + onPress: OnPress + }) => { const store = useStores() + const interp = useAnimatedValue(0) + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, 60)}], + } return ( - <TouchableWithoutFeedback onPress={onPress}> - <View - style={[ - styles.outer, - store.shell.minimalShellMode ? styles.lower : undefined, - ]}> + <TouchableWithoutFeedback testID={testID} onPress={onPress}> + <Animated.View style={[styles.outer, transform]}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={styles.inner}> - <FontAwesomeIcon - size={24} - icon={icon} - color={colors.white} - style={styles.icon} - /> + <FontAwesomeIcon size={24} icon={icon} color={colors.white} /> </LinearGradient> - </View> + </Animated.View> </TouchableWithoutFeedback> ) }, @@ -46,16 +58,10 @@ const styles = StyleSheet.create({ position: 'absolute', zIndex: 1, right: 22, - bottom: 84, + bottom: 94, width: 60, height: 60, borderRadius: 30, - shadowColor: '#000', - shadowOpacity: 0.3, - shadowOffset: {width: 0, height: 1}, - }, - lower: { - bottom: 34, }, inner: { width: 60, @@ -64,5 +70,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - icon: {}, }) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1cbb1af83..bdc447937 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -10,9 +10,9 @@ import { ViewStyle, } from 'react-native' import {Text} from './text/Text' -import {TypographyVariant} from '../../lib/ThemeContext' -import {useStores, RootStoreModel} from '../../../state' -import {convertBskyAppUrlIfNeeded} from '../../../lib/strings' +import {TypographyVariant} from 'lib/ThemeContext' +import {useStores, RootStoreModel} from 'state/index' +import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers' export const Link = observer(function Link({ style, @@ -22,17 +22,21 @@ export const Link = observer(function Link({ noFeedback, }: { style?: StyleProp<ViewStyle> - href: string + href?: string title?: string children?: React.ReactNode noFeedback?: boolean }) { const store = useStores() const onPress = () => { - handleLink(store, href, false) + if (href) { + handleLink(store, href, false) + } } const onLongPress = () => { - handleLink(store, href, true) + if (href) { + handleLink(store, href, true) + } } if (noFeedback) { return ( diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index 43fa97e6f..fd05ecc9c 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite' import LinearGradient from 'react-native-linear-gradient' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Text} from './text/Text' -import {colors, gradients} from '../../lib/styles' +import {colors, gradients} from 'lib/styles' import {clamp} from 'lodash' -import {useStores} from '../../../state' +import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index 388927388..182c1ba5d 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 9bb200d50..9e72640d2 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,10 +1,10 @@ import React from 'react' import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {HeartIcon} from '../../lib/icons' -import {s} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {usePalette} from '../../lib/hooks/usePalette' +import {HeartIcon} from 'lib/icons' +import {s} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function LoadingPlaceholder({ width, diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx index 208ec0492..9007cb1f0 100644 --- a/src/view/com/util/Picker.tsx +++ b/src/view/com/util/Picker.tsx @@ -16,7 +16,7 @@ import { } from '@fortawesome/react-native-fontawesome' import RootSiblings from 'react-native-root-siblings' import {Text} from './text/Text' -import {colors} from '../../lib/styles' +import {colors} from 'lib/styles' interface PickerItem { value: string diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index fca70b687..e42c5e63b 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -1,6 +1,5 @@ import React from 'react' import { - Animated, StyleProp, StyleSheet, TouchableOpacity, @@ -12,6 +11,11 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import ReactNativeHapticFeedback from 'react-native-haptic-feedback' +// DISABLED see #135 +// import { +// TriggerableAnimated, +// TriggerableAnimatedRef, +// } from './anim/TriggerableAnimated' import {Text} from './text/Text' import {PostDropdownBtn} from './forms/DropdownButton' import { @@ -19,12 +23,13 @@ import { HeartIconSolid, RepostIcon, CommentBottomArrow, -} from '../../lib/icons' -import {s, colors} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +} from 'lib/icons' +import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' interface PostCtrlsOpts { + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -36,91 +41,110 @@ interface PostCtrlsOpts { isReposted: boolean isUpvoted: boolean onPressReply: () => void - onPressToggleRepost: () => void - onPressToggleUpvote: () => void + onPressToggleRepost: () => Promise<void> + onPressToggleUpvote: () => Promise<void> onCopyPostText: () => void onDeletePost: () => void } -const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2} +const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} -export function PostCtrls(opts: PostCtrlsOpts) { - const theme = useTheme() - const defaultCtrlColor = React.useMemo( - () => ({ - color: theme.palette.default.postCtrl, +// DISABLED see #135 +/* +function ctrlAnimStart(interp: Animated.Value) { + return Animated.sequence([ + Animated.timing(interp, { + toValue: 1, + duration: 250, + useNativeDriver: true, }), - [theme], - ) - const interp1 = useAnimatedValue(0) - const interp2 = useAnimatedValue(0) - - const anim1Style = { - transform: [ - { - scale: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], + Animated.delay(50), + Animated.timing(interp, { + toValue: 0, + duration: 20, + useNativeDriver: true, }), - } - const anim2Style = { + ]) +} + +function ctrlAnimStyle(interp: Animated.Value) { + return { transform: [ { - scale: interp2.interpolate({ + scale: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 4.0], }), }, ], - opacity: interp2.interpolate({ + opacity: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 0.0], }), } +} +*/ +export function PostCtrls(opts: PostCtrlsOpts) { + const theme = useTheme() + const defaultCtrlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) as StyleProp<ViewStyle> + const [repostMod, setRepostMod] = React.useState<number>(0) + const [likeMod, setLikeMod] = React.useState<number>(0) + // DISABLED see #135 + // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null) + // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null) const onPressToggleRepostWrapper = () => { if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp1, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp1, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setRepostMod(1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) + // DISABLED see #135 + // repostRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleRepost().catch(_e => undefined) + // setRepostMod(0) + // }, + // ) + } else { + setRepostMod(-1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) } - opts.onPressToggleRepost() } const onPressToggleUpvoteWrapper = () => { if (!opts.isUpvoted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp2, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp2, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setLikeMod(1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) + // DISABLED see #135 + // likeRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleUpvote().catch(_e => undefined) + // setLikeMod(0) + // }, + // ) + } else { + setLikeMod(-1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) } - opts.onPressToggleUpvote() } return ( @@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) { hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> - <Animated.View style={anim1Style}> + <RepostIcon + style={ + opts.isReposted || repostMod > 0 + ? (styles.ctrlIconReposted as StyleProp<ViewStyle>) + : defaultCtrlColor + } + strokeWidth={2.4} + size={opts.big ? 24 : 20} + /> + { + undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}> <RepostIcon style={ (opts.isReposted @@ -157,15 +191,16 @@ export function PostCtrls(opts: PostCtrlsOpts) { strokeWidth={2.4} size={opts.big ? 24 : 20} /> - </Animated.View> + </TriggerableAnimated>*/ + } {typeof opts.repostCount !== 'undefined' ? ( <Text style={ - opts.isReposted + opts.isReposted || repostMod > 0 ? [s.bold, s.green3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.repostCount} + {opts.repostCount + repostMod} </Text> ) : undefined} </TouchableOpacity> @@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl} hitSlop={HITSLOP} onPress={onPressToggleUpvoteWrapper}> - <Animated.View style={anim2Style}> - {opts.isUpvoted ? ( + {opts.isUpvoted || likeMod > 0 ? ( + <HeartIconSolid + style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>} + size={opts.big ? 22 : 16} + /> + ) : ( + <HeartIcon + style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} + strokeWidth={3} + size={opts.big ? 20 : 16} + /> + )} + { + undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}> + {opts.isUpvoted || likeMod > 0 ? ( <HeartIconSolid style={styles.ctrlIconUpvoted as ViewStyle} size={opts.big ? 22 : 16} @@ -191,15 +239,16 @@ export function PostCtrls(opts: PostCtrlsOpts) { size={opts.big ? 20 : 16} /> )} - </Animated.View> + </TriggerableAnimated>*/ + } {typeof opts.upvoteCount !== 'undefined' ? ( <Text style={ - opts.isUpvoted + opts.isUpvoted || likeMod > 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount} + {opts.upvoteCount + likeMod} </Text> ) : undefined} </TouchableOpacity> @@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { {opts.big ? undefined : ( <PostDropdownBtn style={styles.ctrl} + itemUri={opts.itemUri} + itemCid={opts.itemCid} itemHref={opts.itemHref} itemTitle={opts.itemTitle} isAuthor={opts.isAuthor} diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx new file mode 100644 index 000000000..e8c63bdb7 --- /dev/null +++ b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {Text} from '../text/Text' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' + +const ExternalLinkEmbed = ({ + link, + onImagePress, + imageChild, +}: { + link: PresentedExternal + onImagePress?: () => void + imageChild?: React.ReactNode +}) => { + const pal = usePalette('default') + return ( + <> + {link.thumb ? ( + <AutoSizedImage + uri={link.thumb} + style={styles.extImage} + onPress={onImagePress}> + {imageChild} + </AutoSizedImage> + ) : undefined} + <View style={styles.extInner}> + <Text type="md-bold" numberOfLines={2} style={[pal.text]}> + {link.title || link.uri} + </Text> + <Text + type="sm" + numberOfLines={1} + style={[pal.textLight, styles.extUri]}> + {link.uri} + </Text> + {link.description ? ( + <Text + type="sm" + numberOfLines={2} + style={[pal.text, styles.extDescription]}> + {link.description} + </Text> + ) : undefined} + </View> + </> + ) +} + +const styles = StyleSheet.create({ + extInner: { + padding: 10, + }, + extImage: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + maxHeight: 200, + }, + extUri: { + marginTop: 2, + }, + extDescription: { + marginTop: 4, + }, +}) + +export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..d9425fe4e --- /dev/null +++ b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx @@ -0,0 +1,119 @@ +import React, {useEffect} from 'react' +import {useState} from 'react' +import { + View, + StyleSheet, + Pressable, + TouchableWithoutFeedback, + EmitterSubscription, +} from 'react-native' +import YoutubePlayer from 'react-native-youtube-iframe' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {useStores} from 'state/index' + +const YoutubeEmbed = ({ + link, + videoId, +}: { + videoId: string + link: PresentedExternal +}) => { + const store = useStores() + const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) + const [playerDimensions, setPlayerDimensions] = useState({ + width: 0, + height: 0, + }) + const pal = usePalette('default') + const handlePlayButtonPressed = () => { + setDisplayVideoPlayer(true) + } + const handleOnLayout = (event: { + nativeEvent: {layout: {width: any; height: any}} + }) => { + setPlayerDimensions({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }) + } + useEffect(() => { + let sub: EmitterSubscription + if (displayVideoPlayer) { + sub = store.onNavigation(() => { + setDisplayVideoPlayer(false) + }) + } + return () => sub && sub.remove() + }, [displayVideoPlayer, store]) + + const imageChild = ( + <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}> + <FontAwesomeIcon icon="play" size={24} color="white" /> + </Pressable> + ) + + if (!displayVideoPlayer) { + return ( + <View + style={[styles.extOuter, pal.view, pal.border]} + onLayout={handleOnLayout}> + <ExternalLinkEmbed + link={link} + onImagePress={handlePlayButtonPressed} + imageChild={imageChild} + /> + </View> + ) + } + + const height = (playerDimensions.width / 16) * 9 + const noop = () => {} + + return ( + <TouchableWithoutFeedback onPress={noop}> + <View> + {/* Removing the outter View will make tap events propagate to parents */} + <YoutubePlayer + initialPlayerParams={{ + modestbranding: true, + }} + webViewProps={{ + startInLoadingState: true, + }} + height={height} + videoId={videoId} + webViewStyle={styles.webView} + /> + </View> + </TouchableWithoutFeedback> + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) + +export default YoutubeEmbed diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds/index.tsx index 1d8df038b..031f01e88 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -1,16 +1,22 @@ import React from 'react' -import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native' +import { + StyleSheet, + StyleProp, + View, + ViewStyle, + Image as RNImage, +} from 'react-native' import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' -import LinearGradient from 'react-native-linear-gradient' -import {Link} from '../util/Link' -import {Text} from './text/Text' -import {AutoSizedImage} from './images/AutoSizedImage' -import {ImageLayoutGrid} from './images/ImageLayoutGrid' -import {ImagesLightbox} from '../../../state/models/shell-ui' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {gradients} from '../../lib/styles' -import {saveImageModal} from '../../../lib/images' +import {Link} from '../Link' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {ImageLayoutGrid} from '../images/ImageLayoutGrid' +import {ImagesLightbox} from 'state/models/shell-ui' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {saveImageModal} from 'lib/images' +import YoutubeEmbed from './YoutubeEmbed' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {getYoutubeVideoId} from 'lib/strings/url-helpers' type Embed = | AppBskyEmbedImages.Presented @@ -35,6 +41,16 @@ export function PostEmbeds({ const onLongPress = (index: number) => { saveImageModal({uri: uris[index]}) } + const onPressIn = (index: number) => { + const firstImageToShow = uris[index] + RNImage.prefetch(firstImageToShow) + uris.forEach(uri => { + if (firstImageToShow !== uri) { + // First image already prefeched above + RNImage.prefetch(uri) + } + }) + } if (embed.images.length === 4) { return ( @@ -44,6 +60,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -55,6 +72,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -66,6 +84,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -76,7 +95,8 @@ export function PostEmbeds({ uri={embed.images[0].thumb} onPress={() => openLightbox(0)} onLongPress={() => onLongPress(0)} - containerStyle={styles.singleImage} + onPressIn={() => onPressIn(0)} + style={styles.singleImage} /> </View> ) @@ -85,40 +105,18 @@ export function PostEmbeds({ } if (AppBskyEmbedExternal.isPresented(embed)) { const link = embed.external + const youtubeVideoId = getYoutubeVideoId(link.uri) + + if (youtubeVideoId) { + return <YoutubeEmbed videoId={youtubeVideoId} link={link} /> + } + return ( <Link style={[styles.extOuter, pal.view, pal.border, style]} href={link.uri} noFeedback> - {link.thumb ? ( - <AutoSizedImage uri={link.thumb} containerStyle={styles.extImage} /> - ) : ( - <LinearGradient - colors={[gradients.blueDark.start, gradients.blueDark.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.extImage, styles.extImageFallback]} - /> - )} - <View style={styles.extInner}> - <Text type="md-bold" numberOfLines={2} style={[pal.text]}> - {link.title || link.uri} - </Text> - <Text - type="sm" - numberOfLines={1} - style={[pal.textLight, styles.extUri]}> - {link.uri} - </Text> - {link.description ? ( - <Text - type="sm" - numberOfLines={2} - style={[pal.text, styles.extDescription]}> - {link.description} - </Text> - ) : undefined} - </View> + <ExternalLinkEmbed link={link} /> </Link> ) } @@ -131,28 +129,11 @@ const styles = StyleSheet.create({ }, singleImage: { borderRadius: 8, + maxHeight: 500, }, extOuter: { borderWidth: 1, borderRadius: 8, marginTop: 4, }, - extInner: { - padding: 10, - }, - extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - maxHeight: 200, - }, - extImageFallback: { - height: 160, - }, - extUri: { - marginTop: 2, - }, - extDescription: { - marginTop: 4, - }, }) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 35edeafb4..16b9535ff 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,8 +1,8 @@ import React from 'react' import {Platform, StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {ago} from '../../../lib/strings' -import {usePalette} from '../../lib/hooks/usePalette' +import {ago} from 'lib/strings/time' +import {usePalette} from 'lib/hooks/usePalette' interface PostMetaOpts { authorHandle: string diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index 87540cf38..5b331dc8d 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -6,7 +6,7 @@ import { View, } from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' interface Layout { x: number diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 287d94412..9b8dd3de5 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,15 +1,23 @@ -import React, {useCallback} from 'react' -import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {HighPriorityImage} from 'view/com/util/images/Image' import { openCamera, openCropper, openPicker, PickedMedia, } from './images/image-crop-picker/ImageCropPicker' -import {useStores} from '../../../state' -import {colors, gradients} from '../../lib/styles' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' +import {useStores} from 'state/index' +import {colors, gradients} from 'lib/styles' +import {DropdownButton} from './forms/DropdownButton' +import {usePalette} from 'lib/hooks/usePalette' export function UserAvatar({ size, @@ -25,40 +33,9 @@ export function UserAvatar({ onSelectNewAvatar?: (img: PickedMedia) => void }) { const store = useStores() + const pal = usePalette('default') const initials = getInitials(displayName || handle) - const handleEditAvatar = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { - mediaType: 'photo', - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }, - }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { - mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }) - }, - }, - ]) - }, [store, onSelectNewAvatar]) - const renderSvg = (svgSize: number, svgInitials: string) => ( <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> <Defs> @@ -80,11 +57,65 @@ export function UserAvatar({ </Svg> ) + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewAvatar?.( + await openCamera(store, { + mediaType: 'photo', + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewAvatar?.( + await openCropper(store, { + mediaType: 'photo', + path: items[0].path, + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove avatar API call + // }, + // }, + ] // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( - <TouchableOpacity onPress={handleEditAvatar}> + <DropdownButton + type="bare" + items={dropdownItems} + openToRight + rightOffset={-10} + bottomOffset={-10} + menuWidth={170}> {avatar ? ( - <Image + <HighPriorityImage style={{ width: size, height: size, @@ -95,16 +126,16 @@ export function UserAvatar({ ) : ( renderSvg(size, initials) )} - <View style={styles.editButtonContainer}> + <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon icon="camera" size={12} - style={{color: colors.white}} + color={pal.text.color as string} /> </View> - </TouchableOpacity> + </DropdownButton> ) : avatar ? ( - <Image + <HighPriorityImage style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} resizeMode="stretch" source={{uri: avatar}} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index d5d6e3aaa..dc140b035 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,15 +1,23 @@ -import React, {useCallback} from 'react' -import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors, gradients} from '../../lib/styles' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import Image from 'view/com/util/images/Image' +import {colors, gradients} from 'lib/styles' import { openCamera, openCropper, openPicker, PickedMedia, } from './images/image-crop-picker/ImageCropPicker' -import {useStores} from '../../../state' +import {useStores} from 'state/index' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' +import {DropdownButton} from './forms/DropdownButton' +import {usePalette} from 'lib/hooks/usePalette' export function UserBanner({ banner, @@ -19,39 +27,57 @@ export function UserBanner({ onSelectNewBanner?: (img: PickedMedia) => void }) { const store = useStores() - const handleEditBanner = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { + const pal = usePalette('default') + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewBanner?.( + await openCamera(store, { mediaType: 'photo', // compressImageMaxWidth: 3000, TODO needed? width: 3000, // compressImageMaxHeight: 1000, TODO needed? height: 1000, - }).then(onSelectNewBanner) - }, + }), + ) }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewBanner?.( + await openCropper(store, { mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - // compressImageMaxWidth: 3000, TODO needed? - width: 3000, - // compressImageMaxHeight: 1000, TODO needed? - height: 1000, - }).then(onSelectNewBanner) - }) - }, + path: items[0].path, + // compressImageMaxWidth: 3000, TODO needed? + width: 3000, + // compressImageMaxHeight: 1000, TODO needed? + height: 1000, + }), + ) }, - ]) - }, [store, onSelectNewBanner]) + }, + // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove banner api call + // }, + // }, + ] const renderSvg = () => ( <Svg width="100%" height="150" viewBox="50 0 200 100"> @@ -72,20 +98,27 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - <TouchableOpacity onPress={handleEditBanner}> + <DropdownButton + type="bare" + items={dropdownItems} + openToRight + rightOffset={-200} + bottomOffset={-10} + menuWidth={170}> {banner ? ( <Image style={styles.bannerImage} source={{uri: banner}} /> ) : ( renderSvg() )} - <View style={styles.editButtonContainer}> + <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon icon="camera" size={12} style={{color: colors.white}} + color={pal.text.color as string} /> </View> - </TouchableOpacity> + </DropdownButton> ) : banner ? ( <Image style={styles.bannerImage} diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index a6daf18b2..d7907aa89 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {Link} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' -import {useStores} from '../../../state' -import {TypographyVariant} from '../../lib/ThemeContext' +import {useStores} from 'state/index' +import {TypographyVariant} from 'lib/ThemeContext' export function UserInfoText({ type = 'md', diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 82eff2a81..259196b66 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,56 +1,40 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {CenteredView} from './Views' +import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {UserAvatar} from './UserAvatar' import {Text} from './text/Text' -import {MagnifyingGlassIcon} from '../../lib/icons' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useAnalytics} from 'lib/analytics' -const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} export const ViewHeader = observer(function ViewHeader({ title, - subtitle, canGoBack, + hideOnScroll, }: { title: string - subtitle?: string canGoBack?: boolean + hideOnScroll?: boolean }) { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() const onPressBack = () => { store.nav.tab.goBack() } const onPressMenu = () => { + track('ViewHeader:MenuButtonClicked') store.shell.setMainMenuOpen(true) } - const onPressSearch = () => { - store.nav.navigate('/search') - } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } return ( - <CenteredView style={[styles.header, pal.view]}> + <Container hideOnScroll={hideOnScroll || false}> <TouchableOpacity testID="viewHeaderBackOrMenuBtn" onPress={canGoBack ? onPressBack : onPressMenu} @@ -75,48 +59,57 @@ export const ViewHeader = observer(function ViewHeader({ <Text type="title" style={[pal.text, styles.title]}> {title} </Text> - {subtitle ? ( - <Text - type="title-sm" - style={[styles.subtitle, pal.textLight]} - numberOfLines={1}> - {subtitle} - </Text> - ) : undefined} </View> - <TouchableOpacity - onPress={onPressSearch} - hitSlop={HITSLOP} - style={styles.btn}> - <MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} /> - </TouchableOpacity> - {!store.session.online ? ( - <TouchableOpacity style={styles.btn} onPress={onPressReconnect}> - {store.session.attemptingConnect ? ( - <ActivityIndicator /> - ) : ( - <> - <FontAwesomeIcon - icon="signal" - style={pal.text as FontAwesomeIconStyle} - size={16} - /> - <FontAwesomeIcon - icon="x" - style={[ - styles.littleXIcon, - {backgroundColor: pal.colors.background}, - ]} - size={8} - /> - </> - )} - </TouchableOpacity> - ) : undefined} - </CenteredView> + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + </Container> ) }) +const Container = observer( + ({ + children, + hideOnScroll, + }: { + children: React.ReactNode + hideOnScroll: boolean + }) => { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + React.useEffect(() => { + if (store.shell.minimalShellMode) { + Animated.timing(interp, { + toValue: 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } else { + Animated.timing(interp, { + toValue: 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } + + if (!hideOnScroll) { + return <View style={[styles.header, pal.view]}>{children}</View> + } + return ( + <Animated.View + style={[styles.header, pal.view, styles.headerFloating, transform]}> + {children} + </Animated.View> + ) + }, +) + const styles = StyleSheet.create({ header: { flexDirection: 'row', @@ -125,20 +118,20 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 6, }, + headerFloating: { + position: 'absolute', + top: 0, + width: '100%', + }, titleContainer: { - flexDirection: 'row', - alignItems: 'baseline', + marginLeft: 'auto', marginRight: 'auto', + paddingRight: 10, }, title: { fontWeight: 'bold', }, - subtitle: { - marginLeft: 4, - maxWidth: 200, - fontWeight: 'normal', - }, backBtn: { width: 30, @@ -152,19 +145,4 @@ const styles = StyleSheet.create({ backIcon: { marginTop: 6, }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: 36, - height: 36, - borderRadius: 20, - marginLeft: 4, - }, - littleXIcon: { - color: colors.red3, - position: 'absolute', - right: 7, - bottom: 7, - }, }) diff --git a/src/view/com/util/ViewHeader.web.tsx b/src/view/com/util/ViewHeader.web.tsx index 0d5c99aac..5c0869e8b 100644 --- a/src/view/com/util/ViewHeader.web.tsx +++ b/src/view/com/util/ViewHeader.web.tsx @@ -1,20 +1,12 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CenteredView} from './Views' import {Text} from './text/Text' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {colors} from 'lib/styles' const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} @@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({ const onPressBack = () => { store.nav.tab.goBack() } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } @@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({ </Text> </View> )} - {!store.session.online ? ( - <TouchableOpacity style={styles.btn} onPress={onPressReconnect}> - {store.session.attemptingConnect ? ( - <ActivityIndicator /> - ) : ( - <> - <FontAwesomeIcon - icon="signal" - style={pal.text as FontAwesomeIconStyle} - size={16} - /> - <FontAwesomeIcon - icon="x" - style={[ - styles.littleXIcon, - {backgroundColor: pal.colors.background}, - ]} - size={8} - /> - </> - )} - </TouchableOpacity> - ) : undefined} </CenteredView> ) }) diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index ff5115c51..b786c2290 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -3,10 +3,10 @@ import {View} from 'react-native' import {Selector} from './Selector' import {HorzSwipe} from './gestures/HorzSwipe' import {FlatList} from './Views' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {clamp} from '../../../lib/numbers' -import {s} from '../../lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {clamp} from 'lib/numbers' +import {s} from 'lib/styles' const HEADER_ITEM = {_reactKey: '__header__'} const SELECTOR_ITEM = {_reactKey: '__selector__'} @@ -101,6 +101,7 @@ export function ViewSelector({ onRefresh={onRefresh} onEndReached={onEndReached} contentContainerStyle={s.contentContainer} + removeClippedSubviews={true} /> </HorzSwipe> ) diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 2df534144..c16070b2b 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -22,9 +22,8 @@ import { View, ViewProps, } from 'react-native' -import {useTheme} from '../../lib/ThemeContext' -import {addStyle} from '../../lib/addStyle' -import {colors} from '../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {addStyle, colors} from 'lib/styles' export function CenteredView({ style, diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx new file mode 100644 index 000000000..2a3cbb957 --- /dev/null +++ b/src/view/com/util/anim/TriggerableAnimated.tsx @@ -0,0 +1,73 @@ +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 +>(({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 a6d77326e..e6e27fac0 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -11,8 +11,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorMessage({ message, diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index f316dbcc6..0221ea153 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -5,9 +5,9 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {colors} from '../../../lib/styles' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorScreen({ title, diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index b5c4da19d..a070d2f0f 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -7,8 +7,8 @@ import { ViewStyle, } from 'react-native' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export type ButtonType = | 'primary' diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index f911529d2..8fddd5941 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {colors} from '../../../lib/styles' -import {toShareUrl} from '../../../../lib/strings' -import {useStores} from '../../../../state' -import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui' -import {TABS_ENABLED} from '../../../../build-flags' +import {colors} from 'lib/styles' +import {toShareUrl} from 'lib/strings/url-helpers' +import {useStores} from 'state/index' +import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui' +import {TABS_ENABLED} from 'lib/build-flags' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} @@ -36,6 +38,9 @@ export function DropdownButton({ label, menuWidth, children, + openToRight = false, + rightOffset = 0, + bottomOffset = 0, }: { type?: DropdownButtonType style?: StyleProp<ViewStyle> @@ -43,6 +48,9 @@ export function DropdownButton({ label?: string menuWidth?: number children?: React.ReactNode + openToRight?: boolean + rightOffset?: number + bottomOffset?: number }) { const ref = useRef<TouchableOpacity>(null) @@ -59,12 +67,11 @@ export function DropdownButton({ if (!menuWidth) { menuWidth = 200 } - createDropdownMenu( - pageX + width - menuWidth, - pageY + height, - menuWidth, - items, - ) + const newX = openToRight + ? pageX + width + rightOffset + : pageX + width - menuWidth + const newY = pageY + height + bottomOffset + createDropdownMenu(newX, newY, menuWidth, items) }, ) } @@ -97,6 +104,8 @@ export function DropdownButton({ export function PostDropdownBtn({ style, children, + itemUri, + itemCid, itemHref, isAuthor, onCopyPostText, @@ -104,6 +113,8 @@ export function PostDropdownBtn({ }: { style?: StyleProp<ViewStyle> children?: React.ReactNode + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -140,7 +151,7 @@ export function PostDropdownBtn({ icon: 'circle-exclamation', label: 'Report post', onPress() { - store.shell.openModal(new ReportPostModal(itemHref)) + store.shell.openModal(new ReportPostModal(itemUri, itemCid)) }, }, isAuthor @@ -180,24 +191,14 @@ function createDropdownMenu( const onOuterPress = () => sibling.destroy() const sibling = new RootSiblings( ( - <> - <TouchableWithoutFeedback onPress={onOuterPress}> - <View style={styles.bg} /> - </TouchableWithoutFeedback> - <View style={[styles.menu, {left: x, top: y, width}]}> - {items.map((item, index) => ( - <TouchableOpacity - key={index} - style={[styles.menuItem]} - onPress={() => onPressItem(index)}> - {item.icon && ( - <FontAwesomeIcon style={styles.icon} icon={item.icon} /> - )} - <Text style={styles.label}>{item.label}</Text> - </TouchableOpacity> - ))} - </View> - </> + <DropdownItems + onOuterPress={onOuterPress} + x={x} + y={y} + width={width} + items={items} + onPressItem={onPressItem} + /> ), ) return sibling @@ -241,3 +242,55 @@ const styles = StyleSheet.create({ fontSize: 18, }, }) +type DropDownItemProps = { + onOuterPress: () => void + x: number + y: number + width: number + items: DropdownItem[] + onPressItem: (index: number) => void +} + +const DropdownItems = ({ + onOuterPress, + x, + y, + width, + items, + onPressItem, +}: DropDownItemProps) => { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + + return ( + <> + <TouchableWithoutFeedback onPress={onOuterPress}> + <View style={[styles.bg]} /> + </TouchableWithoutFeedback> + <View + style={[ + styles.menu, + {left: x, top: y, width}, + dropDownBackgroundColor, + ]}> + {items.map((item, index) => ( + <TouchableOpacity + key={index} + style={[styles.menuItem]} + onPress={() => onPressItem(index)}> + {item.icon && ( + <FontAwesomeIcon + style={styles.icon} + icon={item.icon} + color={pal.text.color as string} + /> + )} + <Text style={[styles.label, pal.text]}>{item.label}</Text> + </TouchableOpacity> + ))} + </View> + </> + ) +} diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 81489c447..57a875cd3 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -2,8 +2,8 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export function RadioButton({ type = 'default-light', diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index b33cd9831..901b0cdd8 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react' import {View} from 'react-native' import {RadioButton} from './RadioButton' import {ButtonType} from './Button' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export interface RadioGroupItem { label: string diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 77e8fa203..005d1165e 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -2,9 +2,9 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' -import {colors} from '../../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' +import {colors} from 'lib/styles' export function ToggleButton({ type = 'default-light', diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx index 22b15afe7..09f6c345f 100644 --- a/src/view/com/util/gestures/HorzSwipe.tsx +++ b/src/view/com/util/gestures/HorzSwipe.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' interface Props { panX: Animated.Value @@ -90,6 +90,7 @@ export function HorzSwipe({ // swiping right (diffX < 0 && !canSwipeRight) ) { + panX.setValue(0) return } @@ -119,6 +120,7 @@ export function HorzSwipe({ toValue: final, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { onSwipeEnd?.(final) panX.flattenOffset() @@ -130,6 +132,7 @@ export function HorzSwipe({ toValue: 0, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { panX.flattenOffset() panX.setValue(0) diff --git a/src/view/com/util/gestures/SwipeAndZoom.tsx b/src/view/com/util/gestures/SwipeAndZoom.tsx index ee00edab7..75c679012 100644 --- a/src/view/com/util/gestures/SwipeAndZoom.tsx +++ b/src/view/com/util/gestures/SwipeAndZoom.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export enum Dir { None, diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index cdefc7123..0443c7be4 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,125 +1,59 @@ -import React, {useState, useEffect} from 'react' -import { - Image, - ImageStyle, - LayoutChangeEvent, - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' -import {DELAY_PRESS_IN} from './constants' +import React from 'react' +import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' +import Image, {OnLoadEvent} from 'view/com/util/images/Image' +import {clamp} from 'lib/numbers' -const MAX_HEIGHT = 300 - -interface Dim { - width: number - height: number -} +export const DELAY_PRESS_IN = 500 +const MIN_ASPECT_RATIO = 0.33 // 1/3 +const MAX_ASPECT_RATIO = 5 // 5/1 export function AutoSizedImage({ uri, onPress, onLongPress, + onPressIn, style, - containerStyle, + children = null, }: { uri: string onPress?: () => void onLongPress?: () => void - style?: StyleProp<ImageStyle> - containerStyle?: StyleProp<ViewStyle> + onPressIn?: () => void + style?: StyleProp<ViewStyle> + children?: React.ReactNode }) { - const theme = useTheme() - const errPal = usePalette('error') - const [error, setError] = useState<string | undefined>('') - const [imgInfo, setImgInfo] = useState<Dim | undefined>() - const [containerInfo, setContainerInfo] = useState<Dim | undefined>() - - useEffect(() => { - let aborted = false - if (!imgInfo) { - Image.getSize( - uri, - (width: number, height: number) => { - if (!aborted) { - setImgInfo({width, height}) - } - }, - (err: any) => { - if (!aborted) { - setError(String(err)) - } - }, - ) - } - return () => { - aborted = true - } - }, [uri, imgInfo]) - - const onLayout = (evt: LayoutChangeEvent) => { - setContainerInfo({ - width: evt.nativeEvent.layout.width, - height: evt.nativeEvent.layout.height, - }) - } - - let calculatedStyle: StyleProp<ImageStyle> | undefined - if (imgInfo && containerInfo) { - // imgInfo.height / imgInfo.width = x / containerInfo.width - // x = imgInfo.height / imgInfo.width * containerInfo.width - calculatedStyle = { - height: Math.min( - MAX_HEIGHT, - (imgInfo.height / imgInfo.width) * containerInfo.width, + const [aspectRatio, setAspectRatio] = React.useState<number>(1) + const onLoad = (e: OnLoadEvent) => { + setAspectRatio( + clamp( + e.nativeEvent.width / e.nativeEvent.height, + MIN_ASPECT_RATIO, + MAX_ASPECT_RATIO, ), - } + ) } - return ( - <View style={style}> - <TouchableOpacity - onPress={onPress} - onLongPress={onLongPress} - delayPressIn={DELAY_PRESS_IN}> - {error ? ( - <View style={[styles.errorContainer, errPal.view, containerStyle]}> - <Text style={errPal.text}>{error}</Text> - </View> - ) : calculatedStyle ? ( - <View style={[styles.container, containerStyle]}> - <Image style={calculatedStyle} source={{uri}} /> - </View> - ) : ( - <View - style={[ - style, - styles.placeholder, - {backgroundColor: theme.palette.default.backgroundLight}, - ]} - onLayout={onLayout} - /> - )} - </TouchableOpacity> - </View> + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + delayPressIn={DELAY_PRESS_IN} + style={[styles.container, style]}> + <Image + style={[styles.image, {aspectRatio}]} + source={{uri}} + onLoad={onLoad} + /> + {children} + </TouchableOpacity> ) } const styles = StyleSheet.create({ - placeholder: { - width: '100%', - aspectRatio: 1, - }, - errorContainer: { - paddingHorizontal: 12, - paddingVertical: 8, - }, container: { overflow: 'hidden', }, + image: { + width: '100%', + }, }) diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx new file mode 100644 index 000000000..8c95a581e --- /dev/null +++ b/src/view/com/util/images/Image.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import FastImage, {FastImageProps, Source} from 'react-native-fast-image' +export default FastImage +export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image' + +export function HighPriorityImage({source, ...props}: FastImageProps) { + const updatedSource = { + uri: typeof source === 'object' && source ? source.uri : '', + priority: FastImage.priority.high, + } as Source + return <FastImage source={updatedSource} {...props} /> +} diff --git a/src/view/com/util/images/Image.web.tsx b/src/view/com/util/images/Image.web.tsx new file mode 100644 index 000000000..ecd9d730a --- /dev/null +++ b/src/view/com/util/images/Image.web.tsx @@ -0,0 +1,11 @@ +import { + Image, + NativeSyntheticEvent, + ImageLoadEventData, + ImageSourcePropType, +} from 'react-native' +export default Image +export const HighPriorityImage = Image +export type OnLoadEvent = NativeSyntheticEvent<ImageLoadEventData> +export type Source = ImageSourcePropType +export type {ImageStyle} from 'react-native' diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 366424308..bed13406c 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -1,12 +1,12 @@ import React from 'react' import { - Image, StyleProp, StyleSheet, TouchableWithoutFeedback, View, ViewStyle, } from 'react-native' +import Image from 'view/com/util/images/Image' export function ImageHorzList({ uris, diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 97ad9d700..a1c732649 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,7 +1,5 @@ import React from 'react' import { - Image, - ImageStyle, LayoutChangeEvent, StyleProp, StyleSheet, @@ -9,7 +7,9 @@ import { View, ViewStyle, } from 'react-native' -import {DELAY_PRESS_IN} from './constants' +import Image, {ImageStyle} from 'view/com/util/images/Image' + +export const DELAY_PRESS_IN = 500 interface Dim { width: number @@ -23,12 +23,14 @@ export function ImageLayoutGrid({ uris, onPress, onLongPress, + onPressIn, style, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void style?: StyleProp<ViewStyle> }) { const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>() @@ -47,6 +49,7 @@ export function ImageLayoutGrid({ type={type} uris={uris} onPress={onPress} + onPressIn={onPressIn} onLongPress={onLongPress} containerInfo={containerInfo} /> @@ -60,15 +63,17 @@ function ImageLayoutGridInner({ uris, onPress, onLongPress, + onPressIn, containerInfo, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void containerInfo: Dim }) { - const size1 = React.useMemo<ImageStyle>(() => { + const size1 = React.useMemo<StyleProp<ImageStyle>>(() => { if (type === 'three') { const size = (containerInfo.width - 10) / 3 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -77,7 +82,7 @@ function ImageLayoutGridInner({ return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} } }, [type, containerInfo]) - const size2 = React.useMemo<ImageStyle>(() => { + const size2 = React.useMemo<StyleProp<ImageStyle>>(() => { if (type === 'three') { const size = ((containerInfo.width - 10) / 3) * 2 + 5 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -93,6 +98,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size1} /> </TouchableOpacity> @@ -100,6 +106,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> @@ -112,6 +119,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size2} /> </TouchableOpacity> @@ -120,6 +128,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> @@ -127,6 +136,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(2)} + onPressIn={() => onPressIn?.(2)} onLongPress={() => onLongPress?.(2)}> <Image source={{uri: uris[2]}} style={size1} /> </TouchableOpacity> @@ -141,29 +151,33 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size1} /> </TouchableOpacity> <View style={styles.hSpace} /> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} - onPress={() => onPress?.(1)} - onLongPress={() => onLongPress?.(1)}> - <Image source={{uri: uris[1]}} style={size1} /> + onPress={() => onPress?.(2)} + onPressIn={() => onPressIn?.(2)} + onLongPress={() => onLongPress?.(2)}> + <Image source={{uri: uris[2]}} style={size1} /> </TouchableOpacity> </View> <View style={styles.wSpace} /> <View> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} - onPress={() => onPress?.(2)} - onLongPress={() => onLongPress?.(2)}> - <Image source={{uri: uris[2]}} style={size1} /> + onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} + onLongPress={() => onLongPress?.(1)}> + <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> <View style={styles.hSpace} /> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(3)} + onPressIn={() => onPressIn?.(3)} onLongPress={() => onLongPress?.(3)}> <Image source={{uri: uris[3]}} style={size1} /> </TouchableOpacity> diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts deleted file mode 100644 index cb2c26cea..000000000 --- a/src/view/com/util/images/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DELAY_PRESS_IN = 500 diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx index ddc9e87fd..d723fef99 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx @@ -4,7 +4,7 @@ import { openCropper as openCropperFn, ImageOrVideo, } from 'react-native-image-crop-picker' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx index a7037f3a4..d632590d6 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx @@ -1,9 +1,9 @@ /// <reference lib="dom" /> -import {CropImageModal} from '../../../../../state/models/shell-ui' +import {CropImageModal} from 'state/models/shell-ui' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' interface PickedFile { uri: string @@ -31,17 +31,17 @@ export async function openPicker( export async function openCamera( _store: RootStoreModel, - opts: CameraOpts, + _opts: CameraOpts, ): Promise<PickedMedia> { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } export async function openCropper( _store: RootStoreModel, - opts: CropperOpts, + _opts: CropperOpts, ): Promise<PickedMedia> { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index f04f4566d..d4cf19172 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -2,29 +2,21 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' import {TextLink} from '../Link' import {Text} from './Text' -import {lh} from '../../../lib/styles' -import {toShortUrl} from '../../../../lib/strings' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' - -type TextSlice = {start: number; end: number} -type Entity = { - index: TextSlice - type: string - value: string -} +import {lh} from 'lib/styles' +import {toShortUrl} from 'lib/strings/url-helpers' +import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function RichText({ type = 'md', - text, - entities, + richText, lineHeight = 1.2, style, numberOfLines, }: { type?: TypographyVariant - text: string - entities?: Entity[] + richText?: RichTextObj lineHeight?: number style?: StyleProp<TextStyle> numberOfLines?: number @@ -32,6 +24,12 @@ export function RichText({ const theme = useTheme() const pal = usePalette('default') const lineHeightStyle = lh(theme, type, lineHeight) + + if (!richText) { + return null + } + + const {text, entities} = richText if (!entities?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index c3a8a2194..14c57cf47 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,7 +1,7 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' -import {s} from '../../../lib/styles' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' +import {s} from 'lib/styles' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' export type CustomTextProps = TextProps & { type?: TypographyVariant |