diff options
Diffstat (limited to 'src/view/com')
44 files changed, 1439 insertions, 1104 deletions
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/Composer.tsx index f45c6340d..e9b728d73 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,74 +1,47 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react' +import React, {useEffect, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, - NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, - TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics' -import _isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' -import {Autocomplete} from './autocomplete/Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' 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/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 {getPostAsQuote} from 'lib/link-meta/bsky' -import {getImageDim, downloadAndResize} from 'lib/media/manip' -import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker' -import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker' -import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' -import {SelectedPhoto} from './SelectedPhoto' +import {SelectPhotoBtn} from './photos/SelectPhotoBtn' +import {OpenCameraBtn} from './photos/OpenCameraBtn' +import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' -import {isWeb} from 'platform/detection' import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import {useExternalLinkFetch} from './useExternalLinkFetch' 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, onPost, onClose, quote: initQuote, }: { replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void quote?: ComposerOpts['quote'] @@ -77,7 +50,6 @@ export const ComposePost = observer(function ComposePost({ 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('') @@ -85,15 +57,8 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) - const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( - undefined, - ) - const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>( - new Set(), - ) - const [isSelectingPhotos, setIsSelectingPhotos] = useState( - imagesOpen || false, - ) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( @@ -106,85 +71,16 @@ export const ComposePost = observer(function ComposePost({ // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) // manually blurring before closing gets around that // -prf - const hackfixOnClose = () => { + const hackfixOnClose = React.useCallback(() => { textInput.current?.blur() onClose() - } + }, [textInput, onClose]) // initial setup useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - // external link metadata-fetch flow - useEffect(() => { - let aborted = false - const cleanup = () => { - aborted = true - } - if (!extLink) { - return cleanup - } - if (!extLink.meta) { - if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( - newQuote => { - if (aborted) { - return - } - setQuote(newQuote) - setExtLink(undefined) - }, - err => { - store.log.error('Failed to fetch post for quote embedding', {err}) - setExtLink(undefined) - }, - ) - } else { - getLinkMeta(store, extLink.uri).then(meta => { - if (aborted) { - return - } - setExtLink({ - uri: extLink.uri, - isLoading: !!meta.image, - meta, - }) - }) - } - return cleanup - } - if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { - downloadAndResize({ - uri: extLink.meta.image, - width: 2000, - height: 2000, - mode: 'contain', - maxSize: 1000000, - timeout: 15e3, - }) - .catch(() => undefined) - .then(localThumb => { - if (aborted) { - return - } - setExtLink({ - ...extLink, - isLoading: false, // done - localThumb, - }) - }) - return cleanup - } - if (extLink.isLoading) { - setExtLink({ - ...extLink, - isLoading: false, // done - }) - } - return cleanup - }, [store, extLink]) - useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view @@ -202,95 +98,36 @@ export const ComposePost = observer(function ComposePost({ } }, []) - const onPressContainer = () => { + const onPressContainer = React.useCallback(() => { textInput.current?.focus() - } - const onPressSelectPhotos = async () => { - track('ComposePost:SelectPhotos') - if (isWeb) { - if (selectedPhotos.length < 4) { - const images = await pickImagesFlow( - store, - 4 - selectedPhotos.length, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - setSelectedPhotos([...selectedPhotos, ...images]) - } - } else { - if (isSelectingPhotos) { - setIsSelectingPhotos(false) - } else if (selectedPhotos.length < 4) { - setIsSelectingPhotos(true) - } - } - } - 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) + }, [textInput]) - const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) - if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) - } + const onSelectPhotos = React.useCallback( + (photos: string[]) => { + track('Composer:SelectedPhotos') + setSelectedPhotos(photos) + }, + [track, setSelectedPhotos], + ) - 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) - } - } - } - const onPaste = async (err: string | undefined, uris: string[]) => { - if (err) { - return setError(cleanError(err)) - } - if (selectedPhotos.length >= 4) { - return - } - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) - if (imgUri) { - let imgDim - try { - imgDim = await getImageDim(imgUri) - } catch (e) { - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + const onPressAddLinkCard = React.useCallback( + (uri: string) => { + setExtLink({uri, isLoading: true}) + }, + [setExtLink], + ) + + const onPhotoPasted = React.useCallback( + async (uri: string) => { + if (selectedPhotos.length >= 4) { + return } - const finalImgPath = await cropAndCompressFlow( - store, - imgUri, - imgDim, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - 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 () => { + onSelectPhotos([...selectedPhotos, uri]) + }, + [selectedPhotos, onSelectPhotos], + ) + + const onPressPublish = React.useCallback(async () => { if (isProcessing) { return } @@ -332,7 +169,22 @@ export const ComposePost = observer(function ComposePost({ onPost?.() hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - } + }, [ + isProcessing, + text, + setError, + setIsProcessing, + replyTo, + autocompleteView.knownHandles, + extLink, + hackfixOnClose, + onPost, + quote, + selectedPhotos, + setExtLink, + store, + track, + ]) const canPost = text.length <= MAX_TEXT_LENGTH @@ -346,25 +198,6 @@ export const ComposePost = observer(function ComposePost({ ? 'Write a comment' : "What's up?" - const textDecorated = useMemo(() => { - let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { - return ( - <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} - </Text> - ) - } else { - return ( - <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} - </Text> - ) - } - }) - }, [text, pal.link, pal.text]) - return ( <KeyboardAvoidingView testID="composePostView" @@ -375,7 +208,7 @@ export const ComposePost = observer(function ComposePost({ <View style={styles.topbar}> <TouchableOpacity testID="composerCancelButton" - onPress={onPressCancel}> + onPress={hackfixOnClose}> <Text style={[pal.link, s.f18]}>Cancel</Text> </TouchableOpacity> <View style={s.flex1} /> @@ -423,19 +256,11 @@ export const ComposePost = observer(function ComposePost({ <ScrollView style={s.flex1}> {replyTo ? ( <View style={[pal.border, styles.replyToLayout]}> - <UserAvatar - handle={replyTo.author.handle} - displayName={replyTo.author.displayName} - avatar={replyTo.author.avatar} - size={50} - /> + <UserAvatar avatar={replyTo.author.avatar} size={50} /> <View style={styles.replyToPost}> - <TextLink - type="xl-medium" - href={`/profile/${replyTo.author.handle}`} - text={replyTo.author.displayName || replyTo.author.handle} - style={[pal.text]} - /> + <Text type="xl-medium" style={[pal.text]}> + {replyTo.author.displayName || replyTo.author.handle} + </Text> <Text type="post-text" style={pal.text} numberOfLines={6}> {replyTo.text} </Text> @@ -449,26 +274,18 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, selectTextInputLayout, ]}> - <UserAvatar - handle={store.me.handle || ''} - displayName={store.me.displayName} - avatar={store.me.avatar} - size={50} - /> + <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput - testID="composerTextInput" - innerRef={textInput} - onChangeText={(str: string) => onChangeText(str)} - onPaste={onPaste} - onSelectionChange={onSelectionChange} + ref={textInput} + text={text} placeholder={selectTextInputPlaceholder} - style={[ - pal.text, - styles.textInput, - styles.textInputFormatting, - ]}> - {textDecorated} - </TextInput> + suggestedLinks={suggestedLinks} + autocompleteView={autocompleteView} + onTextChanged={setText} + onPhotoPasted={onPhotoPasted} + onSuggestedLinksChanged={setSuggestedLinks} + onError={setError} + /> </View> {quote ? ( @@ -477,7 +294,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : undefined} - <SelectedPhoto + <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> @@ -488,17 +305,12 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && selectedPhotos.length < 4 ? ( - <PhotoCarouselPicker - selectedPhotos={selectedPhotos} - onSelectPhotos={onSelectPhotos} - /> - ) : !extLink && - selectedPhotos.length === 0 && - suggestedExtLinks.size > 0 && - !quote ? ( + {!extLink && + selectedPhotos.length === 0 && + suggestedLinks.size > 0 && + !quote ? ( <View style={s.mb5}> - {Array.from(suggestedExtLinks).map(url => ( + {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} style={[pal.borderDark, styles.addExtLinkBtn]} @@ -511,31 +323,19 @@ export const ComposePost = observer(function ComposePost({ </View> ) : null} <View style={[pal.border, styles.bottomBar]}> - {quote ? undefined : ( - <TouchableOpacity - testID="composerSelectPhotosButton" - onPress={onPressSelectPhotos} - style={[s.pl5]} - hitSlop={HITSLOP}> - <FontAwesomeIcon - icon={['far', 'image']} - style={ - (selectedPhotos.length < 4 - ? pal.link - : pal.textLight) as FontAwesomeIconStyle - } - size={24} - /> - </TouchableOpacity> - )} + <SelectPhotoBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> + <OpenCameraBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> <View style={s.flex1} /> <CharProgress count={text.length} /> </View> - <Autocomplete - active={autocompleteView.isActive} - items={autocompleteView.suggestions} - onSelect={onSelectAutocompleteItem} - /> </SafeAreaView> </TouchableWithoutFeedback> </KeyboardAvoidingView> @@ -597,18 +397,6 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingTop: 16, }, - textInput: { - flex: 1, - padding: 5, - marginLeft: 8, - alignSelf: 'flex-start', - }, - textInputFormatting: { - fontSize: 18, - letterSpacing: 0.2, - fontWeight: '400', - lineHeight: 23.4, // 1.3*16 - }, replyToLayout: { flexDirection: 'row', borderTopWidth: 1, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 23dcaffd5..658023330 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -75,6 +75,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 8, marginTop: 20, + marginBottom: 10, }, inner: { padding: 10, diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 88d5de2bf..301b90093 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -4,12 +4,9 @@ import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' -export function ComposePrompt({ - onPressCompose, -}: { - onPressCompose: (imagesOpen?: boolean) => void -}) { +export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { const store = useStores() const pal = usePalette('default') return ( @@ -17,13 +14,13 @@ export function ComposePrompt({ testID="replyPromptBtn" style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()}> - <UserAvatar - handle={store.me.handle} - avatar={store.me.avatar} - displayName={store.me.displayName} - size={38} - /> - <Text type="xl" style={[pal.text, styles.label]}> + <UserAvatar avatar={store.me.avatar} size={38} /> + <Text + type="xl" + style={[ + pal.text, + isDesktopWeb ? styles.labelDesktopWeb : styles.labelMobile, + ]}> Write your reply </Text> </TouchableOpacity> @@ -39,7 +36,10 @@ const styles = StyleSheet.create({ alignItems: 'center', borderTopWidth: 1, }, - label: { + labelMobile: { paddingLeft: 12, }, + labelDesktopWeb: { + paddingLeft: 20, + }, }) diff --git a/src/view/com/composer/autocomplete/Autocomplete.tsx b/src/view/com/composer/autocomplete/Autocomplete.tsx deleted file mode 100644 index 82fb239da..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, {useEffect} from 'react' -import { - Animated, - TouchableOpacity, - StyleSheet, - useWindowDimensions, -} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - const winDim = useWindowDimensions() - const positionInterp = useAnimatedValue(0) - - useEffect(() => { - Animated.timing(positionInterp, { - toValue: active ? 1 : 0, - duration: 200, - useNativeDriver: false, - }).start() - }, [positionInterp, active]) - - const topAnimStyle = { - top: positionInterp.interpolate({ - inputRange: [0, 1], - outputRange: [winDim.height, winDim.height / 4], - }), - } - return ( - <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </Animated.View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - borderTopWidth: 1, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/autocomplete/Autocomplete.web.tsx b/src/view/com/composer/autocomplete/Autocomplete.web.tsx deleted file mode 100644 index b6be1c21e..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.web.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - - if (!active) { - return <View /> - } - return ( - <View style={[styles.outer, pal.view, pal.border]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderWidth: 1, - borderRadius: 8, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx new file mode 100644 index 000000000..cf4a4c7d1 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openCamera} from 'lib/media/picker' +import {compressIfNeeded} from 'lib/media/manip' +import {useCameraPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function OpenCameraBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestCameraAccessIfNeeded} = useCameraPermission() + + const onPressTakePicture = React.useCallback(async () => { + track('Composer:CameraOpened') + if (!enabled) { + return + } + try { + if (!(await requestCameraAccessIfNeeded())) { + return + } + const cameraRes = await openCamera(store, { + mediaType: 'photo', + width: POST_IMG_MAX_WIDTH, + height: POST_IMG_MAX_HEIGHT, + freeStyleCropEnabled: true, + }) + const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) + onSelectPhotos([...selectedPhotos, img.path]) + } catch (err: any) { + // ignore + store.log.warn('Error using camera', err) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestCameraAccessIfNeeded, + ]) + + if (isDesktopWeb) { + return <></> + } + + return ( + <TouchableOpacity + testID="openCameraButton" + onPress={onPressTakePicture} + style={[s.pl5]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon="camera" + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx deleted file mode 100644 index 580e9746e..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics' -import { - openPicker, - openCamera, - cropAndCompressFlow, -} from '../../../../lib/media/picker' -import { - UserLocalPhotosModel, - PhotoIdentifier, -} from 'state/models/user-local-photos' -import {compressIfNeeded} from 'lib/media/manip' -import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' -import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' - -export const PhotoCarouselPicker = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const [isSetup, setIsSetup] = React.useState<boolean>(false) - - const localPhotos = React.useMemo<UserLocalPhotosModel>( - () => new UserLocalPhotosModel(store), - [store], - ) - - React.useEffect(() => { - // initial setup - localPhotos.setup().then(() => { - setIsSetup(true) - }) - }, [localPhotos]) - - const handleOpenCamera = useCallback(async () => { - try { - if (!(await requestCameraAccessIfNeeded())) { - return - } - const cameraRes = await openCamera(store, { - mediaType: 'photo', - width: POST_IMG_MAX_WIDTH, - height: POST_IMG_MAX_HEIGHT, - freeStyleCropEnabled: true, - }) - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) - onSelectPhotos([...selectedPhotos, img.path]) - } catch (err: any) { - // ignore - store.log.warn('Error using camera', err) - } - }, [store, selectedPhotos, onSelectPhotos]) - - const handleSelectPhoto = useCallback( - async (item: PhotoIdentifier) => { - track('PhotoCarouselPicker:PhotoSelected') - try { - const imgPath = await cropAndCompressFlow( - store, - item.node.image.uri, - { - width: item.node.image.width, - height: item.node.image.height, - }, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, imgPath]) - } catch (err: any) { - // ignore - store.log.warn('Error selecting photo', err) - } - }, - [track, store, onSelectPhotos, selectedPhotos], - ) - - const handleOpenGallery = useCallback(async () => { - track('PhotoCarouselPicker:GalleryOpened') - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker(store, { - multiple: true, - maxFiles: 4 - selectedPhotos.length, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow( - store, - image.path, - image, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ), - ) - } - onSelectPhotos([...selectedPhotos, ...result]) - }, [track, store, selectedPhotos, onSelectPhotos]) - - return ( - <ScrollView - testID="photoCarouselPickerView" - horizontal - style={[pal.view, styles.photosContainer]} - keyboardShouldPersistTaps="always" - showsHorizontalScrollIndicator={false}> - <TouchableOpacity - testID="openCameraButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenCamera}> - <FontAwesomeIcon - icon="camera" - size={24} - style={pal.link as FontAwesomeIconStyle} - /> - </TouchableOpacity> - <TouchableOpacity - testID="openGalleryButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenGallery}> - <FontAwesomeIcon - icon="image" - style={pal.link as FontAwesomeIconStyle} - size={24} - /> - </TouchableOpacity> - {isSetup && - localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - <TouchableOpacity - testID="openSelectPhotoButton" - key={`local-image-${index}`} - style={[pal.border, styles.photoButton]} - onPress={() => handleSelectPhoto(item)}> - <Image style={styles.photo} source={{uri: item.node.image.uri}} /> - </TouchableOpacity> - ))} - </ScrollView> - ) -} - -const styles = StyleSheet.create({ - photosContainer: { - width: '100%', - maxHeight: 96, - padding: 8, - overflow: 'hidden', - }, - galleryButton: { - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, - photoButton: { - width: 75, - height: 75, - marginRight: 8, - borderWidth: 1, - borderRadius: 16, - }, - photo: { - width: 75, - height: 75, - marginRight: 8, - borderRadius: 16, - }, -}) diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx deleted file mode 100644 index ff4350b0c..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -// Not used on Web - -export const PhotoCarouselPicker = (_opts: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - return <></> -} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx new file mode 100644 index 000000000..bdcb0534a --- /dev/null +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' +import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function SelectPhotoBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + + const onPressSelectPhotos = React.useCallback(async () => { + track('Composer:GalleryOpened') + if (!enabled) { + return + } + if (isDesktopWeb) { + const images = await pickImagesFlow( + store, + 4 - selectedPhotos.length, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onSelectPhotos([...selectedPhotos, ...images]) + } else { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + multiple: true, + maxFiles: 4 - selectedPhotos.length, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow( + store, + image.path, + image, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ), + ) + } + onSelectPhotos([...selectedPhotos, ...result]) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestPhotoAccessIfNeeded, + ]) + + return ( + <TouchableOpacity + testID="openGalleryBtn" + onPress={onPressSelectPhotos} + style={[s.pl5, s.pr20]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon={['far', 'image']} + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx index 6aeda33cd..c2a00ce53 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/photos/SelectedPhotos.tsx @@ -4,7 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import Image from 'view/com/util/images/Image' import {colors} from 'lib/styles' -export const SelectedPhoto = ({ +export const SelectedPhotos = ({ selectedPhotos, onSelectPhotos, }: { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index be6150e11..2a40fb518 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,64 +1,222 @@ import React from 'react' import { NativeSyntheticEvent, - StyleProp, + StyleSheet, TextInputSelectionChangeEventData, - TextStyle, } from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {Autocomplete} from './mobile/Autocomplete' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getImageDim} from 'lib/media/manip' +import {cropAndCompressFlow} from 'lib/media/picker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' -export type TextInputRef = PasteInputRef +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - <PasteInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </PasteInput> - ) +interface Selection { + start: number + end: number } + +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + onPhotoPasted, + onSuggestedLinksChanged, + onError, + }: TextInputProps, + ref, + ) => { + const pal = usePalette('default') + const store = useStores() + const textInput = React.useRef<PasteInputRef>(null) + const textInputSelection = React.useRef<Selection>({start: 0, end: 0}) + const theme = useTheme() + + React.useImperativeHandle(ref, () => ({ + focus: () => textInput.current?.focus(), + blur: () => textInput.current?.blur(), + })) + + React.useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onChangeText = React.useCallback( + (newText: string) => { + onTextChanged(newText) + + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix.value) + } else { + autocompleteView.setActive(false) + } + + const ents = extractEntities(newText)?.filter( + ent => ent.type === 'link', + ) + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) + } + }, + [ + onTextChanged, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + ], + ) + + const onPaste = React.useCallback( + async (err: string | undefined, files: PastedFile[]) => { + if (err) { + return onError(cleanError(err)) + } + const uris = files.map(f => f.uri) + const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) + if (imgUri) { + let imgDim + try { + imgDim = await getImageDim(imgUri) + } catch (e) { + imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + } + const finalImgPath = await cropAndCompressFlow( + store, + imgUri, + imgDim, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(finalImgPath) + } + }, + [store, onError, onPhotoPasted], + ) + + const onSelectionChange = React.useCallback( + (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + }, + [textInputSelection], + ) + + const onSelectAutocompleteItem = React.useCallback( + (item: string) => { + onChangeText( + insertMentionAt(text, textInputSelection.current?.start || 0, item), + ) + autocompleteView.setActive(false) + }, + [onChangeText, text, autocompleteView], + ) + + const textDecorated = React.useMemo(() => { + let i = 0 + return detectLinkables(text).map(v => { + if (typeof v === 'string') { + return ( + <Text key={i++} style={[pal.text, styles.textInputFormatting]}> + {v} + </Text> + ) + } else { + return ( + <Text key={i++} style={[pal.link, styles.textInputFormatting]}> + {v.link} + </Text> + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + <PasteInput + testID="composerTextInput" + ref={textInput} + onChangeText={onChangeText} + onPaste={onPaste} + onSelectionChange={onSelectionChange} + placeholder={placeholder} + keyboardAppearance={theme.colorScheme} + style={[pal.text, styles.textInput, styles.textInputFormatting]}> + {textDecorated} + </PasteInput> + <Autocomplete + view={autocompleteView} + onSelect={onSelectAutocompleteItem} + /> + </> + ) + }, +) + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + padding: 5, + marginLeft: 8, + alignSelf: 'flex-start', + }, + textInputFormatting: { + fontSize: 18, + letterSpacing: 0.2, + fontWeight: '400', + lineHeight: 23.4, // 1.3*16 + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 2b610850c..67ef836a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,58 +1,133 @@ 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/styles' - -export type TextInputRef = RNTextInput +import {StyleSheet, View} from 'react-native' +import {useEditor, EditorContent, JSONContent} from '@tiptap/react' +import {Document} from '@tiptap/extension-document' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' +import {Paragraph} from '@tiptap/extension-paragraph' +import {Placeholder} from '@tiptap/extension-placeholder' +import {Text} from '@tiptap/extension-text' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {createSuggestion} from './web/Autocomplete' + +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - <RNTextInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </RNTextInput> - ) +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + // onPhotoPasted, TODO + onSuggestedLinksChanged, + }: // onError, TODO + TextInputProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + Link.configure({ + protocols: ['http', 'https'], + autolink: true, + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + ], + content: text, + autofocus: true, + editable: true, + injectCSS: true, + onUpdate({editor: editorProp}) { + const json = editorProp.getJSON() + const newText = editorJsonToText(json).trim() + onTextChanged(newText) + + const newSuggestedLinks = new Set(editorJsonToLinks(json)) + if (!isEqual(newSuggestedLinks, suggestedLinks)) { + onSuggestedLinksChanged(newSuggestedLinks) + } + }, + }) + + React.useImperativeHandle(ref, () => ({ + focus: () => {}, // TODO + blur: () => {}, // TODO + })) + + return ( + <View style={styles.container}> + <EditorContent editor={editor} /> + </View> + ) + }, +) + +function editorJsonToText(json: JSONContent): string { + let text = '' + if (json.type === 'doc' || json.type === 'paragraph') { + if (json.content?.length) { + for (const node of json.content) { + text += editorJsonToText(node) + } + } + text += '\n' + } else if (json.type === 'text') { + text += json.text || '' + } else if (json.type === 'mention') { + text += json.attrs?.id || '' + } + return text +} + +function editorJsonToLinks(json: JSONContent): string[] { + let links: string[] = [] + if (json.content?.length) { + for (const node of json.content) { + links = links.concat(editorJsonToLinks(node)) + } + } + + const link = json.marks?.find(m => m.type === 'link') + if (link?.attrs?.href) { + links.push(link.attrs.href) + } + + return links } const styles = StyleSheet.create({ - input: { - minHeight: 140, + container: { + flex: 1, + alignSelf: 'flex-start', + padding: 5, + marginLeft: 8, + marginBottom: 10, }, }) diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx new file mode 100644 index 000000000..424a8629f --- /dev/null +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -0,0 +1,75 @@ +import React, {useEffect} from 'react' +import { + Animated, + TouchableOpacity, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export const Autocomplete = observer( + ({ + view, + onSelect, + }: { + view: UserAutocompleteViewModel + onSelect: (item: string) => void + }) => { + const pal = usePalette('default') + const winDim = useWindowDimensions() + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [positionInterp, view.isActive]) + + const topAnimStyle = { + top: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winDim.height, winDim.height / 4], + }), + } + return ( + <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> + {view.suggestions.map(item => ( + <TouchableOpacity + testID="autocompleteButton" + key={item.handle} + style={[pal.border, styles.item]} + onPress={() => onSelect(item.handle)}> + <Text type="md-medium" style={pal.text}> + {item.displayName || item.handle} + <Text type="sm" style={pal.textLight}> + @{item.handle} + </Text> + </Text> + </TouchableOpacity> + ))} + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 16, + paddingHorizontal: 16, + height: 50, + }, +}) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx new file mode 100644 index 000000000..fbe438969 --- /dev/null +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -0,0 +1,157 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' + +interface MentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createSuggestion({ + autocompleteView, +}: { + autocompleteView: UserAutocompleteViewModel +}): Omit<SuggestionOptions, 'editor'> { + return { + async items({query}) { + autocompleteView.setActive(true) + await autocompleteView.setPrefix(query) + return autocompleteView.suggestions.slice(0, 8).map(s => s.handle) + }, + + render: () => { + let component: ReactRenderer<MentionListRef> | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const MentionList = forwardRef<MentionListRef, SuggestionProps>( + (props: SuggestionProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({id: item}) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( + <div className="items"> + {props.items.length ? ( + props.items.map((item, index) => ( + <button + className={`item ${index === selectedIndex ? 'is-selected' : ''}`} + key={index} + onClick={() => selectItem(index)}> + {item} + </button> + )) + ) : ( + <div className="item">No result</div> + )} + </div> + ) + }, +) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts new file mode 100644 index 000000000..75f833e84 --- /dev/null +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -0,0 +1,90 @@ +import {useState, useEffect} from 'react' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {getPostAsQuote} from 'lib/link-meta/bsky' +import {downloadAndResize} from 'lib/media/manip' +import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {ComposerOpts} from 'state/models/shell-ui' + +export function useExternalLinkFetch({ + setQuote, +}: { + setQuote: (opts: ComposerOpts['quote']) => void +}) { + const store = useStores() + const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( + undefined, + ) + + useEffect(() => { + let aborted = false + const cleanup = () => { + aborted = true + } + if (!extLink) { + return cleanup + } + if (!extLink.meta) { + if (isBskyPostUrl(extLink.uri)) { + getPostAsQuote(store, extLink.uri).then( + newQuote => { + if (aborted) { + return + } + setQuote(newQuote) + setExtLink(undefined) + }, + err => { + store.log.error('Failed to fetch post for quote embedding', {err}) + setExtLink(undefined) + }, + ) + } else { + getLinkMeta(store, extLink.uri).then(meta => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: !!meta.image, + meta, + }) + }) + } + return cleanup + } + if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { + console.log('attempting download') + downloadAndResize({ + uri: extLink.meta.image, + width: 2000, + height: 2000, + mode: 'contain', + maxSize: 1000000, + timeout: 15e3, + }) + .catch(() => undefined) + .then(localThumb => { + if (aborted) { + return + } + setExtLink({ + ...extLink, + isLoading: false, // done + localThumb, + }) + }) + return cleanup + } + if (extLink.isLoading) { + setExtLink({ + ...extLink, + isLoading: false, // done + }) + } + return cleanup + }, [store, extLink, setQuote]) + + return {extLink, setExtLink} +} diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index 3c09a6cc2..a24dc4e35 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -27,11 +27,13 @@ import {toNiceDomain} from 'lib/strings/url-helpers' import {useStores, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const {track, screen, identify} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isProcessing, setIsProcessing] = React.useState<boolean>(false) const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE) @@ -220,6 +222,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { autoCapitalize="none" autoCorrect={false} autoFocus + keyboardAppearance={theme.colorScheme} value={inviteCode} onChangeText={setInviteCode} onBlur={onBlurInviteCode} diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 4f994f831..6faf5ff12 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -26,6 +26,7 @@ import {ServiceDescription} from 'state/models/session' import {AccountData} from 'state/models/session' import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' enum Forms { @@ -195,12 +196,7 @@ const ChooseAccountForm = ({ <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <View style={s.p10}> - <UserAvatar - displayName={account.displayName} - handle={account.handle} - avatar={account.aviUrl} - size={30} - /> + <UserAvatar avatar={account.aviUrl} size={30} /> </View> <Text style={styles.accountText}> <Text type="lg-bold" style={pal.text}> @@ -273,6 +269,7 @@ const LoginForm = ({ }) => { const {track} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [identifier, setIdentifier] = useState<string>(initialHandle) const [password, setPassword] = useState<string>('') @@ -383,6 +380,7 @@ const LoginForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={identifier} onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} @@ -400,6 +398,7 @@ const LoginForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} @@ -479,6 +478,7 @@ const ForgotPasswordForm = ({ onEmailSent: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [email, setEmail] = useState<string>('') const {screen} = useAnalytics() @@ -567,6 +567,7 @@ const ForgotPasswordForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} editable={!isProcessing} @@ -630,11 +631,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const {screen} = useAnalytics() - // useEffect(() => { - screen('Signin:SetNewPasswordForm') - // }, [screen]) + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) const [isProcessing, setIsProcessing] = useState<boolean>(false) const [resetCode, setResetCode] = useState<string>('') @@ -692,6 +694,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} autoFocus value={resetCode} onChangeText={setResetCode} @@ -710,6 +713,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 519be7b2e..0795d6d20 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -17,6 +17,7 @@ import {ServiceDescription} from 'state/models/session' import {s} from 'lib/styles' import {makeValidHandle, createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError} from 'lib/strings/errors' @@ -212,6 +213,7 @@ function ProvidedHandleForm({ setCanSave: (v: boolean) => void }) { const pal = usePalette('default') + const theme = useTheme() // events // = @@ -239,6 +241,7 @@ function ProvidedHandleForm({ placeholder="eg alice" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} @@ -283,6 +286,7 @@ function CustomHandleForm({ const pal = usePalette('default') const palSecondary = usePalette('secondary') const palError = usePalette('error') + const theme = useTheme() const [isVerifying, setIsVerifying] = React.useState(false) const [error, setError] = React.useState<string>('') @@ -348,6 +352,7 @@ function CustomHandleForm({ placeholder="eg alice.com" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index de29e728d..62fa9f386 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -12,13 +12,16 @@ 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 {useTheme} from 'lib/ThemeContext' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' +import {resetToTab} from '../../../Navigation' export const snapPoints = ['60%'] export function Component({}: {}) { const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [confirmCode, setConfirmCode] = React.useState<string>('') @@ -46,7 +49,7 @@ export function Component({}: {}) { token: confirmCode, }) Toast.show('Your account has been deleted') - store.nav.tab.fixedTabReset() + resetToTab('HomeTab') store.session.clear() store.shell.closeModal() } catch (e: any) { @@ -117,6 +120,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} placeholder="Confirmation code" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} value={confirmCode} onChangeText={setConfirmCode} /> @@ -127,6 +131,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text]} placeholder="Password" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 121831ada..6eb21d17d 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -20,6 +20,7 @@ import {compressIfNeeded} from 'lib/media/manip' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError, isNetworkError} from 'lib/strings/errors' @@ -35,6 +36,7 @@ export function Component({ const store = useStores() const [error, setError] = useState<string>('') const pal = usePalette('default') + const theme = useTheme() const {track} = useAnalytics() const [isProcessing, setProcessing] = useState<boolean>(false) @@ -133,9 +135,7 @@ export function Component({ <UserAvatar size={80} avatar={userAvatar} - handle={profileView.handle} onSelectNewAvatar={onSelectNewAvatar} - displayName={profileView.displayName} /> </View> </View> @@ -160,6 +160,7 @@ export function Component({ style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} + keyboardAppearance={theme.colorScheme} multiline value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 5a9a4cfed..1d352cec9 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -8,12 +8,14 @@ import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] export function Component({onSelect}: {onSelect: (url: string) => void}) { + const theme = useTheme() const store = useStores() const [customUrl, setCustomUrl] = useState<string>('') @@ -74,6 +76,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { autoCapitalize="none" autoComplete="off" autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} /> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index b21681c7f..306686557 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,6 +5,7 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' import {PickedMedia} from 'lib/media/types' +import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -54,7 +55,7 @@ export function Component({ mediaType: 'photo', path: dataUri, mime: 'image/jpeg', - size: Math.round((dataUri.length * 3) / 4), // very rough estimate + size: getDataUriSize(dataUri), width: DIMS[as].width, height: DIMS[as].height, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index acd00a67d..1c2299b03 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -24,7 +24,7 @@ import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' -import {Link} from '../util/Link' +import {Link, TextLink} from '../util/Link' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' @@ -186,15 +186,12 @@ export const FeedItem = observer(function FeedItem({ authors={authors} /> <View style={styles.meta}> - <Link + <TextLink key={authors[0].href} - style={styles.metaItem} + style={[pal.text, s.bold, styles.metaItem]} href={authors[0].href} - title={`@${authors[0].handle}`}> - <Text style={[pal.text, s.bold]} lineHeight={1.2}> - {authors[0].displayName || authors[0].handle} - </Text> - </Link> + text={authors[0].displayName || authors[0].handle} + /> {authors.length > 1 ? ( <> <Text style={[styles.metaItem, pal.text]}>and</Text> @@ -256,13 +253,9 @@ function CondensedAuthorsList({ <Link style={s.mr5} href={authors[0].href} - title={`@${authors[0].handle}`}> - <UserAvatar - size={35} - displayName={authors[0].displayName} - handle={authors[0].handle} - avatar={authors[0].avatar} - /> + title={`@${authors[0].handle}`} + asAnchor> + <UserAvatar size={35} avatar={authors[0].avatar} /> </Link> </View> ) @@ -271,12 +264,7 @@ function CondensedAuthorsList({ <View style={styles.avis}> {authors.slice(0, MAX_AUTHORS).map(author => ( <View key={author.href} style={s.mr5}> - <UserAvatar - size={35} - displayName={author.displayName} - handle={author.handle} - avatar={author.avatar} - /> + <UserAvatar size={35} avatar={author.avatar} /> </View> ))} {authors.length > MAX_AUTHORS ? ( @@ -326,14 +314,10 @@ function ExpandedAuthorsList({ key={author.href} href={author.href} title={author.displayName || author.handle} - style={styles.expandedAuthor}> + style={styles.expandedAuthor} + asAnchor> <View style={styles.expandedAuthorAvi}> - <UserAvatar - size={35} - displayName={author.displayName} - handle={author.handle} - avatar={author.avatar} - /> + <UserAvatar size={35} avatar={author.avatar} /> </View> <View style={s.flex1}> <Text diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 646d4b276..f84593db8 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,28 +1,43 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, } from 'state/models/post-thread-view' import {PostThreadItem} from './PostThreadItem' +import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' + +const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} +const BOTTOM_BORDER = { + _reactKey: '__bottom_border__', + _isHighlightedPost: false, +} +type YieldedItem = PostThreadViewPostModel | typeof REPLY_PROMPT export const PostThread = observer(function PostThread({ uri, view, + onPressReply, }: { uri: string view: PostThreadViewModel + onPressReply: () => void }) { + const pal = usePalette('default') const ref = useRef<FlatList>(null) const [isRefreshing, setIsRefreshing] = React.useState(false) - const posts = React.useMemo( - () => (view.thread ? Array.from(flattenThread(view.thread)) : []), - [view.thread], - ) + const posts = React.useMemo(() => { + if (view.thread) { + return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) + } + return [] + }, [view.thread]) // events // = @@ -58,6 +73,23 @@ export const PostThread = observer(function PostThread({ }, [ref], ) + const renderItem = React.useCallback( + ({item}: {item: YieldedItem}) => { + if (item === REPLY_PROMPT) { + return <ComposePrompt onPressCompose={onPressReply} /> + } else if (item === BOTTOM_BORDER) { + // HACK + // due to some complexities with how flatlist works, this is the easiest way + // I could find to get a border positioned directly under the last item + // -prf + return <View style={[styles.bottomBorder, pal.border]} /> + } else if (item instanceof PostThreadViewPostModel) { + return <PostThreadItem item={item} onPostReply={onRefresh} /> + } + return <></> + }, + [onRefresh, onPressReply, pal], + ) // loading // = @@ -81,9 +113,6 @@ export const PostThread = observer(function PostThread({ // loaded // = - const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( - <PostThreadItem item={item} onPostReply={onRefresh} /> - ) return ( <FlatList ref={ref} @@ -104,7 +133,7 @@ export const PostThread = observer(function PostThread({ function* flattenThread( post: PostThreadViewPostModel, isAscending = false, -): Generator<PostThreadViewPostModel, void> { +): Generator<YieldedItem, void> { if (post.parent) { if ('notFound' in post.parent && post.parent.notFound) { // TODO render not found @@ -113,6 +142,9 @@ function* flattenThread( } } yield post + if (isDesktopWeb && post._isHighlightedPost) { + yield REPLY_PROMPT + } if (post.replies?.length) { for (const reply of post.replies) { if ('notFound' in reply && reply.notFound) { @@ -125,3 +157,9 @@ function* flattenThread( post._hasMore = true } } + +const styles = StyleSheet.create({ + bottomBorder: { + borderBottomWidth: 1, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 1413148a9..17c7943d9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -135,13 +135,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -299,13 +294,8 @@ export const PostThreadItem = observer(function PostThreadItem({ )} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -313,6 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7b4161afc..ac7d1cc55 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -150,13 +150,8 @@ export const Post = observer(function Post({ {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -164,6 +159,7 @@ export const Post = observer(function Post({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 5751faa68..8f57900b5 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -7,6 +7,7 @@ import { StyleSheet, ViewStyle, } from 'react-native' +import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {CenteredView, FlatList} from '../util/Views' @@ -18,10 +19,10 @@ import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_FEED_ITEM = {_reactKey: '__error__'} @@ -47,9 +48,9 @@ export const Feed = observer(function Feed({ }) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const navigation = useNavigation<NavigationProp>() const data = React.useMemo(() => { let feedItems: any[] = [] @@ -112,7 +113,12 @@ export const Feed = observer(function Feed({ <Button type="inverted" style={styles.emptyBtn} - onPress={() => store.nav.navigate('/search')}> + onPress={ + () => + navigation.navigate( + 'SearchTab', + ) /* TODO make sure it goes to root of the tab */ + }> <Text type="lg-medium" style={palInverted.text}> Find accounts </Text> @@ -134,7 +140,7 @@ export const Feed = observer(function Feed({ } return <FeedItem item={item} showFollowBtn={showPostFollowBtn} /> }, - [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav], + [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation], ) const FeedFooter = React.useCallback( diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 8b9a6eb2c..ec8feb664 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -9,7 +9,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {FeedItemModel} from 'state/models/feed-view' -import {Link} from '../util/Link' +import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -169,19 +169,24 @@ export const FeedItem = observer(function ({ lineHeight={1.2} numberOfLines={1}> Reposted by{' '} - {item.reasonRepost.by.displayName || item.reasonRepost.by.handle} + <DesktopWebTextLink + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={ + item.reasonRepost.by.displayName || + item.reasonRepost.by.handle + } + href={`/profile/${item.reasonRepost.by.handle}`} + /> </Text> </Link> )} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={item.post.author.handle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={item.post.author.handle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -189,6 +194,7 @@ export const FeedItem = observer(function ({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} showFollowBtn={showFollowBtn} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 3c487b70f..087536c36 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -37,15 +37,11 @@ export function ProfileCard({ ]} href={`/profile/${handle}`} title={handle} - noFeedback> + noFeedback + asAnchor> <View style={styles.layout}> <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={displayName} - handle={handle} - avatar={avatar} - /> + <UserAvatar size={40} avatar={avatar} /> </View> <View style={styles.layoutContent}> <Text diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 519d224ea..b061aac41 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -7,18 +7,18 @@ import { TouchableWithoutFeedback, View, } from 'react-native' -import LinearGradient from 'react-native-linear-gradient' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' import {BlurView} from '../util/BlurView' import {ProfileViewModel} from 'state/models/profile-view' import {useStores} from 'state/index' import {ProfileImageLightbox} 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 {s, colors} from 'lib/styles' import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -28,6 +28,8 @@ import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} @@ -40,16 +42,17 @@ export const ProfileHeader = observer(function ProfileHeader({ }) { const pal = usePalette('default') const store = useStores() + const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const onPressBack = () => { - store.nav.tab.goBack() - } - const onPressAvi = () => { + const onPressBack = React.useCallback(() => { + navigation.goBack() + }, [navigation]) + const onPressAvi = React.useCallback(() => { if (view.avatar) { store.shell.openLightbox(new ProfileImageLightbox(view)) } - } - const onPressToggleFollow = () => { + }, [store, view]) + const onPressToggleFollow = React.useCallback(() => { view?.toggleFollowing().then( () => { Toast.show( @@ -60,28 +63,28 @@ export const ProfileHeader = observer(function ProfileHeader({ }, err => store.log.error('Failed to toggle follow', err), ) - } - const onPressEditProfile = () => { + }, [view, store]) + const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal({ name: 'edit-profile', profileView: view, onUpdate: onRefreshAll, }) - } - const onPressFollowers = () => { + }, [track, store, view, onRefreshAll]) + const onPressFollowers = React.useCallback(() => { track('ProfileHeader:FollowersButtonClicked') - store.nav.navigate(`/profile/${view.handle}/followers`) - } - const onPressFollows = () => { + navigation.push('ProfileFollowers', {name: view.handle}) + }, [track, navigation, view]) + const onPressFollows = React.useCallback(() => { track('ProfileHeader:FollowsButtonClicked') - store.nav.navigate(`/profile/${view.handle}/follows`) - } - const onPressShare = () => { + navigation.push('ProfileFollows', {name: view.handle}) + }, [track, navigation, view]) + const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) - } - const onPressMuteAccount = async () => { + }, [track, view]) + const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { await view.muteAccount() @@ -90,8 +93,8 @@ export const ProfileHeader = observer(function ProfileHeader({ store.log.error('Failed to mute account', e) Toast.show(`There was an issue! ${e.toString()}`) } - } - const onPressUnmuteAccount = async () => { + }, [track, view, store]) + const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { await view.unmuteAccount() @@ -100,14 +103,14 @@ export const ProfileHeader = observer(function ProfileHeader({ store.log.error('Failed to unmute account', e) Toast.show(`There was an issue! ${e.toString()}`) } - } - const onPressReportAccount = () => { + }, [track, view, store]) + const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ name: 'report-account', did: view.did, }) - } + }, [track, store, view]) // loading // = @@ -189,23 +192,15 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : ( <TouchableOpacity testID="profileHeaderToggleFollowButton" - onPress={onPressToggleFollow}> - <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]} - /> - <Text type="button" style={[s.white, s.bold]}> - Follow - </Text> - </LinearGradient> + onPress={onPressToggleFollow} + style={[styles.btn, styles.primaryBtn]}> + <FontAwesomeIcon + icon="plus" + style={[s.white as FontAwesomeIconStyle, s.mr5]} + /> + <Text type="button" style={[s.white, s.bold]}> + Follow + </Text> </TouchableOpacity> )} </> @@ -287,24 +282,21 @@ export const ProfileHeader = observer(function ProfileHeader({ </View> ) : undefined} </View> - <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}> - <View style={styles.backBtnWrapper}> - <BlurView style={styles.backBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> - </BlurView> - </View> - </TouchableWithoutFeedback> + {!isDesktopWeb && ( + <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}> + <View style={styles.backBtnWrapper}> + <BlurView style={styles.backBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> + </BlurView> + </View> + </TouchableWithoutFeedback> + )} <TouchableWithoutFeedback testID="profileHeaderAviButton" onPress={onPressAvi}> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <UserAvatar - size={80} - handle={view.handle} - displayName={view.displayName} - avatar={view.avatar} - /> + <UserAvatar size={80} avatar={view.avatar} /> </View> </TouchableWithoutFeedback> </View> @@ -350,7 +342,8 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginBottom: 12, }, - gradientBtn: { + primaryBtn: { + backgroundColor: colors.blue3, paddingHorizontal: 24, paddingVertical: 6, }, diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 017265f48..c7374e195 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' +import {CenteredView} from './Views' interface Props { children?: ReactNode @@ -27,11 +28,13 @@ export class ErrorBoundary extends Component<Props, State> { public render() { if (this.state.hasError) { return ( - <ErrorScreen - title="Oh no!" - message="There was an unexpected issue in the application. Please let us know if this happened to you!" - details={this.state.error.toString()} - /> + <CenteredView> + <ErrorScreen + title="Oh no!" + message="There was an unexpected issue in the application. Please let us know if this happened to you!" + details={this.state.error.toString()} + /> + </CenteredView> ) } diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index bdc447937..cee4d4136 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -2,6 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { Linking, + GestureResponderEvent, + Platform, StyleProp, TouchableWithoutFeedback, TouchableOpacity, @@ -9,10 +11,22 @@ import { View, ViewStyle, } from 'react-native' +import { + useLinkProps, + useNavigation, + StackActions, +} from '@react-navigation/native' import {Text} from './text/Text' import {TypographyVariant} from 'lib/ThemeContext' +import {NavigationProp} from 'lib/routes/types' +import {router} from '../../../routes' import {useStores, RootStoreModel} from 'state/index' import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers' +import {isDesktopWeb} from 'platform/detection' + +type Event = + | React.MouseEvent<HTMLAnchorElement, MouseEvent> + | GestureResponderEvent export const Link = observer(function Link({ style, @@ -20,30 +34,33 @@ export const Link = observer(function Link({ title, children, noFeedback, + asAnchor, }: { style?: StyleProp<ViewStyle> href?: string title?: string children?: React.ReactNode noFeedback?: boolean + asAnchor?: boolean }) { const store = useStores() - const onPress = () => { - if (href) { - handleLink(store, href, false) - } - } - const onLongPress = () => { - if (href) { - handleLink(store, href, true) - } - } + const navigation = useNavigation<NavigationProp>() + + const onPress = React.useCallback( + (e?: Event) => { + if (typeof href === 'string') { + return onPressInner(store, navigation, href, e) + } + }, + [store, navigation, href], + ) + if (noFeedback) { return ( <TouchableWithoutFeedback onPress={onPress} - onLongPress={onLongPress} - delayPressIn={50}> + // @ts-ignore web only -prf + href={asAnchor ? href : undefined}> <View style={style}> {children ? children : <Text>{title || 'link'}</Text>} </View> @@ -52,10 +69,10 @@ export const Link = observer(function Link({ } return ( <TouchableOpacity + style={style} onPress={onPress} - onLongPress={onLongPress} - delayPressIn={50} - style={style}> + // @ts-ignore web only -prf + href={asAnchor ? href : undefined}> {children ? children : <Text>{title || 'link'}</Text>} </TouchableOpacity> ) @@ -66,35 +83,123 @@ export const TextLink = observer(function TextLink({ style, href, text, + numberOfLines, + lineHeight, }: { type?: TypographyVariant style?: StyleProp<TextStyle> href: string - text: string + text: string | JSX.Element + numberOfLines?: number + lineHeight?: number }) { + const {...props} = useLinkProps({to: href}) const store = useStores() - const onPress = () => { - handleLink(store, href, false) - } - const onLongPress = () => { - handleLink(store, href, true) + const navigation = useNavigation<NavigationProp>() + + props.onPress = React.useCallback( + (e?: Event) => { + return onPressInner(store, navigation, href, e) + }, + [store, navigation, href], + ) + + return ( + <Text + type={type} + style={style} + numberOfLines={numberOfLines} + lineHeight={lineHeight} + {...props}> + {text} + </Text> + ) +}) + +/** + * Only acts as a link on desktop web + */ +export const DesktopWebTextLink = observer(function DesktopWebTextLink({ + type = 'md', + style, + href, + text, + numberOfLines, + lineHeight, +}: { + type?: TypographyVariant + style?: StyleProp<TextStyle> + href: string + text: string | JSX.Element + numberOfLines?: number + lineHeight?: number +}) { + if (isDesktopWeb) { + return ( + <TextLink + type={type} + style={style} + href={href} + text={text} + numberOfLines={numberOfLines} + lineHeight={lineHeight} + /> + ) } return ( - <Text type={type} style={style} onPress={onPress} onLongPress={onLongPress}> + <Text + type={type} + style={style} + numberOfLines={numberOfLines} + lineHeight={lineHeight}> {text} </Text> ) }) -function handleLink(store: RootStoreModel, href: string, longPress: boolean) { - href = convertBskyAppUrlIfNeeded(href) - if (href.startsWith('http')) { - Linking.openURL(href) - } else if (longPress) { - store.shell.closeModal() // close any active modals - store.nav.newTab(href) - } else { - store.shell.closeModal() // close any active modals - store.nav.navigate(href) +// NOTE +// we can't use the onPress given by useLinkProps because it will +// match most paths to the HomeTab routes while we actually want to +// preserve the tab the app is currently in +// +// we also have some additional behaviors - closing the current modal, +// converting bsky urls, and opening http/s links in the system browser +// +// this method copies from the onPress implementation but adds our +// needed customizations +// -prf +function onPressInner( + store: RootStoreModel, + navigation: NavigationProp, + href: string, + e?: Event, +) { + let shouldHandle = false + + if (Platform.OS !== 'web' || !e) { + shouldHandle = e ? !e.defaultPrevented : true + } else if ( + !e.defaultPrevented && // onPress prevented default + // @ts-ignore Web only -prf + !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys + // @ts-ignore Web only -prf + (e.button == null || e.button === 0) && // ignore everything but left clicks + // @ts-ignore Web only -prf + [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. + ) { + e.preventDefault() + shouldHandle = true + } + + if (shouldHandle) { + href = convertBskyAppUrlIfNeeded(href) + if (href.startsWith('http')) { + Linking.openURL(href) + } else { + store.shell.closeModal() // close any active modals + + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } } } diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index 182c1ba5d..ba33f92a7 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -2,6 +2,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {Text} from './text/Text' import {usePalette} from 'lib/hooks/usePalette' +import {UpIcon} from 'lib/icons' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} @@ -9,10 +10,11 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { const pal = usePalette('default') return ( <TouchableOpacity - style={[pal.view, styles.loadLatest]} + style={[pal.view, pal.borderDark, styles.loadLatest]} onPress={onPress} hitSlop={HITSLOP}> <Text type="md-bold" style={pal.text}> + <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> Load new posts </Text> </TouchableOpacity> @@ -29,8 +31,15 @@ const styles = StyleSheet.create({ shadowOpacity: 0.2, shadowOffset: {width: 0, height: 2}, shadowRadius: 4, - paddingHorizontal: 24, + paddingLeft: 20, + paddingRight: 24, paddingVertical: 10, borderRadius: 30, + borderWidth: 1, + }, + icon: { + position: 'relative', + top: 2, + marginRight: 5, }, }) diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx index 76b71a53d..f98a66b76 100644 --- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx +++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx @@ -25,6 +25,7 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { authorAvatar={quote.author.avatar} authorHandle={quote.author.handle} authorDisplayName={quote.author.displayName} + postHref={itemHref} timestamp={quote.indexedAt} /> <Text type="post-text" style={pal.text} numberOfLines={6}> diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index cde5a3e92..0bb402100 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {Text} from './text/Text' +import {DesktopWebTextLink} from './Link' import {ago} from 'lib/strings/time' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' @@ -12,6 +13,7 @@ interface PostMetaOpts { authorAvatar?: string authorHandle: string authorDisplayName: string | undefined + postHref: string timestamp: string did?: string declarationCid?: string @@ -20,8 +22,8 @@ interface PostMetaOpts { export const PostMeta = observer(function (opts: PostMetaOpts) { const pal = usePalette('default') - let displayName = opts.authorDisplayName || opts.authorHandle - let handle = opts.authorHandle + const displayName = opts.authorDisplayName || opts.authorHandle + const handle = opts.authorHandle const store = useStores() const isMe = opts.did === store.me.did const isFollowing = @@ -41,31 +43,35 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { ) { // two-liner with follow button return ( - <View style={[styles.metaTwoLine]}> + <View style={styles.metaTwoLine}> <View> - <Text - type="lg-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {displayName}{' '} - <Text + <View style={styles.metaTwoLineTop}> + <DesktopWebTextLink + type="lg-bold" + style={pal.text} + numberOfLines={1} + lineHeight={1.2} + text={displayName} + href={`/profile/${opts.authorHandle}`} + /> + <Text type="md" style={pal.textLight} lineHeight={1.2}> + · + </Text> + <DesktopWebTextLink type="md" style={[styles.metaItem, pal.textLight]} - lineHeight={1.2}> - · {ago(opts.timestamp)} - </Text> - </Text> - <Text + lineHeight={1.2} + text={ago(opts.timestamp)} + href={opts.postHref} + /> + </View> + <DesktopWebTextLink type="md" style={[styles.metaItem, pal.textLight]} - lineHeight={1.2}> - {handle ? ( - <Text type="md" style={[pal.textLight]}> - @{handle} - </Text> - ) : undefined} - </Text> + lineHeight={1.2} + text={`@${handle}`} + href={`/profile/${opts.authorHandle}`} + /> </View> <View> @@ -84,31 +90,36 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <View style={styles.meta}> {typeof opts.authorAvatar !== 'undefined' && ( <View style={[styles.metaItem, styles.avatar]}> - <UserAvatar - avatar={opts.authorAvatar} - handle={opts.authorHandle} - displayName={opts.authorDisplayName} - size={16} - /> + <UserAvatar avatar={opts.authorAvatar} size={16} /> </View> )} <View style={[styles.metaItem, styles.maxWidth]}> - <Text + <DesktopWebTextLink type="lg-bold" - style={[pal.text]} + style={pal.text} numberOfLines={1} - lineHeight={1.2}> - {displayName} - {handle ? ( - <Text type="md" style={[pal.textLight]}> - {handle} - </Text> - ) : undefined} - </Text> + lineHeight={1.2} + text={ + <> + {displayName} + <Text type="md" style={[pal.textLight]}> + {handle} + </Text> + </> + } + href={`/profile/${opts.authorHandle}`} + /> </View> - <Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}> - · {ago(opts.timestamp)} + <Text type="md" style={pal.textLight} lineHeight={1.2}> + · </Text> + <DesktopWebTextLink + type="md" + style={[styles.metaItem, pal.textLight]} + lineHeight={1.2} + text={ago(opts.timestamp)} + href={opts.postHref} + /> </View> ) }) @@ -125,6 +136,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingBottom: 2, }, + metaTwoLineTop: { + flexDirection: 'row', + alignItems: 'baseline', + }, metaItem: { paddingRight: 5, }, diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx index d8573bd56..539a71ecf 100644 --- a/src/view/com/util/PostMuted.tsx +++ b/src/view/com/util/PostMuted.tsx @@ -7,7 +7,7 @@ import {Text} from './text/Text' export function PostMutedWrapper({ isMuted, children, -}: React.PropsWithChildren<{isMuted: boolean}>) { +}: React.PropsWithChildren<{isMuted?: boolean}>) { const pal = usePalette('default') const [override, setOverride] = React.useState(false) if (!isMuted || override) { diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d0d2c273b..2e0632521 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg' +import Svg, {Circle, Path} 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' @@ -11,52 +11,48 @@ import { PickedMedia, } from '../../../lib/media/picker' import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' + usePhotoLibraryPermission, + useCameraPermission, +} from 'lib/hooks/usePermissions' import {useStores} from 'state/index' -import {colors, gradients} from 'lib/styles' +import {colors} from 'lib/styles' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' +function DefaultAvatar({size}: {size: number}) { + return ( + <Svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="none"> + <Circle cx="12" cy="12" r="12" fill="#0070ff" /> + <Circle cx="12" cy="9.5" r="3.5" fill="#fff" /> + <Path + strokeLinecap="round" + strokeLinejoin="round" + fill="#fff" + d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z" + /> + </Svg> + ) +} + export function UserAvatar({ size, - handle, avatar, - displayName, onSelectNewAvatar, }: { size: number - handle: string - displayName: string | undefined avatar?: string | null onSelectNewAvatar?: (img: PickedMedia | null) => void }) { const store = useStores() const pal = usePalette('default') - const initials = getInitials(displayName || handle) - - const renderSvg = (svgSize: number, svgInitials: string) => ( - <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> - <Defs> - <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> - <Stop offset="0" stopColor={gradients.blue.start} stopOpacity="1" /> - <Stop offset="1" stopColor={gradients.blue.end} stopOpacity="1" /> - </LinearGradient> - </Defs> - <Circle cx="50" cy="50" r="50" fill="url(#grad)" /> - <Text - fill="white" - fontSize="50" - fontWeight="bold" - x="50" - y="67" - textAnchor="middle"> - {svgInitials} - </Text> - </Svg> - ) + const {requestCameraAccessIfNeeded} = useCameraPermission() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const dropdownItems = [ !isWeb && { @@ -124,7 +120,7 @@ export function UserAvatar({ source={{uri: avatar}} /> ) : ( - renderSvg(size, initials) + <DefaultAvatar size={size} /> )} <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon @@ -141,26 +137,10 @@ export function UserAvatar({ source={{uri: avatar}} /> ) : ( - renderSvg(size, initials) + <DefaultAvatar size={size} /> ) } -function getInitials(str: string): string { - const tokens = str - .toLowerCase() - .replace(/[^a-z]/g, '') - .split(' ') - .filter(Boolean) - .map(v => v.trim()) - if (tokens.length >= 2 && tokens[0][0] && tokens[0][1]) { - return tokens[0][0].toUpperCase() + tokens[1][0].toUpperCase() - } - if (tokens.length === 1 && tokens[0][0]) { - return tokens[0][0].toUpperCase() - } - return 'X' -} - const styles = StyleSheet.create({ editButtonContainer: { position: 'absolute', diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 16e05311b..d89de9158 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,10 +1,10 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' +import Svg, {Rect} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import Image from 'view/com/util/images/Image' -import {colors, gradients} from 'lib/styles' +import {colors} from 'lib/styles' import { openCamera, openCropper, @@ -13,9 +13,9 @@ import { } from '../../../lib/media/picker' import {useStores} from 'state/index' import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' + usePhotoLibraryPermission, + useCameraPermission, +} from 'lib/hooks/usePermissions' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -29,6 +29,9 @@ export function UserBanner({ }) { const store = useStores() const pal = usePalette('default') + const {requestCameraAccessIfNeeded} = useCameraPermission() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const dropdownItems = [ !isWeb && { label: 'Camera', @@ -80,19 +83,8 @@ export function UserBanner({ ] const renderSvg = () => ( - <Svg width="100%" height="150" viewBox="50 0 200 100"> - <Defs> - <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> - <Stop - offset="0" - stopColor={gradients.blueDark.start} - stopOpacity="1" - /> - <Stop offset="1" stopColor={gradients.blueDark.end} stopOpacity="1" /> - </LinearGradient> - </Defs> - <Rect x="0" y="0" width="400" height="100" fill="url(#grad)" /> - <Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" /> + <Svg width="100%" height="150" viewBox="0 0 400 100"> + <Rect x="0" y="0" width="400" height="100" fill="#0070ff" /> </Svg> ) diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index 84170b3bf..4753c9b01 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,7 +1,7 @@ import React, {useState, useEffect} from 'react' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' import {StyleProp, StyleSheet, TextStyle} from 'react-native' -import {Link} from './Link' +import {DesktopWebTextLink} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' import {useStores} from 'state/index' @@ -14,7 +14,6 @@ export function UserInfoText({ failed, prefix, style, - asLink, }: { type?: TypographyVariant did: string @@ -23,7 +22,6 @@ export function UserInfoText({ failed?: string prefix?: string style?: StyleProp<TextStyle> - asLink?: boolean }) { attr = attr || 'handle' failed = failed || 'user' @@ -64,9 +62,14 @@ export function UserInfoText({ ) } else if (profile) { inner = ( - <Text type={type} style={style} lineHeight={1.2} numberOfLines={1}>{`${ - prefix || '' - }${profile[attr] || profile.handle}`}</Text> + <DesktopWebTextLink + type={type} + style={style} + lineHeight={1.2} + numberOfLines={1} + href={`/profile/${profile.handle}`} + text={`${prefix || ''}${profile[attr] || profile.handle}`} + /> ) } else { inner = ( @@ -78,17 +81,6 @@ export function UserInfoText({ ) } - if (asLink) { - const title = profile?.displayName || profile?.handle || 'User' - return ( - <Link - href={`/profile/${profile?.handle ? profile.handle : did}`} - title={title}> - {inner} - </Link> - ) - } - return inner } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index ffd1b1d63..a99282512 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -2,17 +2,19 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' import {UserAvatar} from './UserAvatar' import {Text} from './text/Text' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnalytics} from 'lib/analytics' -import {isDesktopWeb} from '../../../platform/detection' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const ViewHeader = observer(function ViewHeader({ +export const ViewHeader = observer(function ({ title, canGoBack, hideOnScroll, @@ -23,50 +25,55 @@ export const ViewHeader = observer(function ViewHeader({ }) { const pal = usePalette('default') const store = useStores() + const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const onPressBack = () => { - store.nav.tab.goBack() - } - const onPressMenu = () => { + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressMenu = React.useCallback(() => { track('ViewHeader:MenuButtonClicked') - store.shell.setMainMenuOpen(true) - } - if (typeof canGoBack === 'undefined') { - canGoBack = store.nav.tab.canGoBack - } + store.shell.openDrawer() + }, [track, store]) + if (isDesktopWeb) { return <></> + } else { + if (typeof canGoBack === 'undefined') { + canGoBack = navigation.canGoBack() + } + + return ( + <Container hideOnScroll={hideOnScroll || false}> + <TouchableOpacity + testID="viewHeaderBackOrMenuBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide}> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : ( + <UserAvatar size={30} avatar={store.me.avatar} /> + )} + </TouchableOpacity> + <View style={styles.titleContainer} pointerEvents="none"> + <Text type="title" style={[pal.text, styles.title]}> + {title} + </Text> + </View> + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + </Container> + ) } - return ( - <Container hideOnScroll={hideOnScroll || false}> - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={canGoBack ? onPressBack : onPressMenu} - hitSlop={BACK_HITSLOP} - style={canGoBack ? styles.backBtn : styles.backBtnWide}> - {canGoBack ? ( - <FontAwesomeIcon - size={18} - icon="angle-left" - style={[styles.backIcon, pal.text]} - /> - ) : ( - <UserAvatar - size={30} - handle={store.me.handle} - displayName={store.me.displayName} - avatar={store.me.avatar} - /> - )} - </TouchableOpacity> - <View style={styles.titleContainer} pointerEvents="none"> - <Text type="title" style={[pal.text, styles.title]}> - {title} - </Text> - </View> - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> - </Container> - ) }) const Container = observer( @@ -119,8 +126,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, - paddingTop: 6, - paddingBottom: 6, + paddingVertical: 6, }, headerFloating: { position: 'absolute', diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 8b5adaa04..9a43697b5 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -23,7 +23,6 @@ import { ViewProps, } from 'react-native' import {addStyle, colors} from 'lib/styles' -import {DESKTOP_HEADER_HEIGHT} from 'lib/constants' export function CenteredView({ style, @@ -73,14 +72,14 @@ export const ScrollView = React.forwardRef(function ( const styles = StyleSheet.create({ container: { width: '100%', - maxWidth: 550, + maxWidth: 600, marginLeft: 'auto', marginRight: 'auto', }, containerScroll: { width: '100%', - height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`, - maxWidth: 550, + minHeight: '100vh', + maxWidth: 600, marginLeft: 'auto', marginRight: 'auto', }, diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index ac83d1a54..d6ae800c6 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -17,7 +17,6 @@ import {Button, ButtonType} from './Button' import {colors} from 'lib/styles' import {toShareUrl} from 'lib/strings/url-helpers' import {useStores} from 'state/index' -import {TABS_ENABLED} from 'lib/build-flags' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' @@ -138,15 +137,6 @@ export function PostDropdownBtn({ const store = useStores() const dropdownItems: DropdownItem[] = [ - TABS_ENABLED - ? { - icon: ['far', 'clone'], - label: 'Open in new tab', - onPress() { - store.nav.newTab(itemHref) - }, - } - : undefined, { icon: 'language', label: 'Translate...', diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 57a875cd3..d6b2bb119 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -41,6 +41,9 @@ export function RadioButton({ 'secondary-light': { borderColor: theme.palette.secondary.border, }, + default: { + borderColor: theme.palette.default.border, + }, 'default-light': { borderColor: theme.palette.default.border, }, @@ -69,6 +72,9 @@ export function RadioButton({ 'secondary-light': { backgroundColor: theme.palette.secondary.background, }, + default: { + backgroundColor: theme.palette.primary.background, + }, 'default-light': { backgroundColor: theme.palette.primary.background, }, @@ -103,6 +109,10 @@ export function RadioButton({ color: theme.palette.secondary.textInverted, fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, }, + default: { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, 'default-light': { color: theme.palette.default.text, fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 005d1165e..a6e0ba3fe 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -42,6 +42,9 @@ export function ToggleButton({ 'secondary-light': { borderColor: theme.palette.secondary.border, }, + default: { + borderColor: theme.palette.default.border, + }, 'default-light': { borderColor: theme.palette.default.border, }, @@ -77,6 +80,11 @@ export function ToggleButton({ backgroundColor: theme.palette.secondary.background, opacity: isSelected ? 1 : 0.5, }, + default: { + backgroundColor: isSelected + ? theme.palette.primary.background + : colors.gray3, + }, 'default-light': { backgroundColor: isSelected ? theme.palette.primary.background @@ -113,6 +121,10 @@ export function ToggleButton({ color: theme.palette.secondary.textInverted, fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, }, + default: { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, 'default-light': { color: theme.palette.default.text, fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, |