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:
parent
503e03d91e
commit
56cf890deb
222 changed files with 8705 additions and 6338 deletions
|
@ -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,
|
|
@ -75,6 +75,7 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
},
|
||||
inner: {
|
||||
padding: 10,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import {
|
||||
Animated,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '../../util/text/Text'
|
||||
|
||||
interface AutocompleteItem {
|
||||
handle: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
active,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean
|
||||
items: AutocompleteItem[]
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const winDim = useWindowDimensions()
|
||||
const positionInterp = useAnimatedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(positionInterp, {
|
||||
toValue: active ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}).start()
|
||||
}, [positionInterp, active])
|
||||
|
||||
const topAnimStyle = {
|
||||
top: positionInterp.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [winDim.height, winDim.height / 4],
|
||||
}),
|
||||
}
|
||||
return (
|
||||
<Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}>
|
||||
{items.map((item, i) => (
|
||||
<TouchableOpacity
|
||||
testID="autocompleteButton"
|
||||
key={i}
|
||||
style={[pal.border, styles.item]}
|
||||
onPress={() => onSelect(item.handle)}>
|
||||
<Text type="md-medium" style={pal.text}>
|
||||
{item.displayName || item.handle}
|
||||
<Text type="sm" style={pal.textLight}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
item: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
})
|
|
@ -1,59 +0,0 @@
|
|||
import React from 'react'
|
||||
import {TouchableOpacity, StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '../../util/text/Text'
|
||||
|
||||
interface AutocompleteItem {
|
||||
handle: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
active,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean
|
||||
items: AutocompleteItem[]
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
if (!active) {
|
||||
return <View />
|
||||
}
|
||||
return (
|
||||
<View style={[styles.outer, pal.view, pal.border]}>
|
||||
{items.map((item, i) => (
|
||||
<TouchableOpacity
|
||||
testID="autocompleteButton"
|
||||
key={i}
|
||||
style={[pal.border, styles.item]}
|
||||
onPress={() => onSelect(item.handle)}>
|
||||
<Text type="md-medium" style={pal.text}>
|
||||
{item.displayName || item.handle}
|
||||
<Text type="sm" style={pal.textLight}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '100%',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
},
|
||||
item: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
})
|
84
src/view/com/composer/photos/OpenCameraBtn.tsx
Normal file
84
src/view/com/composer/photos/OpenCameraBtn.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -1,10 +0,0 @@
|
|||
import React from 'react'
|
||||
|
||||
// Not used on Web
|
||||
|
||||
export const PhotoCarouselPicker = (_opts: {
|
||||
selectedPhotos: string[]
|
||||
onSelectPhotos: (v: string[]) => void
|
||||
}) => {
|
||||
return <></>
|
||||
}
|
94
src/view/com/composer/photos/SelectPhotoBtn.tsx
Normal file
94
src/view/com/composer/photos/SelectPhotoBtn.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}: {
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
75
src/view/com/composer/text-input/mobile/Autocomplete.tsx
Normal file
75
src/view/com/composer/text-input/mobile/Autocomplete.tsx
Normal 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}>
|
||||
@{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,
|
||||
},
|
||||
})
|
157
src/view/com/composer/text-input/web/Autocomplete.tsx
Normal file
157
src/view/com/composer/text-input/web/Autocomplete.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
)
|
90
src/view/com/composer/useExternalLinkFetch.ts
Normal file
90
src/view/com/composer/useExternalLinkFetch.ts
Normal 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}
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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))}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {DesktopWebTextLink} from './Link'
|
||||
import {ago} from 'lib/strings/time'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
|
@ -12,6 +13,7 @@ interface PostMetaOpts {
|
|||
authorAvatar?: string
|
||||
authorHandle: string
|
||||
authorDisplayName: string | undefined
|
||||
postHref: string
|
||||
timestamp: string
|
||||
did?: string
|
||||
declarationCid?: string
|
||||
|
@ -20,8 +22,8 @@ interface PostMetaOpts {
|
|||
|
||||
export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||
const pal = usePalette('default')
|
||||
let displayName = opts.authorDisplayName || opts.authorHandle
|
||||
let handle = opts.authorHandle
|
||||
const displayName = opts.authorDisplayName || opts.authorHandle
|
||||
const handle = opts.authorHandle
|
||||
const store = useStores()
|
||||
const isMe = opts.did === store.me.did
|
||||
const isFollowing =
|
||||
|
@ -41,31 +43,35 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
|||
) {
|
||||
// two-liner with follow button
|
||||
return (
|
||||
<View style={[styles.metaTwoLine]}>
|
||||
<View style={styles.metaTwoLine}>
|
||||
<View>
|
||||
<Text
|
||||
type="lg-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{displayName}{' '}
|
||||
<Text
|
||||
<View style={styles.metaTwoLineTop}>
|
||||
<DesktopWebTextLink
|
||||
type="lg-bold"
|
||||
style={pal.text}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}
|
||||
text={displayName}
|
||||
href={`/profile/${opts.authorHandle}`}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight} lineHeight={1.2}>
|
||||
·
|
||||
</Text>
|
||||
<DesktopWebTextLink
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
lineHeight={1.2}>
|
||||
· {ago(opts.timestamp)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
lineHeight={1.2}
|
||||
text={ago(opts.timestamp)}
|
||||
href={opts.postHref}
|
||||
/>
|
||||
</View>
|
||||
<DesktopWebTextLink
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
lineHeight={1.2}>
|
||||
{handle ? (
|
||||
<Text type="md" style={[pal.textLight]}>
|
||||
@{handle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</Text>
|
||||
lineHeight={1.2}
|
||||
text={`@${handle}`}
|
||||
href={`/profile/${opts.authorHandle}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
|
@ -84,31 +90,36 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
|||
<View style={styles.meta}>
|
||||
{typeof opts.authorAvatar !== 'undefined' && (
|
||||
<View style={[styles.metaItem, styles.avatar]}>
|
||||
<UserAvatar
|
||||
avatar={opts.authorAvatar}
|
||||
handle={opts.authorHandle}
|
||||
displayName={opts.authorDisplayName}
|
||||
size={16}
|
||||
/>
|
||||
<UserAvatar avatar={opts.authorAvatar} size={16} />
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.metaItem, styles.maxWidth]}>
|
||||
<Text
|
||||
<DesktopWebTextLink
|
||||
type="lg-bold"
|
||||
style={[pal.text]}
|
||||
style={pal.text}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{displayName}
|
||||
{handle ? (
|
||||
<Text type="md" style={[pal.textLight]}>
|
||||
{handle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</Text>
|
||||
lineHeight={1.2}
|
||||
text={
|
||||
<>
|
||||
{displayName}
|
||||
<Text type="md" style={[pal.textLight]}>
|
||||
{handle}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
href={`/profile/${opts.authorHandle}`}
|
||||
/>
|
||||
</View>
|
||||
<Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}>
|
||||
· {ago(opts.timestamp)}
|
||||
<Text type="md" style={pal.textLight} lineHeight={1.2}>
|
||||
·
|
||||
</Text>
|
||||
<DesktopWebTextLink
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
lineHeight={1.2}
|
||||
text={ago(opts.timestamp)}
|
||||
href={opts.postHref}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
@ -125,6 +136,10 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'space-between',
|
||||
paddingBottom: 2,
|
||||
},
|
||||
metaTwoLineTop: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
metaItem: {
|
||||
paddingRight: 5,
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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...',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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%',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
|
@ -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}
|
|
@ -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
386
src/view/shell/Drawer.tsx
Normal 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')} ·{' '}
|
||||
<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,
|
||||
},
|
||||
})
|
254
src/view/shell/desktop/LeftNav.tsx
Normal file
254
src/view/shell/desktop/LeftNav.tsx
Normal 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',
|
||||
},
|
||||
})
|
46
src/view/shell/desktop/RightNav.tsx
Normal file
46
src/view/shell/desktop/RightNav.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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
139
src/view/shell/index.tsx
Normal 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%',
|
||||
},
|
||||
})
|
113
src/view/shell/index.web.tsx
Normal file
113
src/view/shell/index.web.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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')} ·{' '}
|
||||
<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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue