Move to expo and react-navigation (#288)

* WIP - adding expo

* WIP - adding expo 2

* Fix tsc

* Finish adding expo

* Disable the 'require cycle' warning

* Tweak plist

* Modify some dependency versions to make expo happy

* Fix icon fill

* Get Web compiling for expo

* 1.7

* Switch to react-navigation in expo2 (#287)

* WIP Switch to react-navigation

* WIP Switch to react-navigation 2

* WIP Switch to react-navigation 3

* Convert all screens to react navigation

* Update BottomBar for react navigation

* Update mobile menu to be react-native drawer

* Fixes to drawer and bottombar

* Factor out some helpers

* Replace the navigation model with react-navigation

* Restructure the shell folder and fix the header positioning

* Restore the error boundary

* Fix tsc

* Implement not-found page

* Remove react-native-gesture-handler (no longer used)

* Handle notifee card presses

* Handle all navigations from the state layer

* Fix drawer behaviors

* Fix two linking issues

* Switch to our react-native-progress fork to fix an svg rendering issue

* Get Web working with react-navigation

* Refactor routes and navigation for a bit more clarity

* Remove dead code

* Rework Web shell to left/right nav to make this easier

* Fix ViewHeader for desktop web

* Hide profileheader back btn on desktop web

* Move the compose button to the left nav

* Implement reply prompt in threads for desktop web

* Composer refactors

* Factor out all platform-specific text input behaviors from the composer

* Small fix

* Update the web build to use tiptap for the composer

* Tune up the mention autocomplete dropdown

* Simplify the default avatar and banner

* Fixes to link cards in web composer

* Fix dropdowns on web

* Tweak load latest on desktop

* Add web beta message and feedback link

* Fix up links in desktop web
This commit is contained in:
Paul Frazee 2023-03-13 16:01:43 -05:00 committed by GitHub
parent 503e03d91e
commit 56cf890deb
222 changed files with 8705 additions and 6338 deletions

View file

@ -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 onPressAddLinkCard = React.useCallback(
(uri: string) => {
setExtLink({uri, isLoading: true})
},
[setExtLink],
)
const onPhotoPasted = React.useCallback(
async (uri: string) => {
if (selectedPhotos.length >= 4) {
return
}
}
}
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 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,

View file

@ -75,6 +75,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: 8,
marginTop: 20,
marginBottom: 10,
},
inner: {
padding: 10,

View file

@ -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,
},
})

View file

@ -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}>
&nbsp;@{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,
},
})

View file

@ -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}>
&nbsp;@{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,
},
})

View file

@ -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>
)
}

View file

@ -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,
},
})

View file

@ -1,10 +0,0 @@
import React from 'react'
// Not used on Web
export const PhotoCarouselPicker = (_opts: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
return <></>
}

View file

@ -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>
)
}

View file

@ -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,
}: {

View file

@ -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
},
})

View file

@ -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'
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 type TextInputRef = RNTextInput
interface TextInputProps {
testID: string
innerRef: React.Ref<TextInputRef>
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onSelectionChange?:
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
| undefined
onPaste: (err: string | undefined, uris: string[]) => void
export interface TextInputRef {
focus: () => void
blur: () => 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>
)
interface TextInputProps {
text: string
placeholder: string
suggestedLinks: Set<string>
autocompleteView: UserAutocompleteViewModel
onTextChanged: (v: string) => void
onPhotoPasted: (uri: string) => void
onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void
}
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,
},
})

View file

@ -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}>
&nbsp;@{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,
},
})

View file

@ -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>
)
},
)

View file

@ -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}
}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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))}

View file

@ -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}
/>

View file

@ -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,
})

View file

@ -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

View file

@ -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,
},
})

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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(

View file

@ -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}

View file

@ -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

View file

@ -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,
},

View file

@ -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>
)
}

View file

@ -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} onPress={onPress} onLongPress={onLongPress}>
<Text
type={type}
style={style}
numberOfLines={numberOfLines}
lineHeight={lineHeight}
{...props}>
{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)
/**
* 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}
numberOfLines={numberOfLines}
lineHeight={lineHeight}>
{text}
</Text>
)
})
// 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)))
}
}
}

View file

@ -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,
},
})

View file

@ -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}>

View file

@ -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}>
&nbsp;&middot;&nbsp;
</Text>
<DesktopWebTextLink
type="md"
style={[styles.metaItem, pal.textLight]}
lineHeight={1.2}>
&middot; {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]}>
&nbsp;{handle}
</Text>
) : undefined}
</Text>
lineHeight={1.2}
text={
<>
{displayName}
<Text type="md" style={[pal.textLight]}>
&nbsp;{handle}
</Text>
</>
}
href={`/profile/${opts.authorHandle}`}
/>
</View>
<Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}>
&middot; {ago(opts.timestamp)}
<Text type="md" style={pal.textLight} lineHeight={1.2}>
&middot;&nbsp;
</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,
},

View file

@ -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) {

View file

@ -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',

View file

@ -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>
)

View file

@ -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
}

View file

@ -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',

View file

@ -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',
},

View file

@ -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...',

View file

@ -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,

View file

@ -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,