Implement basic web composer

zio/stable
Paul Frazee 2023-01-27 00:16:07 -06:00
parent 5961c26800
commit 99360f7bd9
11 changed files with 282 additions and 82 deletions

View File

@ -13,29 +13,10 @@
#app-root { display:flex; height:100%; } #app-root { display:flex; height:100%; }
/* Remove focus state on inputs */ /* Remove focus state on inputs */
input:focus { input:focus,
textarea:focus {
outline: 0; outline: 0;
} }
/* These styles are for src/view/com/modals/WebModal */
div[data-modal-overlay] {
position: fixed;
top: 0;
left: 0;
background: #0004;
width: 100vw;
height: 100vh;
}
div[data-modal-container] {
position: fixed;
top: 20vh;
left: calc(50vw - 300px);
width: 600px;
padding: 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 5px 10px #0005;
}
</style> </style>
</head> </head>
<body> <body>

View File

@ -11,25 +11,19 @@ import {
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native' } from 'react-native'
import PasteInput, {
PastedFile,
PasteInputRef,
} from '@mattermost/react-native-paste-input'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {useAnalytics} from '@segment/analytics-react-native' // import {useAnalytics} from '@segment/analytics-react-native' TODO
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
import {Autocomplete} from './Autocomplete' import {Autocomplete} from './Autocomplete'
import {ExternalEmbed} from './ExternalEmbed' import {ExternalEmbed} from './ExternalEmbed'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
// @ts-ignore no type definition -prf import {TextInput, TextInputRef} from './text-input/TextInput'
import ProgressCircle from 'react-native-progress/Circle' import {CharProgress} from './char-progress/CharProgress'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
@ -49,7 +43,6 @@ import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
const MAX_TEXT_LENGTH = 256 const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
@ -63,10 +56,10 @@ export const ComposePost = observer(function ComposePost({
onPost?: ComposerOpts['onPost'] onPost?: ComposerOpts['onPost']
onClose: () => void onClose: () => void
}) { }) {
const {track} = useAnalytics() // const {track} = useAnalytics() TODO
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const textInput = useRef<PasteInputRef>(null) const textInput = useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('') const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@ -80,7 +73,6 @@ export const ComposePost = observer(function ComposePost({
) )
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const autocompleteView = React.useMemo<UserAutocompleteViewModel>( const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
[store], [store],
@ -219,20 +211,19 @@ export const ComposePost = observer(function ComposePost({
} }
} }
} }
const onPaste = async (err: string | undefined, files: PastedFile[]) => { const onPaste = async (err: string | undefined, uris: string[]) => {
if (err) { if (err) {
return setError(cleanError(err)) return setError(cleanError(err))
} }
if (selectedPhotos.length >= 4) { if (selectedPhotos.length >= 4) {
return return
} }
const imgFile = files.find(file => /\.(jpe?g|png)$/.test(file.fileName)) const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (!imgFile) { if (imgUri) {
return const finalImgPath = await cropPhoto(imgUri)
}
const finalImgPath = await cropPhoto(imgFile.uri)
onSelectPhotos([...selectedPhotos, finalImgPath]) onSelectPhotos([...selectedPhotos, finalImgPath])
} }
}
const onPressCancel = () => hackfixOnClose() const onPressCancel = () => hackfixOnClose()
const onPressPublish = async () => { const onPressPublish = async () => {
if (isProcessing) { if (isProcessing) {
@ -257,9 +248,10 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.knownHandles, autocompleteView.knownHandles,
setProcessingState, setProcessingState,
) )
track('Create Post', { // TODO
imageCount: selectedPhotos.length, // track('Create Post', {
}) // imageCount: selectedPhotos.length,
// })
} catch (e: any) { } catch (e: any) {
setError(cleanError(e.message)) setError(cleanError(e.message))
setIsProcessing(false) setIsProcessing(false)
@ -276,7 +268,6 @@ export const ComposePost = observer(function ComposePost({
} }
const canPost = text.length <= MAX_TEXT_LENGTH const canPost = text.length <= MAX_TEXT_LENGTH
const progressColor = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
const selectTextInputLayout = const selectTextInputLayout =
selectedPhotos.length !== 0 selectedPhotos.length !== 0
@ -311,7 +302,7 @@ export const ComposePost = observer(function ComposePost({
<KeyboardAvoidingView <KeyboardAvoidingView
testID="composePostView" testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[pal.view, styles.outer]}> style={styles.outer}>
<TouchableWithoutFeedback onPressIn={onPressContainer}> <TouchableWithoutFeedback onPressIn={onPressContainer}>
<SafeAreaView style={s.flex1}> <SafeAreaView style={s.flex1}>
<View style={styles.topbar}> <View style={styles.topbar}>
@ -396,22 +387,19 @@ export const ComposePost = observer(function ComposePost({
avatar={store.me.avatar} avatar={store.me.avatar}
size={50} size={50}
/> />
<PasteInput <TextInput
testID="composerTextInput" testID="composerTextInput"
ref={textInput} innerRef={textInput}
multiline
scrollEnabled
onChangeText={(str: string) => onChangeText(str)} onChangeText={(str: string) => onChangeText(str)}
onPaste={onPaste} onPaste={onPaste}
placeholder={selectTextInputPlaceholder} placeholder={selectTextInputPlaceholder}
placeholderTextColor={pal.colors.textLight}
style={[ style={[
pal.text, pal.text,
styles.textInput, styles.textInput,
styles.textInputFormatting, styles.textInputFormatting,
]}> ]}>
{textDecorated} {textDecorated}
</PasteInput> </TextInput>
</View> </View>
<SelectedPhoto <SelectedPhoto
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
@ -450,31 +438,7 @@ export const ComposePost = observer(function ComposePost({
/> />
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> <View style={s.flex1} />
<Text style={[s.mr10, {color: progressColor}]}> <CharProgress count={text.length} />
{MAX_TEXT_LENGTH - text.length}
</Text>
<View>
{text.length > DANGER_TEXT_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min(
(text.length - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH,
1,
)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={text.length / MAX_TEXT_LENGTH}
/>
)}
</View>
</View> </View>
<Autocomplete <Autocomplete
active={autocompleteView.isActive} active={autocompleteView.isActive}
@ -504,7 +468,6 @@ const styles = StyleSheet.create({
flexDirection: 'column', flexDirection: 'column',
flex: 1, flex: 1,
padding: 15, padding: 15,
paddingBottom: Platform.OS === 'ios' ? 0 : 50,
height: '100%', height: '100%',
}, },
topbar: { topbar: {

View File

@ -0,0 +1,41 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../../util/text/Text'
// @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {s, colors} from '../../../lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
export function CharProgress({count}: {count: number}) {
const progressColor = count > DANGER_TEXT_LENGTH ? '#e60000' : undefined
return (
<>
<Text style={[s.mr10, {color: progressColor}]}>
{MAX_TEXT_LENGTH - count}
</Text>
<View>
{count > DANGER_TEXT_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={count / MAX_TEXT_LENGTH}
/>
)}
</View>
</>
)
}

View File

@ -0,0 +1,39 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../util/text/Text'
import {s} from '../../lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
export function CharProgress({count}: {count: number}) {
const progressColor = count > DANGER_TEXT_LENGTH ? '#e60000' : undefined
return (
<>
<Text style={[s.mr10, {color: progressColor}]}>
{MAX_TEXT_LENGTH - count}
</Text>
<View>
{
null /* TODO count > DANGER_TEXT_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={count / MAX_TEXT_LENGTH}
/>
)*/
}
</View>
</>
)
}

View File

@ -0,0 +1,54 @@
import React from 'react'
import {StyleProp, TextStyle} from 'react-native'
import PasteInput, {
PastedFile,
PasteInputRef,
} from '@mattermost/react-native-paste-input'
import {usePalette} from '../../../lib/hooks/usePalette'
export type TextInputRef = PasteInputRef
interface TextInputProps {
testID: string
innerRef: React.Ref<TextInputRef>
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onPaste: (err: string | undefined, uris: string[]) => void
}
export function TextInput({
testID,
innerRef,
placeholder,
style,
onChangeText,
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)}
onPaste={onPasteInner}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
style={style}>
{children}
</PasteInput>
)
}

View File

@ -0,0 +1,51 @@
import React from 'react'
import {
StyleProp,
StyleSheet,
TextInput as RNTextInput,
TextStyle,
} from 'react-native'
import {usePalette} from '../../lib/hooks/usePalette'
import {addStyle} from '../../lib/addStyle'
export type TextInputRef = RNTextInput
interface TextInputProps {
testID: string
innerRef: React.Ref<TextInputRef>
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onPaste: (err: string | undefined, uris: string[]) => void
}
export function TextInput({
testID,
innerRef,
placeholder,
style,
onChangeText,
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)}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
style={style}>
{children}
</RNTextInput>
)
}
const styles = StyleSheet.create({
input: {
minHeight: 140,
},
})

View File

@ -48,9 +48,6 @@ export const Composer = observer(
], ],
} }
// events
// =
// rendering // rendering
// = // =

View File

@ -0,0 +1,65 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import {ComposePost} from '../../com/composer/ComposePost'
import {ComposerOpts} from '../../../state/models/shell-ui'
import {usePalette} from '../../lib/hooks/usePalette'
export const Composer = observer(
({
active,
replyTo,
imagesOpen,
onPost,
onClose,
}: {
active: boolean
winHeight: number
replyTo?: ComposerOpts['replyTo']
imagesOpen?: ComposerOpts['imagesOpen']
onPost?: ComposerOpts['onPost']
onClose: () => void
}) => {
const pal = usePalette('default')
// rendering
// =
if (!active) {
return <View />
}
return (
<View style={styles.mask}>
<View style={[styles.container, pal.view]}>
<ComposePost
replyTo={replyTo}
imagesOpen={imagesOpen}
onPost={onPost}
onClose={onClose}
/>
</View>
</View>
)
},
)
const styles = StyleSheet.create({
mask: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000c',
alignItems: 'center',
justifyContent: 'center',
},
container: {
maxWidth: 600,
width: '100%',
paddingVertical: 0,
paddingHorizontal: 2,
borderRadius: 8,
},
})

View File

@ -3,13 +3,14 @@ import {observer} from 'mobx-react-lite'
import {View, StyleSheet} from 'react-native' import {View, StyleSheet} from 'react-native'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {match, MatchResult} from '../../routes' import {match, MatchResult} from '../../routes'
import {DesktopLeftColumn} from './left-column' import {DesktopLeftColumn} from './DesktopLeftColumn'
import {DesktopRightColumn} from './right-column' import {DesktopRightColumn} from './DesktopRightColumn'
import {Onboard} from '../../screens/Onboard' import {Onboard} from '../../screens/Onboard'
import {Login} from '../../screens/Login' import {Login} from '../../screens/Login'
import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {ErrorBoundary} from '../../com/util/ErrorBoundary'
import {Lightbox} from '../../com/lightbox/Lightbox' import {Lightbox} from '../../com/lightbox/Lightbox'
import {Modal} from '../../com/modals/Modal' import {Modal} from '../../com/modals/Modal'
import {Composer} from './Composer'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
@ -49,6 +50,14 @@ export const WebShell: React.FC = observer(() => {
))} ))}
<DesktopLeftColumn /> <DesktopLeftColumn />
<DesktopRightColumn /> <DesktopRightColumn />
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}
winHeight={0}
replyTo={store.shell.composerOpts?.replyTo}
imagesOpen={store.shell.composerOpts?.imagesOpen}
onPost={store.shell.composerOpts?.onPost}
/>
<Modal /> <Modal />
<Lightbox /> <Lightbox />
</View> </View>