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,

View file

@ -1,91 +0,0 @@
import React from 'react'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Home} from './screens/Home'
import {Contacts} from './screens/Contacts'
import {Search} from './screens/Search'
import {Notifications} from './screens/Notifications'
import {NotFound} from './screens/NotFound'
import {PostThread} from './screens/PostThread'
import {PostUpvotedBy} from './screens/PostUpvotedBy'
import {PostDownvotedBy} from './screens/PostDownvotedBy'
import {PostRepostedBy} from './screens/PostRepostedBy'
import {Profile} from './screens/Profile'
import {ProfileFollowers} from './screens/ProfileFollowers'
import {ProfileFollows} from './screens/ProfileFollows'
import {Settings} from './screens/Settings'
import {Debug} from './screens/Debug'
import {Log} from './screens/Log'
export type ScreenParams = {
navIdx: string
params: Record<string, any>
visible: boolean
}
export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp]
export type MatchResult = {
Com: React.FC<ScreenParams>
defaultTitle: string
icon: IconProp
params: Record<string, any>
isNotFound?: boolean
}
const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i')
export const routes: Route[] = [
[Home, 'Home', 'house', r('/')],
[Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')],
[Search, 'Search', 'magnifying-glass', r('/search')],
[Notifications, 'Notifications', 'bell', r('/notifications')],
[Settings, 'Settings', 'bell', r('/settings')],
[Profile, 'User', ['far', 'user'], r('/profile/(?<name>[^/]+)')],
[
ProfileFollowers,
'Followers',
'users',
r('/profile/(?<name>[^/]+)/followers'),
],
[ProfileFollows, 'Follows', 'users', r('/profile/(?<name>[^/]+)/follows')],
[
PostThread,
'Post',
['far', 'message'],
r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)'),
],
[
PostUpvotedBy,
'Liked by',
'heart',
r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/upvoted-by'),
],
[
PostDownvotedBy,
'Downvoted by',
'heart',
r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/downvoted-by'),
],
[
PostRepostedBy,
'Reposted by',
'retweet',
r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/reposted-by'),
],
[Debug, 'Debug', 'house', r('/sys/debug')],
[Log, 'Log', 'house', r('/sys/log')],
]
export function match(url: string): MatchResult {
for (const [Com, defaultTitle, icon, pattern] of routes) {
const res = pattern.exec(url)
if (res) {
// TODO: query params
return {Com, defaultTitle, icon, params: res.groups || {}}
}
}
return {
Com: NotFound,
defaultTitle: 'Not found',
icon: 'magnifying-glass',
params: {},
isNotFound: true,
}
}

View file

@ -1,88 +0,0 @@
import React, {useEffect, useState, useRef} from 'react'
import {StyleSheet, TextInput, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {Selector} from '../com/util/Selector'
import {Text} from '../com/util/text/Text'
import {colors} from 'lib/styles'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
export const Contacts = ({navIdx, visible}: ScreenParams) => {
const store = useStores()
const selectorInterp = useAnimatedValue(0)
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, 'Contacts')
}
}, [store, visible, navIdx])
const [searchText, onChangeSearchText] = useState('')
const inputRef = useRef<TextInput | null>(null)
return (
<View>
<View style={styles.section}>
<Text testID="contactsTitle" style={styles.title}>
Contacts
</Text>
</View>
<View style={styles.section}>
<View style={styles.searchContainer}>
<FontAwesomeIcon
icon="magnifying-glass"
size={16}
style={styles.searchIcon}
/>
<TextInput
testID="contactsTextInput"
ref={inputRef}
value={searchText}
style={styles.searchInput}
placeholder="Search"
placeholderTextColor={colors.gray4}
onChangeText={onChangeSearchText}
/>
</View>
</View>
<Selector
items={['All', 'Following', 'Scenes']}
selectedIndex={0}
panX={selectorInterp}
/>
{!!store.me.handle && <ProfileFollowsComponent name={store.me.handle} />}
</View>
)
}
const styles = StyleSheet.create({
section: {
backgroundColor: colors.white,
},
title: {
fontSize: 30,
fontWeight: 'bold',
paddingHorizontal: 12,
paddingVertical: 6,
},
searchContainer: {
flexDirection: 'row',
backgroundColor: colors.gray1,
paddingHorizontal: 8,
paddingVertical: 8,
marginHorizontal: 10,
marginBottom: 6,
borderRadius: 4,
},
searchIcon: {
color: colors.gray5,
marginRight: 8,
},
searchInput: {
flex: 1,
color: colors.black,
},
})

View file

@ -1,5 +1,6 @@
import React from 'react'
import {ScrollView, View} from 'react-native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
@ -20,7 +21,10 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs']
export const Debug = () => {
export const DebugScreen = ({}: NativeStackScreenProps<
CommonNavigatorParams,
'Debug'
>) => {
const [colorScheme, setColorScheme] = React.useState<'light' | 'dark'>(
'light',
)

View file

@ -1,14 +1,15 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/posts/Feed'
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
import {WelcomeBanner} from '../com/util/WelcomeBanner'
import {FAB} from '../com/util/FAB'
import {useStores} from 'state/index'
import {ScreenParams} from '../routes'
import {s} from 'lib/styles'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics'
@ -16,19 +17,20 @@ import {ComposeIcon2} from 'lib/icons'
const HEADER_HEIGHT = 42
export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = observer(function Home(_opts: Props) {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const {screen, track} = useAnalytics()
const scrollElRef = React.useRef<FlatList>(null)
const [wasVisible, setWasVisible] = React.useState<boolean>(false)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isFocused = useIsFocused()
const doPoll = React.useCallback(
(knownActive = false) => {
if ((!knownActive && appState !== 'active') || !visible) {
if ((!knownActive && appState !== 'active') || !isFocused) {
return
}
if (store.me.mainFeed.isLoading) {
@ -37,7 +39,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
store.log.debug('HomeScreen: Polling for new posts')
store.me.mainFeed.checkForLatest()
},
[appState, visible, store],
[appState, isFocused, store],
)
const scrollToTop = React.useCallback(() => {
@ -46,53 +48,35 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
}, [scrollElRef])
React.useEffect(() => {
const softResetSub = store.onScreenSoftReset(scrollToTop)
const feedCleanup = store.me.mainFeed.registerListeners()
const pollInterval = setInterval(doPoll, 15e3)
const cleanup = () => {
clearInterval(pollInterval)
softResetSub.remove()
feedCleanup()
}
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(scrollToTop)
const feedCleanup = store.me.mainFeed.registerListeners()
const pollInterval = setInterval(doPoll, 15e3)
// guard to only continue when transitioning from !visible -> visible
// TODO is this 100% needed? depends on if useEffect() is getting refired
// for reasons other than `visible` changing -prf
if (!visible) {
setWasVisible(false)
return cleanup
} else if (wasVisible) {
return cleanup
}
setWasVisible(true)
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
}
// just became visible
screen('Feed')
store.nav.setTitle(navIdx, 'Home')
store.log.debug('HomeScreen: Updating feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
}
return cleanup
}, [
visible,
store,
store.me.mainFeed,
navIdx,
doPoll,
wasVisible,
scrollToTop,
screen,
])
return () => {
clearInterval(pollInterval)
softResetSub.remove()
feedCleanup()
}
}, [store, doPoll, scrollToTop, screen]),
)
const onPressCompose = React.useCallback(() => {
track('HomeScreen:PressCompose')
store.shell.openComposer({})
}, [store, track])
const onPressTryAgain = React.useCallback(() => {
store.me.mainFeed.refresh()
}, [store])
const onPressLoadLatest = React.useCallback(() => {
store.me.mainFeed.refresh()
scrollToTop()

View file

@ -1,28 +1,30 @@
import React, {useEffect} from 'react'
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ScrollView} from '../com/util/Views'
import {useStores} from 'state/index'
import {ScreenParams} from '../routes'
import {s} from 'lib/styles'
import {ViewHeader} from '../com/util/ViewHeader'
import {Text} from '../com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {ago} from 'lib/strings/time'
export const Log = observer(function Log({navIdx, visible}: ScreenParams) {
export const LogScreen = observer(function Log({}: NativeStackScreenProps<
CommonNavigatorParams,
'Log'
>) {
const pal = usePalette('default')
const store = useStores()
const [expanded, setExpanded] = React.useState<string[]>([])
useEffect(() => {
if (!visible) {
return
}
store.shell.setMinimalShellMode(false)
store.nav.setTitle(navIdx, 'Log')
}, [visible, store, navIdx])
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}, [store]),
)
const toggler = (id: string) => () => {
if (expanded.includes(id)) {

View file

@ -1,20 +1,41 @@
import React from 'react'
import {Button, StyleSheet, View} from 'react-native'
import {StyleSheet, View} from 'react-native'
import {useNavigation, StackActions} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {Button} from 'view/com/util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
export const NotFoundScreen = () => {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const canGoBack = navigation.canGoBack()
const onPressHome = React.useCallback(() => {
if (canGoBack) {
navigation.goBack()
} else {
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
}
}, [navigation, canGoBack])
export const NotFound = () => {
const stores = useStores()
return (
<View testID="notFoundView">
<View testID="notFoundView" style={pal.view}>
<ViewHeader title="Page not found" />
<View style={styles.container}>
<Text style={styles.title}>Page not found</Text>
<Text type="title-2xl" style={[pal.text, s.mb10]}>
Page not found
</Text>
<Text type="md" style={[pal.text, s.mb10]}>
We're sorry! We can't find the page you were looking for.
</Text>
<Button
testID="navigateHomeButton"
title="Home"
onPress={() => stores.nav.navigate('/')}
type="primary"
label={canGoBack ? 'Go back' : 'Go home'}
onPress={onPressHome}
/>
</View>
</View>
@ -23,12 +44,9 @@ export const NotFound = () => {
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
},
title: {
fontSize: 40,
fontWeight: 'bold',
paddingHorizontal: 20,
alignItems: 'center',
height: '100%',
},
})

View file

@ -1,17 +1,25 @@
import React, {useEffect} from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import useAppState from 'react-native-appstate-hook'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
import {useStores} from 'state/index'
import {ScreenParams} from '../routes'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
const NOTIFICATIONS_POLL_INTERVAL = 15e3
export const Notifications = ({navIdx, visible}: ScreenParams) => {
type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
'Notifications'
>
export const NotificationsScreen = ({}: Props) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
@ -59,21 +67,19 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
// on-visible setup
// =
useEffect(() => {
if (!visible) {
// mark read when the user leaves the screen
store.me.notifications.markAllRead()
return
}
store.log.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(scrollToTop)
store.me.notifications.update()
screen('Notifications')
store.nav.setTitle(navIdx, 'Notifications')
return () => {
softResetSub.remove()
}
}, [visible, store, navIdx, screen, scrollToTop])
useFocusEffect(
React.useCallback(() => {
store.log.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(scrollToTop)
store.me.notifications.update()
screen('Notifications')
return () => {
softResetSub.remove()
store.me.notifications.markAllRead()
}
}, [store, screen, scrollToTop]),
)
return (
<View style={s.hContentRegion}>

View file

@ -1,27 +0,0 @@
import React, {useEffect} from 'react'
import {View} from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => {
const store = useStores()
const {name, rkey} = params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, 'Downvoted by')
store.shell.setMinimalShellMode(false)
}
}, [store, visible, navIdx])
return (
<View>
<ViewHeader title="Downvoted by" />
<PostLikedByComponent uri={uri} direction="down" />
</View>
)
}

View file

@ -1,22 +1,23 @@
import React, {useEffect} from 'react'
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
export const PostRepostedByScreen = ({route}: Props) => {
const store = useStores()
const {name, rkey} = params
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, 'Reposted by')
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}
}, [store, visible, navIdx])
}, [store]),
)
return (
<View>

View file

@ -1,58 +1,45 @@
import React, {useEffect, useMemo} from 'react'
import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThreadViewModel} from 'state/models/post-thread-view'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lodash'
import {isDesktopWeb} from 'platform/detection'
const SHELL_FOOTER_HEIGHT = 44
export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export const PostThreadScreen = ({route}: Props) => {
const store = useStores()
const safeAreaInsets = useSafeAreaInsets()
const {name, rkey} = params
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const view = useMemo<PostThreadViewModel>(
() => new PostThreadViewModel(store, {uri}),
[store, uri],
)
useEffect(() => {
let aborted = false
const threadCleanup = view.registerListeners()
const setTitle = () => {
const author = view.thread?.post.author
const niceName = author?.handle || name
store.nav.setTitle(navIdx, `Post by ${niceName}`)
}
if (!visible) {
return threadCleanup
}
setTitle()
store.shell.setMinimalShellMode(false)
if (!view.hasLoaded && !view.isLoading) {
view.setup().then(
() => {
if (!aborted) {
setTitle()
}
},
err => {
useFocusEffect(
React.useCallback(() => {
const threadCleanup = view.registerListeners()
store.shell.setMinimalShellMode(false)
if (!view.hasLoaded && !view.isLoading) {
view.setup().catch(err => {
store.log.error('Failed to fetch thread', err)
},
)
}
return () => {
aborted = true
threadCleanup()
}
}, [visible, store.nav, store.log, store.shell, name, navIdx, view])
})
}
return () => {
threadCleanup()
}
}, [store, view]),
)
const onPressReply = React.useCallback(() => {
if (!view.thread) {
@ -77,15 +64,24 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
<View style={s.hContentRegion}>
<ViewHeader title="Post" />
<View style={s.hContentRegion}>
<PostThreadComponent uri={uri} view={view} />
</View>
<View
style={[
styles.prompt,
{bottom: SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30)},
]}>
<ComposePrompt onPressCompose={onPressReply} />
<PostThreadComponent
uri={uri}
view={view}
onPressReply={onPressReply}
/>
</View>
{!isDesktopWeb && (
<View
style={[
styles.prompt,
{
bottom:
SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30),
},
]}>
<ComposePrompt onPressCompose={onPressReply} />
</View>
)}
</View>
)
}

View file

@ -1,21 +1,23 @@
import React, {useEffect} from 'react'
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'>
export const PostUpvotedByScreen = ({route}: Props) => {
const store = useStores()
const {name, rkey} = params
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, 'Liked by')
}
}, [store, visible, navIdx])
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}, [store]),
)
return (
<View>

View file

@ -1,9 +1,10 @@
import React, {useEffect, useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewSelector} from '../com/util/ViewSelector'
import {CenteredView} from '../com/util/Views'
import {ScreenParams} from '../routes'
import {ProfileUiModel, Sections} from 'state/models/profile-ui'
import {useStores} from 'state/index'
import {ProfileHeader} from '../com/profile/ProfileHeader'
@ -23,7 +24,8 @@ const LOADING_ITEM = {_reactKey: '__loading__'}
const END_ITEM = {_reactKey: '__end__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export const ProfileScreen = observer(({route}: Props) => {
const store = useStores()
const {screen, track} = useAnalytics()
@ -34,35 +36,30 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = React.useMemo(
() => new ProfileUiModel(store, {user: params.name}),
[params.name, store],
() => new ProfileUiModel(store, {user: route.params.name}),
[route.params.name, store],
)
useEffect(() => {
store.nav.setTitle(navIdx, params.name)
}, [store, navIdx, params.name])
useEffect(() => {
let aborted = false
const feedCleanup = uiState.feed.registerListeners()
if (!visible) {
return feedCleanup
}
if (hasSetup) {
uiState.update()
} else {
uiState.setup().then(() => {
if (aborted) {
return
}
setHasSetup(true)
})
}
return () => {
aborted = true
feedCleanup()
}
}, [visible, store, hasSetup, uiState])
useFocusEffect(
React.useCallback(() => {
let aborted = false
const feedCleanup = uiState.feed.registerListeners()
if (hasSetup) {
uiState.update()
} else {
uiState.setup().then(() => {
if (aborted) {
return
}
setHasSetup(true)
})
}
return () => {
aborted = true
feedCleanup()
}
}, [hasSetup, uiState]),
)
// events
// =
@ -171,7 +168,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
<ErrorScreen
testID="profileErrorScreen"
title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`}
message={`There was an issue when attempting to load ${route.params.name}`}
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain}
/>

View file

@ -1,20 +1,21 @@
import React, {useEffect} from 'react'
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'>
export const ProfileFollowersScreen = ({route}: Props) => {
const store = useStores()
const {name} = params
const {name} = route.params
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, `Followers of ${name}`)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}
}, [store, visible, name, navIdx])
}, [store]),
)
return (
<View>

View file

@ -1,20 +1,21 @@
import React, {useEffect} from 'react'
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'>
export const ProfileFollowsScreen = ({route}: Props) => {
const store = useStores()
const {name} = params
const {name} = route.params
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, `Followed by ${name}`)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}
}, [store, visible, name, navIdx])
}, [store]),
)
return (
<View>

View file

@ -7,12 +7,19 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useFocusEffect} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ScrollView} from '../com/util/Views'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {UserAvatar} from '../com/util/UserAvatar'
import {Text} from '../com/util/text/Text'
import {ScreenParams} from '../routes'
import {useStores} from 'state/index'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {s} from 'lib/styles'
@ -21,14 +28,17 @@ import {WhoToFollow} from '../com/discover/WhoToFollow'
import {SuggestedPosts} from '../com/discover/SuggestedPosts'
import {ProfileCard} from '../com/profile/ProfileCard'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics'
const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
const FIVE_MIN = 5 * 60 * 1e3
export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = observer<Props>(({}: Props) => {
const pal = usePalette('default')
const theme = useTheme()
const store = useStores()
const {track} = useAnalytics()
const scrollElRef = React.useRef<ScrollView>(null)
@ -41,33 +51,32 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
() => new UserAutocompleteViewModel(store),
[store],
)
const {name} = params
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
React.useEffect(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
if (visible) {
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
autocompleteView.setup()
store.nav.setTitle(navIdx, 'Search')
}
return cleanup
}, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
return cleanup
}, [store, autocompleteView, lastRenderTime, setRenderTime]),
)
const onPressMenu = () => {
track('ViewHeader:MenuButtonClicked')
store.shell.setMainMenuOpen(true)
store.shell.openDrawer()
}
const onChangeQuery = (text: string) => {
@ -102,12 +111,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
onPress={onPressMenu}
hitSlop={MENU_HITSLOP}
style={styles.headerMenuBtn}>
<UserAvatar
size={30}
handle={store.me.handle}
displayName={store.me.displayName}
avatar={store.me.avatar}
/>
<UserAvatar size={30} avatar={store.me.avatar} />
</TouchableOpacity>
<View
style={[
@ -127,13 +131,18 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
returnKeyType="search"
value={query}
style={[pal.text, styles.headerSearchInput]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery}
/>
{query ? (
<TouchableOpacity onPress={onPressClearQuery}>
<FontAwesomeIcon icon="xmark" size={16} style={pal.textLight} />
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>

View file

@ -1,8 +1,12 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {ScrollView} from '../com/util/Views'
import {observer} from 'mobx-react-lite'
import {ScreenParams} from '../routes'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {WhoToFollow} from '../com/discover/WhoToFollow'
@ -12,7 +16,8 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
const FIVE_MIN = 5 * 60 * 1e3
export const Search = observer(({navIdx, visible}: ScreenParams) => {
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const scrollElRef = React.useRef<ScrollView>(null)
@ -23,22 +28,21 @@ export const Search = observer(({navIdx, visible}: ScreenParams) => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
React.useEffect(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
if (visible) {
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
store.nav.setTitle(navIdx, 'Search')
}
return cleanup
}, [store, visible, navIdx, lastRenderTime])
return () => {
softResetSub.remove()
}
}, [store, lastRenderTime, setRenderTime]),
)
return (
<ScrollView

View file

@ -1,18 +1,23 @@
import React, {useEffect} from 'react'
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {
useFocusEffect,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import * as AppInfo from 'lib/app-info'
import {useStores} from 'state/index'
import {ScreenParams} from '../routes'
import {s, colors} from 'lib/styles'
import {ScrollView} from '../com/util/Views'
import {ViewHeader} from '../com/util/ViewHeader'
@ -25,41 +30,38 @@ import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
export const Settings = observer(function Settings({
navIdx,
visible,
}: ScreenParams) {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = observer(function Settings({}: Props) {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {screen, track} = useAnalytics()
const [isSwitching, setIsSwitching] = React.useState(false)
useEffect(() => {
screen('Settings')
}, [screen])
useEffect(() => {
if (!visible) {
return
}
store.shell.setMinimalShellMode(false)
store.nav.setTitle(navIdx, 'Settings')
}, [visible, store, navIdx])
useFocusEffect(
React.useCallback(() => {
screen('Settings')
store.shell.setMinimalShellMode(false)
}, [screen, store]),
)
const onPressSwitchAccount = async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
store.nav.tab.fixedTabReset()
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
}
setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
store.nav.tab.fixedTabReset()
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressAddAccount = () => {
@ -118,12 +120,7 @@ export const Settings = observer(function Settings({
noFeedback>
<View style={[pal.view, styles.linkCard]}>
<View style={styles.avi}>
<UserAvatar
size={40}
displayName={store.me.displayName}
handle={store.me.handle || ''}
avatar={store.me.avatar}
/>
<UserAvatar size={40} avatar={store.me.avatar} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
@ -152,12 +149,7 @@ export const Settings = observer(function Settings({
isSwitching ? undefined : () => onPressSwitchAccount(account)
}>
<View style={styles.avi}>
<UserAvatar
size={40}
displayName={account.displayName}
handle={account.handle || ''}
avatar={account.aviUrl}
/>
<UserAvatar size={40} avatar={account.aviUrl} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text}>

View file

@ -6,13 +6,14 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {StackActions, useNavigationState} from '@react-navigation/native'
import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {observer} from 'mobx-react-lite'
import {Text} from 'view/com/util/text/Text'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation'
import {clamp} from 'lib/numbers'
import {
HomeIcon,
@ -25,13 +26,24 @@ import {
} from 'lib/icons'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {getTabState, TabState} from 'lib/routes/helpers'
export const BottomBar = observer(() => {
export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
const store = useStores()
const pal = usePalette('default')
const minimalShellInterp = useAnimatedValue(0)
const safeAreaInsets = useSafeAreaInsets()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
state => {
return {
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
}
},
)
React.useEffect(() => {
if (store.shell.minimalShellMode) {
@ -54,62 +66,34 @@ export const BottomBar = observer(() => {
transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
}
const onPressHome = React.useCallback(() => {
track('MobileShell:HomeButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
if (!store.nav.tab.canGoBack) {
const onPressTab = React.useCallback(
(tab: string) => {
track(`MobileShell:${tab}ButtonPressed`)
const state = navigation.getState()
const tabState = getTabState(state, tab)
if (tabState === TabState.InsideAtRoot) {
store.emitScreenSoftReset()
} else if (tabState === TabState.Inside) {
navigation.dispatch(StackActions.popToTop())
} else {
store.nav.tab.fixedTabReset()
navigation.navigate(`${tab}Tab`)
}
} else {
store.nav.switchTo(TabPurpose.Default, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}, [store, track])
const onPressSearch = React.useCallback(() => {
track('MobileShell:SearchButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Search, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}, [store, track])
const onPressNotifications = React.useCallback(() => {
track('MobileShell:NotificationsButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Notifs, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}, [store, track])
},
[store, track, navigation],
)
const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
const onPressSearch = React.useCallback(
() => onPressTab('Search'),
[onPressTab],
)
const onPressNotifications = React.useCallback(
() => onPressTab('Notifications'),
[onPressTab],
)
const onPressProfile = React.useCallback(() => {
track('MobileShell:ProfileButtonPressed')
store.nav.navigate(`/profile/${store.me.handle}`)
}, [store, track])
const isAtHome =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
const isAtSearch =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
const isAtNotifications =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
navigation.navigate('Profile', {name: store.me.handle})
}, [navigation, track, store.me.handle])
return (
<Animated.View

View file

@ -1,7 +1,7 @@
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
import {ComposePost} from '../../com/composer/ComposePost'
import {ComposePost} from '../com/composer/Composer'
import {ComposerOpts} from 'state/models/shell-ui'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
@ -11,7 +11,6 @@ export const Composer = observer(
active,
winHeight,
replyTo,
imagesOpen,
onPost,
onClose,
quote,
@ -19,7 +18,6 @@ export const Composer = observer(
active: boolean
winHeight: number
replyTo?: ComposerOpts['replyTo']
imagesOpen?: ComposerOpts['imagesOpen']
onPost?: ComposerOpts['onPost']
onClose: () => void
quote?: ComposerOpts['quote']
@ -61,7 +59,6 @@ export const Composer = observer(
<Animated.View style={[styles.wrapper, pal.view, wrapperAnimStyle]}>
<ComposePost
replyTo={replyTo}
imagesOpen={imagesOpen}
onPost={onPost}
onClose={onClose}
quote={quote}

View file

@ -1,7 +1,7 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import {ComposePost} from '../../com/composer/ComposePost'
import {ComposePost} from '../com/composer/Composer'
import {ComposerOpts} from 'state/models/shell-ui'
import {usePalette} from 'lib/hooks/usePalette'
@ -9,14 +9,12 @@ export const Composer = observer(
({
active,
replyTo,
imagesOpen,
onPost,
onClose,
}: {
active: boolean
winHeight: number
replyTo?: ComposerOpts['replyTo']
imagesOpen?: ComposerOpts['imagesOpen']
onPost?: ComposerOpts['onPost']
onClose: () => void
}) => {
@ -32,12 +30,7 @@ export const Composer = observer(
return (
<View style={styles.mask}>
<View style={[styles.container, pal.view]}>
<ComposePost
replyTo={replyTo}
imagesOpen={imagesOpen}
onPost={onPost}
onClose={onClose}
/>
<ComposePost replyTo={replyTo} onPost={onPost} onClose={onClose} />
</View>
</View>
)

386
src/view/shell/Drawer.tsx Normal file
View file

@ -0,0 +1,386 @@
import React from 'react'
import {
Linking,
SafeAreaView,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
useNavigation,
useNavigationState,
StackActions,
} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {s, colors} from 'lib/styles'
import {FEEDBACK_FORM_URL} from 'lib/constants'
import {useStores} from 'state/index'
import {
HomeIcon,
HomeIconSolid,
BellIcon,
BellIconSolid,
UserIcon,
CogIcon,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
MoonIcon,
} from 'lib/icons'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {pluralize} from 'lib/strings/helpers'
import {getCurrentRoute, isTab, getTabState, TabState} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
export const DrawerContent = observer(() => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
state => {
const currentRoute = state ? getCurrentRoute(state) : false
return {
isAtHome: currentRoute ? isTab(currentRoute.name, 'Home') : true,
isAtSearch: currentRoute ? isTab(currentRoute.name, 'Search') : false,
isAtNotifications: currentRoute
? isTab(currentRoute.name, 'Notifications')
: false,
}
},
)
// events
// =
const onPressTab = React.useCallback(
(tab: string) => {
track('Menu:ItemClicked', {url: tab})
const state = navigation.getState()
store.shell.closeDrawer()
const tabState = getTabState(state, tab)
if (tabState === TabState.InsideAtRoot) {
store.emitScreenSoftReset()
} else if (tabState === TabState.Inside) {
navigation.dispatch(StackActions.popToTop())
} else {
// @ts-ignore must be Home, Search, or Notifications
navigation.navigate(`${tab}Tab`)
}
},
[store, track, navigation],
)
const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
const onPressSearch = React.useCallback(
() => onPressTab('Search'),
[onPressTab],
)
const onPressNotifications = React.useCallback(
() => onPressTab('Notifications'),
[onPressTab],
)
const onPressProfile = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Profile'})
navigation.navigate('Profile', {name: store.me.handle})
store.shell.closeDrawer()
}, [navigation, track, store.me.handle, store.shell])
const onPressSettings = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Settings'})
navigation.navigate('Settings')
store.shell.closeDrawer()
}, [navigation, track, store.shell])
const onPressFeedback = () => {
track('Menu:FeedbackClicked')
Linking.openURL(FEEDBACK_FORM_URL)
}
// rendering
// =
const MenuItem = ({
icon,
label,
count,
bold,
onPress,
}: {
icon: JSX.Element
label: string
count?: number
bold?: boolean
onPress: () => void
}) => (
<TouchableOpacity
testID={`menuItemButton-${label}`}
style={styles.menuItem}
onPress={onPress}>
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View style={styles.menuItemCount}>
<Text style={styles.menuItemCountLabel}>{count}</Text>
</View>
) : undefined}
</View>
<Text
type={bold ? '2xl-bold' : '2xl'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
const onDarkmodePress = () => {
track('Menu:ItemClicked', {url: '/darkmode'})
store.shell.setDarkMode(!store.shell.darkMode)
}
return (
<View
testID="menuView"
style={[
styles.view,
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
]}>
<SafeAreaView style={s.flex1}>
<TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
<UserAvatar size={80} avatar={store.me.avatar} />
<Text
type="title-lg"
style={[pal.text, s.bold, styles.profileCardDisplayName]}>
{store.me.displayName || store.me.handle}
</Text>
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
@{store.me.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{store.me.followersCount || 0}
</Text>{' '}
{pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
<Text type="xl-medium" style={pal.text}>
{store.me.followsCount || 0}
</Text>{' '}
following
</Text>
</TouchableOpacity>
<View style={s.flex1} />
<View>
<MenuItem
icon={
isAtSearch ? (
<MagnifyingGlassIcon2Solid
style={pal.text as StyleProp<ViewStyle>}
size={24}
strokeWidth={1.7}
/>
) : (
<MagnifyingGlassIcon2
style={pal.text as StyleProp<ViewStyle>}
size={24}
strokeWidth={1.7}
/>
)
}
label="Search"
bold={isAtSearch}
onPress={onPressSearch}
/>
<MenuItem
icon={
isAtHome ? (
<HomeIconSolid
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={3.25}
/>
) : (
<HomeIcon
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={3.25}
/>
)
}
label="Home"
bold={isAtHome}
onPress={onPressHome}
/>
<MenuItem
icon={
isAtNotifications ? (
<BellIconSolid
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={1.7}
/>
) : (
<BellIcon
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={1.7}
/>
)
}
label="Notifications"
count={store.me.notifications.unreadCount}
bold={isAtNotifications}
onPress={onPressNotifications}
/>
<MenuItem
icon={
<UserIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.5}
/>
}
label="Profile"
onPress={onPressProfile}
/>
<MenuItem
icon={
<CogIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.75}
/>
}
label="Settings"
onPress={onPressSettings}
/>
</View>
<View style={s.flex1} />
<View style={styles.footer}>
<TouchableOpacity
onPress={onDarkmodePress}
style={[
styles.footerBtn,
theme.colorScheme === 'light'
? pal.btn
: styles.footerBtnDarkMode,
]}>
<MoonIcon
size={22}
style={pal.text as StyleProp<ViewStyle>}
strokeWidth={2}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onPressFeedback}
style={[
styles.footerBtn,
styles.footerBtnFeedback,
theme.colorScheme === 'light'
? styles.footerBtnFeedbackLight
: styles.footerBtnFeedbackDark,
]}>
<FontAwesomeIcon
style={pal.link as FontAwesomeIconStyle}
size={19}
icon={['far', 'message']}
/>
<Text type="2xl-medium" style={[pal.link, s.pl10]}>
Feedback
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
)
})
const styles = StyleSheet.create({
view: {
flex: 1,
paddingTop: 20,
paddingBottom: 50,
paddingLeft: 20,
},
viewDarkMode: {
backgroundColor: '#1B1919',
},
profileCardDisplayName: {
marginTop: 20,
paddingRight: 30,
},
profileCardHandle: {
marginTop: 4,
paddingRight: 30,
},
profileCardFollowers: {
marginTop: 16,
paddingRight: 30,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingRight: 10,
},
menuItemIconWrapper: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemCount: {
position: 'absolute',
right: -6,
top: -2,
backgroundColor: colors.red3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 6,
},
menuItemCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.white,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingRight: 30,
paddingTop: 80,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderRadius: 25,
},
footerBtnDarkMode: {
backgroundColor: colors.black,
},
footerBtnFeedback: {
paddingHorizontal: 24,
},
footerBtnFeedbackLight: {
backgroundColor: '#DDEFFF',
},
footerBtnFeedbackDark: {
backgroundColor: colors.blue6,
},
})

View file

@ -0,0 +1,254 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useNavigation, useNavigationState} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Link} from 'view/com/util/Link'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {
HomeIcon,
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
BellIcon,
BellIconSolid,
UserIcon,
UserIconSolid,
CogIcon,
CogIconSolid,
ComposeIcon2,
} from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes'
const ProfileCard = observer(() => {
const store = useStores()
return (
<Link href={`/profile/${store.me.handle}`} style={styles.profileCard}>
<UserAvatar avatar={store.me.avatar} size={64} />
</Link>
)
})
function BackBtn() {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const shouldShow = useNavigationState(state => !isStateAtTabRoot(state))
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (!shouldShow) {
return <></>
}
return (
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={onPressBack}
style={styles.backBtn}>
<FontAwesomeIcon
size={24}
icon="angle-left"
style={pal.text as FontAwesomeIconStyle}
/>
</TouchableOpacity>
)
}
interface NavItemProps {
count?: number
href: string
icon: JSX.Element
iconFilled: JSX.Element
label: string
}
const NavItem = observer(
({count, href, icon, iconFilled, label}: NavItemProps) => {
const pal = usePalette('default')
const [pathName] = React.useMemo(() => router.matchPath(href), [href])
const currentRouteName = useNavigationState(state => {
if (!state) {
return 'Home'
}
return getCurrentRoute(state).name
})
const isCurrent = isTab(currentRouteName, pathName)
return (
<Link href={href} style={styles.navItem}>
<View style={[styles.navItemIconWrapper]}>
{isCurrent ? iconFilled : icon}
{typeof count === 'number' && count > 0 && (
<Text type="button" style={styles.navItemCount}>
{count}
</Text>
)}
</View>
<Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
{label}
</Text>
</Link>
)
},
)
function ComposeBtn() {
const store = useStores()
const onPressCompose = () => store.shell.openComposer({})
return (
<TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}>
<View style={styles.newPostBtnIconWrapper}>
<ComposeIcon2
size={19}
strokeWidth={2}
style={styles.newPostBtnLabel}
/>
</View>
<Text type="button" style={styles.newPostBtnLabel}>
New Post
</Text>
</TouchableOpacity>
)
}
export const DesktopLeftNav = observer(function DesktopLeftNav() {
const store = useStores()
const pal = usePalette('default')
return (
<View style={styles.leftNav}>
<ProfileCard />
<BackBtn />
<NavItem
href="/"
icon={<HomeIcon size={24} style={pal.text} />}
iconFilled={
<HomeIconSolid strokeWidth={4} size={24} style={pal.text} />
}
label="Home"
/>
<NavItem
href="/search"
icon={
<MagnifyingGlassIcon2 strokeWidth={2} size={24} style={pal.text} />
}
iconFilled={
<MagnifyingGlassIcon2Solid
strokeWidth={2}
size={24}
style={pal.text}
/>
}
label="Search"
/>
<NavItem
href="/notifications"
count={store.me.notifications.unreadCount}
icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
iconFilled={
<BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
}
label="Notifications"
/>
<NavItem
href={`/profile/${store.me.handle}`}
icon={<UserIcon strokeWidth={1.75} size={28} style={pal.text} />}
iconFilled={
<UserIconSolid strokeWidth={1.75} size={28} style={pal.text} />
}
label="Profile"
/>
<NavItem
href="/settings"
icon={<CogIcon strokeWidth={1.75} size={28} style={pal.text} />}
iconFilled={
<CogIconSolid strokeWidth={1.5} size={28} style={pal.text} />
}
label="Settings"
/>
<ComposeBtn />
</View>
)
})
const styles = StyleSheet.create({
leftNav: {
position: 'absolute',
top: 10,
right: 'calc(50vw + 300px)',
width: 220,
},
profileCard: {
marginVertical: 10,
width: 60,
},
backBtn: {
position: 'absolute',
top: 12,
right: 12,
width: 30,
height: 30,
},
navItem: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 14,
paddingBottom: 10,
},
navItemIconWrapper: {
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
marginRight: 10,
marginTop: 2,
},
navItemCount: {
position: 'absolute',
top: 0,
left: 15,
backgroundColor: colors.blue3,
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
paddingHorizontal: 4,
borderRadius: 6,
},
newPostBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 136,
borderRadius: 24,
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: colors.blue3,
marginTop: 20,
},
newPostBtnIconWrapper: {
marginRight: 8,
},
newPostBtnLabel: {
color: colors.white,
fontSize: 16,
fontWeight: 'bold',
},
})

View file

@ -0,0 +1,46 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {DesktopSearch} from './Search'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL} from 'lib/constants'
export const DesktopRightNav = observer(function DesktopRightNav() {
const pal = usePalette('default')
return (
<View style={[styles.rightNav, pal.view]}>
<DesktopSearch />
<View style={styles.message}>
<Text type="md" style={[pal.textLight, styles.messageLine]}>
Welcome to Bluesky! This is a beta application that's still in
development.
</Text>
<TextLink
type="md"
style={pal.link}
href={FEEDBACK_FORM_URL}
text="Send feedback"
/>
</View>
</View>
)
})
const styles = StyleSheet.create({
rightNav: {
position: 'absolute',
top: 20,
left: 'calc(50vw + 330px)',
width: 300,
},
message: {
marginTop: 20,
paddingHorizontal: 10,
},
messageLine: {
marginBottom: 10,
},
})

View file

@ -1,11 +1,12 @@
import React from 'react'
import {TextInput, View, StyleSheet, TouchableOpacity, Text} from 'react-native'
import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon} from 'lib/icons'
import {ProfileCard} from '../../com/profile/ProfileCard'
import {MagnifyingGlassIcon2} from 'lib/icons'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {Text} from 'view/com/util/text/Text'
export const DesktopSearch = observer(function DesktopSearch() {
const store = useStores()
@ -35,9 +36,10 @@ export const DesktopSearch = observer(function DesktopSearch() {
return (
<View style={styles.container}>
<View style={[pal.borderDark, pal.view, styles.search]}>
<View
style={[{backgroundColor: pal.colors.backgroundLight}, styles.search]}>
<View style={[styles.inputContainer]}>
<MagnifyingGlassIcon
<MagnifyingGlassIcon2
size={18}
style={[pal.textLight, styles.iconWrapper]}
/>
@ -57,7 +59,9 @@ export const DesktopSearch = observer(function DesktopSearch() {
{query ? (
<View style={styles.cancelBtn}>
<TouchableOpacity onPress={onPressCancelSearch}>
<Text style={[pal.link]}>Cancel</Text>
<Text type="lg" style={[pal.link]}>
Cancel
</Text>
</TouchableOpacity>
</View>
) : undefined}
@ -97,21 +101,23 @@ const styles = StyleSheet.create({
width: 300,
},
search: {
paddingHorizontal: 10,
paddingHorizontal: 16,
paddingVertical: 2,
width: 300,
borderRadius: 20,
borderWidth: 1,
},
inputContainer: {
flexDirection: 'row',
},
iconWrapper: {
position: 'relative',
top: 2,
paddingVertical: 7,
marginRight: 4,
marginRight: 8,
},
input: {
flex: 1,
fontSize: 16,
fontSize: 18,
width: '100%',
paddingTop: 7,
paddingBottom: 7,

139
src/view/shell/index.tsx Normal file
View file

@ -0,0 +1,139 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StatusBar, StyleSheet, useWindowDimensions, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Drawer} from 'react-native-drawer-layout'
import {useNavigationState} from '@react-navigation/native'
import {useStores} from 'state/index'
import {Login} from 'view/screens/Login'
import {ModalsContainer} from 'view/com/modals/Modal'
import {Lightbox} from 'view/com/lightbox/Lightbox'
import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {DrawerContent} from './Drawer'
import {Composer} from './Composer'
import {s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {isStateAtTabRoot} from 'lib/routes/helpers'
const ShellInner = observer(() => {
const store = useStores()
const winDim = useWindowDimensions()
const safeAreaInsets = useSafeAreaInsets()
const containerPadding = React.useMemo(
() => ({height: '100%', paddingTop: safeAreaInsets.top}),
[safeAreaInsets],
)
const renderDrawerContent = React.useCallback(() => <DrawerContent />, [])
const onOpenDrawer = React.useCallback(
() => store.shell.openDrawer(),
[store],
)
const onCloseDrawer = React.useCallback(
() => store.shell.closeDrawer(),
[store],
)
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
return (
<>
<View style={containerPadding}>
<ErrorBoundary>
<Drawer
renderDrawerContent={renderDrawerContent}
open={store.shell.isDrawerOpen}
onOpen={onOpenDrawer}
onClose={onCloseDrawer}
swipeEdgeWidth={winDim.width}
swipeEnabled={!canGoBack}>
<TabsNavigator />
</Drawer>
</ErrorBoundary>
</View>
<ModalsContainer />
<Lightbox />
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}
winHeight={winDim.height}
replyTo={store.shell.composerOpts?.replyTo}
onPost={store.shell.composerOpts?.onPost}
quote={store.shell.composerOpts?.quote}
/>
</>
)
})
export const Shell: React.FC = observer(() => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
if (store.hackUpgradeNeeded) {
return (
<View style={styles.outerContainer}>
<View style={[s.flexCol, s.p20, s.h100pct]}>
<View style={s.flex1} />
<View>
<Text type="title-2xl" style={s.pb10}>
Update required
</Text>
<Text style={[s.pb20, s.bold]}>
Please update your app to the latest version. If no update is
available yet, please check the App Store in a day or so.
</Text>
<Text type="title" style={s.pb10}>
What's happening?
</Text>
<Text style={s.pb10}>
We're in the final stages of the AT Protocol's v1 development. To
make sure everything works as well as possible, we're making final
breaking changes to the APIs.
</Text>
<Text>
If we didn't botch this process, a new version of the app should
be available now.
</Text>
</View>
<View style={s.flex1} />
<View style={s.footerSpacer} />
</View>
</View>
)
}
if (!store.session.hasSession) {
return (
<View style={styles.outerContainer}>
<StatusBar
barStyle={
theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
}
/>
<Login />
<ModalsContainer />
</View>
)
}
return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar
barStyle={
theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
}
/>
<RoutesContainer>
<ShellInner />
</RoutesContainer>
</View>
)
})
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
})

View file

@ -0,0 +1,113 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {View, StyleSheet} from 'react-native'
import {useStores} from 'state/index'
import {DesktopLeftNav} from './desktop/LeftNav'
import {DesktopRightNav} from './desktop/RightNav'
import {Login} from '../screens/Login'
import {ErrorBoundary} from '../com/util/ErrorBoundary'
import {Lightbox} from '../com/lightbox/Lightbox'
import {ModalsContainer} from '../com/modals/Modal'
import {Text} from 'view/com/util/text/Text'
import {Composer} from './Composer.web'
import {usePalette} from 'lib/hooks/usePalette'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {s, colors} from 'lib/styles'
import {isMobileWeb} from 'platform/detection'
import {RoutesContainer, FlatNavigator} from '../../Navigation'
const ShellInner = observer(() => {
const store = useStores()
return (
<>
<View style={s.hContentRegion}>
<ErrorBoundary>
<FlatNavigator />
</ErrorBoundary>
</View>
<DesktopLeftNav />
<DesktopRightNav />
<View style={[styles.viewBorder, styles.viewBorderLeft]} />
<View style={[styles.viewBorder, styles.viewBorderRight]} />
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}
winHeight={0}
replyTo={store.shell.composerOpts?.replyTo}
onPost={store.shell.composerOpts?.onPost}
/>
<ModalsContainer />
<Lightbox />
</>
)
})
export const Shell: React.FC = observer(() => {
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const store = useStores()
if (isMobileWeb) {
return <NoMobileWeb />
}
if (!store.session.hasSession) {
return (
<View style={[s.hContentRegion, pageBg]}>
<Login />
<ModalsContainer />
</View>
)
}
return (
<View style={[s.hContentRegion, pageBg]}>
<RoutesContainer>
<ShellInner />
</RoutesContainer>
</View>
)
})
function NoMobileWeb() {
const pal = usePalette('default')
return (
<View style={[pal.view, styles.noMobileWeb]}>
<Text type="title-2xl" style={s.pb20}>
We're so sorry!
</Text>
<Text type="lg">
This app is not available for mobile Web yet. Please open it on your
desktop or download the iOS app.
</Text>
</View>
)
}
const styles = StyleSheet.create({
bgLight: {
backgroundColor: colors.white,
},
bgDark: {
backgroundColor: colors.black, // TODO
},
viewBorder: {
position: 'absolute',
width: 1,
height: '100%',
borderLeftWidth: 1,
borderLeftColor: colors.gray2,
},
viewBorderLeft: {
left: 'calc(50vw - 300px)',
},
viewBorderRight: {
left: 'calc(50vw + 300px)',
},
noMobileWeb: {
height: '100%',
justifyContent: 'center',
paddingHorizontal: 20,
paddingBottom: 40,
},
})

View file

@ -1,354 +0,0 @@
import React from 'react'
import {
Linking,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {s, colors} from 'lib/styles'
import {FEEDBACK_FORM_URL} from 'lib/constants'
import {useStores} from 'state/index'
import {
HomeIcon,
HomeIconSolid,
BellIcon,
BellIconSolid,
UserIcon,
CogIcon,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
MoonIcon,
} from 'lib/icons'
import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation'
import {UserAvatar} from '../../com/util/UserAvatar'
import {Text} from '../../com/util/text/Text'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {pluralize} from 'lib/strings/helpers'
export const Menu = observer(({onClose}: {onClose: () => void}) => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
// events
// =
const onNavigate = (url: string) => {
track('Menu:ItemClicked', {url})
onClose()
if (url === TabPurposeMainPath[TabPurpose.Notifs]) {
store.nav.switchTo(TabPurpose.Notifs, true)
} else if (url === TabPurposeMainPath[TabPurpose.Search]) {
store.nav.switchTo(TabPurpose.Search, true)
} else {
store.nav.switchTo(TabPurpose.Default, true)
if (url !== '/') {
store.nav.navigate(url)
}
}
}
const onPressFeedback = () => {
track('Menu:FeedbackClicked')
Linking.openURL(FEEDBACK_FORM_URL)
}
// rendering
// =
const MenuItem = ({
icon,
label,
count,
url,
bold,
onPress,
}: {
icon: JSX.Element
label: string
count?: number
url?: string
bold?: boolean
onPress?: () => void
}) => (
<TouchableOpacity
testID={`menuItemButton-${label}`}
style={styles.menuItem}
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View style={styles.menuItemCount}>
<Text style={styles.menuItemCountLabel}>{count}</Text>
</View>
) : undefined}
</View>
<Text
type={bold ? '2xl-bold' : '2xl'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
const onDarkmodePress = () => {
track('Menu:ItemClicked', {url: '/darkmode'})
store.shell.setDarkMode(!store.shell.darkMode)
}
const isAtHome =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
const isAtSearch =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
const isAtNotifications =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
return (
<View
testID="menuView"
style={[
styles.view,
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
]}>
<TouchableOpacity
testID="profileCardButton"
onPress={() => onNavigate(`/profile/${store.me.handle}`)}>
<UserAvatar
size={80}
displayName={store.me.displayName}
handle={store.me.handle}
avatar={store.me.avatar}
/>
<Text
type="title-lg"
style={[pal.text, s.bold, styles.profileCardDisplayName]}>
{store.me.displayName || store.me.handle}
</Text>
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
@{store.me.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{store.me.followersCount || 0}
</Text>{' '}
{pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
<Text type="xl-medium" style={pal.text}>
{store.me.followsCount || 0}
</Text>{' '}
following
</Text>
</TouchableOpacity>
<View style={s.flex1} />
<View>
<MenuItem
icon={
isAtSearch ? (
<MagnifyingGlassIcon2Solid
style={pal.text as StyleProp<ViewStyle>}
size={24}
strokeWidth={1.7}
/>
) : (
<MagnifyingGlassIcon2
style={pal.text as StyleProp<ViewStyle>}
size={24}
strokeWidth={1.7}
/>
)
}
label="Search"
url="/search"
bold={isAtSearch}
/>
<MenuItem
icon={
isAtHome ? (
<HomeIconSolid
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={3.25}
fillOpacity={1}
/>
) : (
<HomeIcon
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={3.25}
/>
)
}
label="Home"
url="/"
bold={isAtHome}
/>
<MenuItem
icon={
isAtNotifications ? (
<BellIconSolid
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={1.7}
fillOpacity={1}
/>
) : (
<BellIcon
style={pal.text as StyleProp<ViewStyle>}
size="24"
strokeWidth={1.7}
/>
)
}
label="Notifications"
url="/notifications"
count={store.me.notifications.unreadCount}
bold={isAtNotifications}
/>
<MenuItem
icon={
<UserIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.5}
/>
}
label="Profile"
url={`/profile/${store.me.handle}`}
/>
<MenuItem
icon={
<CogIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.75}
/>
}
label="Settings"
url="/settings"
/>
</View>
<View style={s.flex1} />
<View style={styles.footer}>
<TouchableOpacity
onPress={onDarkmodePress}
style={[
styles.footerBtn,
theme.colorScheme === 'light' ? pal.btn : styles.footerBtnDarkMode,
]}>
<MoonIcon
size={22}
style={pal.text as StyleProp<ViewStyle>}
strokeWidth={2}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onPressFeedback}
style={[
styles.footerBtn,
styles.footerBtnFeedback,
theme.colorScheme === 'light'
? styles.footerBtnFeedbackLight
: styles.footerBtnFeedbackDark,
]}>
<FontAwesomeIcon
style={pal.link as FontAwesomeIconStyle}
size={19}
icon={['far', 'message']}
/>
<Text type="2xl-medium" style={[pal.link, s.pl10]}>
Feedback
</Text>
</TouchableOpacity>
</View>
</View>
)
})
const styles = StyleSheet.create({
view: {
flex: 1,
paddingTop: 20,
paddingBottom: 50,
paddingLeft: 30,
},
viewDarkMode: {
backgroundColor: '#1B1919',
},
profileCardDisplayName: {
marginTop: 20,
paddingRight: 20,
},
profileCardHandle: {
marginTop: 4,
paddingRight: 20,
},
profileCardFollowers: {
marginTop: 16,
paddingRight: 20,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingRight: 10,
},
menuItemIconWrapper: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemCount: {
position: 'absolute',
right: -6,
top: -2,
backgroundColor: colors.red3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 6,
},
menuItemCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.white,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingRight: 30,
paddingTop: 80,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderRadius: 25,
},
footerBtnDarkMode: {
backgroundColor: colors.black,
},
footerBtnFeedback: {
paddingHorizontal: 24,
},
footerBtnFeedbackLight: {
backgroundColor: '#DDEFFF',
},
footerBtnFeedbackDark: {
backgroundColor: colors.blue6,
},
})

View file

@ -1,335 +0,0 @@
import React, {useState} from 'react'
import {observer} from 'mobx-react-lite'
import {
Animated,
StatusBar,
StyleSheet,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {useStores} from 'state/index'
import {NavigationModel} from 'state/models/navigation'
import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login'
import {Menu} from './Menu'
import {BottomBar} from './BottomBar'
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
import {ModalsContainer} from '../../com/modals/Modal'
import {Lightbox} from '../../com/lightbox/Lightbox'
import {Text} from '../../com/util/text/Text'
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
import {Composer} from './Composer'
import {s, colors} from 'lib/styles'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
export const MobileShell: React.FC = observer(() => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const winDim = useWindowDimensions()
const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
const swipeGestureInterp = useAnimatedValue(0)
const safeAreaInsets = useSafeAreaInsets()
const screenRenderDesc = constructScreenRenderDesc(store.nav)
// navigation swipes
// =
const isMenuActive = store.shell.isMainMenuOpen
const canSwipeLeft = store.nav.tab.canGoBack || !isMenuActive
const canSwipeRight = isMenuActive
const onNavSwipeStartDirection = (dx: number) => {
if (dx < 0 && !store.nav.tab.canGoBack) {
setMenuSwipingDirection(dx)
} else if (dx > 0 && isMenuActive) {
setMenuSwipingDirection(dx)
} else {
setMenuSwipingDirection(0)
}
}
const onNavSwipeEnd = (dx: number) => {
if (dx < 0) {
if (store.nav.tab.canGoBack) {
store.nav.tab.goBack()
} else {
store.shell.setMainMenuOpen(true)
}
} else if (dx > 0) {
if (isMenuActive) {
store.shell.setMainMenuOpen(false)
}
}
setMenuSwipingDirection(0)
}
const swipeTranslateX = Animated.multiply(
swipeGestureInterp,
winDim.width * -1,
)
const swipeTransform = store.nav.tab.canGoBack
? {transform: [{translateX: swipeTranslateX}]}
: undefined
let shouldRenderMenu = false
let menuTranslateX
const menuDrawerWidth = winDim.width - 100
if (isMenuActive) {
// menu is active, interpret swipes as closes
menuTranslateX = Animated.multiply(swipeGestureInterp, menuDrawerWidth * -1)
shouldRenderMenu = true
} else if (!store.nav.tab.canGoBack) {
// at back of history, interpret swipes as opens
menuTranslateX = Animated.subtract(
menuDrawerWidth * -1,
Animated.multiply(swipeGestureInterp, menuDrawerWidth),
)
shouldRenderMenu = true
}
const menuSwipeTransform = menuTranslateX
? {
transform: [{translateX: menuTranslateX}],
}
: undefined
const swipeOpacity = {
opacity: swipeGestureInterp.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0.6, 0],
}),
}
const menuSwipeOpacity =
menuSwipingDirection !== 0
? {
opacity: swipeGestureInterp.interpolate({
inputRange: menuSwipingDirection > 0 ? [0, 1] : [-1, 0],
outputRange: [0.6, 0],
}),
}
: undefined
if (store.hackUpgradeNeeded) {
return (
<View style={styles.outerContainer}>
<View style={[s.flexCol, s.p20, s.h100pct]}>
<View style={s.flex1} />
<View>
<Text type="title-2xl" style={s.pb10}>
Update required
</Text>
<Text style={[s.pb20, s.bold]}>
Please update your app to the latest version. If no update is
available yet, please check the App Store in a day or so.
</Text>
<Text type="title" style={s.pb10}>
What's happening?
</Text>
<Text style={s.pb10}>
We're in the final stages of the AT Protocol's v1 development. To
make sure everything works as well as possible, we're making final
breaking changes to the APIs.
</Text>
<Text>
If we didn't botch this process, a new version of the app should
be available now.
</Text>
</View>
<View style={s.flex1} />
<View style={s.footerSpacer} />
</View>
</View>
)
}
if (!store.session.hasSession) {
return (
<View style={styles.outerContainer}>
<StatusBar
barStyle={
theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
}
/>
<Login />
<ModalsContainer />
</View>
)
}
const screenBg = {
backgroundColor: theme.colorScheme === 'dark' ? colors.black : colors.gray1,
}
return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar
barStyle={
theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'
}
/>
<View style={[styles.innerContainer, {paddingTop: safeAreaInsets.top}]}>
<HorzSwipe
distThresholdDivisor={2.5}
useNativeDriver
panX={swipeGestureInterp}
swipeEnabled
canSwipeLeft={canSwipeLeft}
canSwipeRight={canSwipeRight}
onSwipeStartDirection={onNavSwipeStartDirection}
onSwipeEnd={onNavSwipeEnd}>
<ScreenContainer style={styles.screenContainer}>
{screenRenderDesc.screens.map(
({Com, navIdx, params, key, current, previous}) => {
if (isMenuActive) {
// HACK menu is active, treat current as previous
if (previous) {
previous = false
} else if (current) {
current = false
previous = true
}
}
return (
<Screen
key={key}
style={[StyleSheet.absoluteFill]}
activityState={current ? 2 : previous ? 1 : 0}>
<Animated.View
style={
current ? [styles.screenMask, swipeOpacity] : undefined
}
/>
<Animated.View
style={[
s.h100pct,
screenBg,
current ? [swipeTransform] : undefined,
]}>
<ErrorBoundary>
<Com
params={params}
navIdx={navIdx}
visible={current}
/>
</ErrorBoundary>
</Animated.View>
</Screen>
)
},
)}
</ScreenContainer>
<BottomBar />
{isMenuActive || menuSwipingDirection !== 0 ? (
<TouchableWithoutFeedback
onPress={() => store.shell.setMainMenuOpen(false)}>
<Animated.View style={[styles.screenMask, menuSwipeOpacity]} />
</TouchableWithoutFeedback>
) : undefined}
{shouldRenderMenu && (
<Animated.View style={[styles.menuDrawer, menuSwipeTransform]}>
<Menu onClose={() => store.shell.setMainMenuOpen(false)} />
</Animated.View>
)}
</HorzSwipe>
</View>
<ModalsContainer />
<Lightbox />
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}
winHeight={winDim.height}
replyTo={store.shell.composerOpts?.replyTo}
imagesOpen={store.shell.composerOpts?.imagesOpen}
onPost={store.shell.composerOpts?.onPost}
quote={store.shell.composerOpts?.quote}
/>
</View>
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {
key: string
navIdx: string
current: boolean
previous: boolean
isNewTab: boolean
}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
hasNewTab: boolean
screens: ScreenRenderDesc[]
} {
let hasNewTab = false
let icon: IconProp = 'magnifying-glass'
let screens: ScreenRenderDesc[] = []
for (const tab of nav.tabs) {
const tabScreens = [
...tab.getBackList(5),
Object.assign({}, tab.current, {index: tab.index}),
]
const parsedTabScreens = tabScreens.map(screen => {
const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
hasNewTab = hasNewTab || tab.isNewTab
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
navIdx: `${tab.id}-${screen.id}`,
current: isCurrent,
previous: isPrevious,
isNewTab: tab.isNewTab,
}) as ScreenRenderDesc
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
hasNewTab,
screens,
}
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
innerContainer: {
height: '100%',
},
screenContainer: {
height: '100%',
},
screenMask: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#000',
opacity: 0.6,
},
menuDrawer: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 100,
},
topBarProtector: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 50, // will be overwritten by insets
backgroundColor: colors.white,
},
topBarProtectorDark: {
backgroundColor: colors.black,
},
})

View file

@ -1,222 +0,0 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {usePalette} from 'lib/hooks/usePalette'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {useStores} from 'state/index'
import {colors} from 'lib/styles'
import {
ComposeIcon,
HomeIcon,
HomeIconSolid,
BellIcon,
BellIconSolid,
MagnifyingGlassIcon,
CogIcon,
} from 'lib/icons'
import {DesktopSearch} from './DesktopSearch'
interface NavItemProps {
count?: number
href: string
icon: JSX.Element
iconFilled: JSX.Element
isProfile?: boolean
}
export const NavItem = observer(
({count, href, icon, iconFilled}: NavItemProps) => {
const store = useStores()
const hoverBg = useColorSchemeStyle(
styles.navItemHoverBgLight,
styles.navItemHoverBgDark,
)
const isCurrent = store.nav.tab.current.url === href
const onPress = () => store.nav.navigate(href)
return (
<Pressable
style={state => [
styles.navItem,
// @ts-ignore Pressable state differs for RNW -prf
(state.hovered || isCurrent) && hoverBg,
]}
onPress={onPress}>
<View style={[styles.navItemIconWrapper]}>
{isCurrent ? iconFilled : icon}
{typeof count === 'number' && count > 0 && (
<Text type="button" style={styles.navItemCount}>
{count}
</Text>
)}
</View>
</Pressable>
)
},
)
export const ProfileItem = observer(() => {
const store = useStores()
const hoverBg = useColorSchemeStyle(
styles.navItemHoverBgLight,
styles.navItemHoverBgDark,
)
const href = `/profile/${store.me.handle}`
const isCurrent = store.nav.tab.current.url === href
const onPress = () => store.nav.navigate(href)
return (
<Pressable
style={state => [
styles.navItem,
// @ts-ignore Pressable state differs for RNW -prf
(state.hovered || isCurrent) && hoverBg,
]}
onPress={onPress}>
<View style={[styles.navItemIconWrapper]}>
<UserAvatar
handle={store.me.handle}
displayName={store.me.displayName}
avatar={store.me.avatar}
size={28}
/>
</View>
</Pressable>
)
})
export const DesktopHeader = observer(function DesktopHeader({}: {
canGoBack?: boolean
}) {
const store = useStores()
const pal = usePalette('default')
const onPressCompose = () => store.shell.openComposer({})
return (
<View style={[styles.header, pal.borderDark, pal.view]}>
<Text type="title-xl" style={[pal.text, styles.title]}>
Bluesky
</Text>
<View style={styles.space30} />
<NavItem
href="/"
icon={<HomeIcon size={24} />}
iconFilled={<HomeIconSolid size={24} />}
/>
<View style={styles.space15} />
<NavItem
href="/search"
icon={<MagnifyingGlassIcon size={24} />}
iconFilled={<MagnifyingGlassIcon strokeWidth={3} size={24} />}
/>
<View style={styles.space15} />
<NavItem
href="/notifications"
count={store.me.notifications.unreadCount}
icon={<BellIcon size={24} />}
iconFilled={<BellIconSolid size={24} />}
/>
<View style={styles.spaceFlex} />
<TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}>
<View style={styles.newPostBtnIconWrapper}>
<ComposeIcon
size={16}
strokeWidth={2}
style={styles.newPostBtnLabel}
/>
</View>
<Text type="md" style={styles.newPostBtnLabel}>
New Post
</Text>
</TouchableOpacity>
<View style={styles.space20} />
<DesktopSearch />
<View style={styles.space15} />
<ProfileItem />
<NavItem
href="/settings"
icon={<CogIcon strokeWidth={2} size={28} />}
iconFilled={<CogIcon strokeWidth={2.5} size={28} />}
/>
</View>
)
})
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
// paddingTop: 18,
// paddingBottom: 18,
paddingLeft: 30,
paddingRight: 40,
borderBottomWidth: 1,
zIndex: 1,
},
spaceFlex: {
flex: 1,
},
space15: {
width: 15,
},
space20: {
width: 20,
},
space30: {
width: 30,
},
title: {},
navItem: {
paddingTop: 14,
paddingBottom: 10,
paddingHorizontal: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
navItemHoverBgLight: {
borderBottomWidth: 2,
borderBottomColor: colors.blue3,
},
navItemHoverBgDark: {
borderBottomWidth: 2,
backgroundColor: colors.blue3,
},
navItemIconWrapper: {
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
marginBottom: 2,
},
navItemCount: {
position: 'absolute',
top: 0,
left: 15,
backgroundColor: colors.red3,
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
paddingHorizontal: 4,
borderRadius: 6,
},
newPostBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 24,
paddingTop: 8,
paddingBottom: 8,
paddingHorizontal: 18,
backgroundColor: colors.blue3,
},
newPostBtnIconWrapper: {
marginRight: 8,
},
newPostBtnLabel: {
color: colors.white,
},
})

View file

@ -1,150 +0,0 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {View, StyleSheet} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {useStores} from 'state/index'
import {NavigationModel} from 'state/models/navigation'
import {match, MatchResult} from '../../routes'
import {DesktopHeader} from './DesktopHeader'
import {Login} from '../../screens/Login'
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
import {Lightbox} from '../../com/lightbox/Lightbox'
import {ModalsContainer} from '../../com/modals/Modal'
import {Text} from 'view/com/util/text/Text'
import {Composer} from './Composer'
import {usePalette} from 'lib/hooks/usePalette'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {s, colors} from 'lib/styles'
import {isMobileWeb} from 'platform/detection'
export const WebShell: React.FC = observer(() => {
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const store = useStores()
const screenRenderDesc = constructScreenRenderDesc(store.nav)
if (isMobileWeb) {
return <NoMobileWeb />
}
if (!store.session.hasSession) {
return (
<View style={styles.outerContainer}>
<Login />
<ModalsContainer />
</View>
)
}
return (
<View style={[styles.outerContainer, pageBg]}>
<DesktopHeader />
{screenRenderDesc.screens.map(({Com, navIdx, params, key, current}) => (
<View
key={key}
style={[s.hContentRegion, current ? styles.visible : styles.hidden]}>
<ErrorBoundary>
<Com params={params} navIdx={navIdx} visible={current} />
</ErrorBoundary>
</View>
))}
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}
winHeight={0}
replyTo={store.shell.composerOpts?.replyTo}
imagesOpen={store.shell.composerOpts?.imagesOpen}
onPost={store.shell.composerOpts?.onPost}
/>
<ModalsContainer />
<Lightbox />
</View>
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {
key: string
navIdx: string
current: boolean
previous: boolean
isNewTab: boolean
}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
hasNewTab: boolean
screens: ScreenRenderDesc[]
} {
let hasNewTab = false
let icon: IconProp = 'magnifying-glass'
let screens: ScreenRenderDesc[] = []
for (const tab of nav.tabs) {
const tabScreens = [
...tab.getBackList(5),
Object.assign({}, tab.current, {index: tab.index}),
]
const parsedTabScreens = tabScreens.map(screen => {
const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
hasNewTab = hasNewTab || tab.isNewTab
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
navIdx: `${tab.id}-${screen.id}`,
current: isCurrent,
previous: isPrevious,
isNewTab: tab.isNewTab,
}) as ScreenRenderDesc
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
hasNewTab,
screens,
}
}
function NoMobileWeb() {
const pal = usePalette('default')
return (
<View style={[pal.view, styles.noMobileWeb]}>
<Text type="title-2xl" style={s.pb20}>
We're so sorry!
</Text>
<Text type="lg">
This app is not available for mobile Web yet. Please open it on your
desktop or download the iOS app.
</Text>
</View>
)
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
bgLight: {
backgroundColor: colors.white,
},
bgDark: {
backgroundColor: colors.black, // TODO
},
visible: {
display: 'flex',
},
hidden: {
display: 'none',
},
noMobileWeb: {
height: '100%',
justifyContent: 'center',
paddingHorizontal: 20,
paddingBottom: 40,
},
})