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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue