Refined posting part

This commit is contained in:
Zhiyuan Zheng 2020-11-15 20:29:43 +01:00
parent 6804e3bde6
commit 8013f6b9a2
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
10 changed files with 595 additions and 306 deletions

View File

@ -17,7 +17,6 @@
"@react-navigation/native": "^5.8.6",
"@react-navigation/stack": "^5.12.3",
"@reduxjs/toolkit": "^1.4.0",
"autolinker": "./src/modules/autolinker",
"expo": "~39.0.4",
"expo-app-auth": "~9.2.0",
"expo-av": "~8.6.0",
@ -42,7 +41,8 @@
"react-native-webview": "10.7.0",
"react-navigation": "^4.4.3",
"react-query": "^2.26.2",
"react-redux": "^7.2.2"
"react-redux": "^7.2.2",
"tslib": "^2.0.3"
},
"devDependencies": {
"@babel/core": "~7.12.3",

View File

@ -7,6 +7,7 @@ import React from 'react'
import { Feather } from '@expo/vector-icons'
import store from 'src/stacks/common/store'
import { Provider } from 'react-redux'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import Toast from 'react-native-toast-message'
import { StatusBar } from 'expo-status-bar'
@ -24,64 +25,66 @@ export const Index: React.FC = () => {
<Provider store={store}>
<StatusBar style='auto' />
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let name: string
switch (route.name) {
case 'Local':
name = 'home'
break
case 'Public':
name = 'globe'
break
case 'PostRoot':
name = 'plus'
break
case 'Notifications':
name = 'bell'
break
case 'Me':
name = focused ? 'smile' : 'meh'
break
default:
name = 'alert-octagon'
break
}
return <Feather name={name} size={size} color={color} />
}
})}
tabBarOptions={{
activeTintColor: 'black',
inactiveTintColor: 'gray',
showLabel: false
}}
>
<Tab.Screen name='Local' component={Local} />
<Tab.Screen name='Public' component={Public} />
<Tab.Screen
name='PostRoot'
component={PostRoot}
listeners={({ navigation, route }) => ({
tabPress: e => {
e.preventDefault()
const {
length,
[length - 1]: last
} = navigation.dangerouslyGetState().history
navigation.navigate(last.key.split(new RegExp(/(.*?)-/))[1], {
screen: 'PostToot'
})
<SafeAreaProvider>
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let name: string
switch (route.name) {
case 'Local':
name = 'home'
break
case 'Public':
name = 'globe'
break
case 'PostRoot':
name = 'plus'
break
case 'Notifications':
name = 'bell'
break
case 'Me':
name = focused ? 'smile' : 'meh'
break
default:
name = 'alert-octagon'
break
}
return <Feather name={name} size={size} color={color} />
}
})}
/>
<Tab.Screen name='Notifications' component={Notifications} />
<Tab.Screen name='Me' component={Me} />
</Tab.Navigator>
tabBarOptions={{
activeTintColor: 'black',
inactiveTintColor: 'gray',
showLabel: false
}}
>
<Tab.Screen name='Local' component={Local} />
<Tab.Screen name='Public' component={Public} />
<Tab.Screen
name='PostRoot'
component={PostRoot}
listeners={({ navigation, route }) => ({
tabPress: e => {
e.preventDefault()
const {
length,
[length - 1]: last
} = navigation.dangerouslyGetState().history
navigation.navigate(last.key.split(new RegExp(/(.*?)-/))[1], {
screen: 'PostToot'
})
}
})}
/>
<Tab.Screen name='Notifications' component={Notifications} />
<Tab.Screen name='Me' component={Me} />
</Tab.Navigator>
<Toast ref={(ref: any) => Toast.setRef(ref)} />
</NavigationContainer>
<Toast ref={(ref: any) => Toast.setRef(ref)} />
</NavigationContainer>
</SafeAreaProvider>
</Provider>
)
}

View File

@ -6,7 +6,7 @@ import Emojis from './Emojis'
export interface Props {
poll: mastodon.Poll
}
// When haven't voted, result should not be shown but intead let people vote
const Poll: React.FC<Props> = ({ poll }) => {
return (
<View>

View File

@ -1,235 +1,20 @@
import { Feather } from '@expo/vector-icons'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import React from 'react'
import { Alert, Pressable, Text } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Autolinker from 'src/modules/autolinker'
import { useNavigation } from '@react-navigation/native'
import { debounce, differenceWith, isEqual } from 'lodash'
import { searchFetch } from '../common/searchFetch'
import { useQuery } from 'react-query'
import { FlatList } from 'react-native-gesture-handler'
import PostMain from './PostToot/PostMain'
const Stack = createNativeStackNavigator()
export interface Tag {
type: 'url' | 'accounts' | 'hashtags'
text: string
}
const Suggestion = React.memo(({ item, index }) => {
return (
<View key={index}>
<Text>{item.acct ? item.acct : item.name}</Text>
</View>
)
})
const Suggestions = ({
type,
text
}: {
type: 'mention' | 'hashtag'
text: string
}) => {
const { status, data } = useQuery(
[
'Search',
{ type: type === 'mention' ? 'accounts' : 'hashtags', term: text }
],
searchFetch,
{ retry: false }
)
let content
switch (status) {
case 'success':
content = data[type === 'mention' ? 'accounts' : 'hashtags'].length ? (
<FlatList
data={data[type === 'mention' ? 'accounts' : 'hashtags']}
renderItem={({ item, index, separators }) => (
<Suggestion item={item} index={index} />
)}
/>
) : (
<Text></Text>
)
break
case 'loading':
content = <ActivityIndicator />
break
case 'error':
content = <Text></Text>
break
default:
content = <></>
}
return content
}
const PostTootMain = () => {
const [viewHeight, setViewHeight] = useState(0)
const [contentHeight, setContentHeight] = useState(0)
const [keyboardHeight, setKeyboardHeight] = useState(0)
useEffect(() => {
Keyboard.addListener('keyboardDidShow', _keyboardDidShow)
Keyboard.addListener('keyboardDidHide', _keyboardDidHide)
// cleanup function
return () => {
Keyboard.removeListener('keyboardDidShow', _keyboardDidShow)
Keyboard.removeListener('keyboardDidHide', _keyboardDidHide)
}
}, [])
const _keyboardDidShow = (props: any) => {
setKeyboardHeight(props.endCoordinates.height)
}
const _keyboardDidHide = () => {
setKeyboardHeight(0)
}
const [charCount, setCharCount] = useState(0)
const [formattedText, setFormattedText] = useState<React.ReactNode>()
const [suggestionsShown, setSuggestionsShown] = useState({
display: false,
tag: undefined
})
const debouncedSuggestions = useCallback(
debounce(tag => setSuggestionsShown({ display: true, tag }), 300),
[]
)
let prevTags: Tag[] = []
const onChangeText = useCallback(content => {
const tags: Tag[] = []
Autolinker.link(content, {
email: false,
phone: false,
mention: 'mastodon',
hashtag: 'twitter',
replaceFn: props => {
let type = props.getType()
switch (type) {
case 'mention':
type = 'accounts'
break
case 'hashtag':
type = 'hashtags'
break
}
// @ts-ignore
tags.push({ type: type, text: props.getMatchedText() })
return
}
})
const changedTag = differenceWith(prevTags, tags, isEqual)
if (changedTag.length && tags.length !== 0) {
if (changedTag[0].type !== 'url') {
debouncedSuggestions(changedTag[0])
}
} else {
setSuggestionsShown({ display: false, tag: undefined })
}
prevTags = tags
let _content = content
let contentLength: number = 0
const children = []
tags.forEach(tag => {
const parts = _content.split(tag.text)
const prevPart = parts.shift()
children.push(prevPart)
contentLength = contentLength + prevPart.length
children.push(
<Text style={{ color: 'red' }} key={Math.random()}>
{tag.text}
</Text>
)
switch (tag.type) {
case 'url':
contentLength = contentLength + 23
break
case 'accounts':
contentLength =
contentLength + tag.text.split(new RegExp('(@.*)@?'))[1].length
break
case 'hashtags':
contentLength = contentLength + tag.text.length
break
}
_content = parts.join()
})
children.push(_content)
contentLength = contentLength + _content.length
setFormattedText(React.createElement(Text, null, children))
setCharCount(500 - contentLength)
}, [])
return (
<View
style={styles.main}
onLayout={({ nativeEvent }) => setViewHeight(nativeEvent.layout.height)}
>
<View style={{ height: viewHeight - keyboardHeight }}>
<TextInput
style={[
styles.textInput,
{
flex: suggestionsShown.display ? 0 : 1,
minHeight: contentHeight + 14
}
]}
autoCapitalize='none'
autoCorrect={false}
autoFocus
enablesReturnKeyAutomatically
multiline
placeholder='想说点什么'
onChangeText={onChangeText}
onContentSizeChange={({ nativeEvent }) => {
setContentHeight(nativeEvent.contentSize.height)
}}
scrollEnabled
>
<Text>{formattedText}</Text>
</TextInput>
{suggestionsShown.display ? (
<View style={[styles.suggestions]}>
<Suggestions {...suggestionsShown.tag} />
</View>
) : (
<></>
)}
<Pressable style={styles.additions} onPress={() => Keyboard.dismiss()}>
<Feather name='paperclip' size={24} />
<Feather name='bar-chart-2' size={24} />
<Feather name='eye-off' size={24} />
<Text>{charCount}</Text>
</Pressable>
</View>
</View>
)
}
const PostToot: React.FC = () => {
const navigation = useNavigation()
return (
<Stack.Navigator>
<Stack.Screen
name='PostTootMain'
component={PostTootMain}
name='PostMain'
component={PostMain}
options={{
headerLeft: () => (
<Pressable
@ -259,22 +44,4 @@ const PostToot: React.FC = () => {
)
}
const styles = StyleSheet.create({
main: {
flex: 1
},
textInput: {
backgroundColor: 'gray'
},
suggestions: {
flex: 1,
backgroundColor: 'lightyellow'
},
additions: {
height: 44,
backgroundColor: 'red',
flexDirection: 'row'
}
})
export default PostToot

View File

@ -0,0 +1,44 @@
import React, { Dispatch } from 'react'
import { Image, Pressable } from 'react-native'
import { PostAction, PostState } from './PostMain'
import updateText from './updateText'
export interface Props {
onChangeText: any
postState: PostState
postDispatch: Dispatch<PostAction>
}
const PostEmojis: React.FC<Props> = ({
onChangeText,
postState,
postDispatch
}) => {
return (
<>
{postState.emojis?.map((emoji, index) => (
<Pressable
key={index}
onPress={() => {
updateText({
onChangeText,
postState,
newText: `:${emoji.shortcode}:`
})
postDispatch({ type: 'overlay', payload: null })
}}
>
<Image
key={index}
source={{ uri: emoji.url }}
style={{ width: 24, height: 24 }}
/>
</Pressable>
))}
</>
)
}
export default PostEmojis

View File

@ -0,0 +1,349 @@
import React, {
createElement,
ReactNode,
useCallback,
useEffect,
useReducer
} from 'react'
import {
Keyboard,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Feather } from '@expo/vector-icons'
import { debounce, differenceWith, isEqual } from 'lodash'
import Autolinker from 'src/modules/autolinker'
import PostSuggestions from './PostSuggestions'
import PostEmojis from './PostEmojis'
import { useQuery } from 'react-query'
import { emojisFetch } from 'src/stacks/common/emojisFetch'
export type PostState = {
text: {
count: number
raw: string
formatted: ReactNode
}
selection: { start: number; end: number }
overlay: null | 'suggestions' | 'emojis'
tag:
| {
type: 'url' | 'accounts' | 'hashtags'
text: string
offset: number
}
| undefined
emojis: mastodon.Emoji[] | undefined
height: {
view: number
editor: number
keyboard: number
}
}
export type PostAction =
| {
type: 'text'
payload: Partial<PostState['text']>
}
| {
type: 'selection'
payload: PostState['selection']
}
| {
type: 'overlay'
payload: PostState['overlay']
}
| {
type: 'tag'
payload: PostState['tag']
}
| {
type: 'emojis'
payload: PostState['emojis']
}
| {
type: 'height'
payload: Partial<PostState['height']>
}
const postInitialState: PostState = {
text: {
count: 0,
raw: '',
formatted: undefined
},
selection: { start: 0, end: 0 },
overlay: null,
tag: undefined,
emojis: undefined,
height: {
view: 0,
editor: 0,
keyboard: 0
}
}
const postReducer = (state: PostState, action: PostAction): PostState => {
switch (action.type) {
case 'text':
return { ...state, text: { ...state.text, ...action.payload } }
case 'selection':
return { ...state, selection: action.payload }
case 'overlay':
return { ...state, overlay: action.payload }
case 'tag':
return { ...state, tag: action.payload }
case 'emojis':
return { ...state, emojis: action.payload }
case 'height':
return { ...state, height: { ...state.height, ...action.payload } }
default:
throw new Error('Unexpected action')
}
}
const PostMain = () => {
const [postState, postDispatch] = useReducer(postReducer, postInitialState)
useEffect(() => {
Keyboard.addListener('keyboardDidShow', _keyboardDidShow)
Keyboard.addListener('keyboardDidHide', _keyboardDidHide)
return () => {
Keyboard.removeListener('keyboardDidShow', _keyboardDidShow)
Keyboard.removeListener('keyboardDidHide', _keyboardDidHide)
}
}, [])
const _keyboardDidShow = (props: any) => {
postDispatch({
type: 'height',
payload: { keyboard: props.endCoordinates.height }
})
}
const _keyboardDidHide = () => {
postDispatch({
type: 'height',
payload: { keyboard: 0 }
})
}
const { data: emojisData } = useQuery(['Emojis'], emojisFetch)
useEffect(() => {
if (emojisData && emojisData.length) {
postDispatch({ type: 'emojis', payload: emojisData })
}
}, [emojisData])
const debouncedSuggestions = useCallback(
debounce(
tag =>
(() => {
console.log(tag)
postDispatch({ type: 'overlay', payload: 'suggestions' })
postDispatch({ type: 'tag', payload: tag })
})(),
300
),
[]
)
let prevTags: PostState['tag'][] = []
const onChangeText = useCallback(({ content, disableDebounce }) => {
const tags: PostState['tag'][] = []
Autolinker.link(content, {
email: false,
phone: false,
mention: 'mastodon',
hashtag: 'twitter',
replaceFn: props => {
const type = props.getType()
let newType: 'url' | 'accounts' | 'hashtags'
switch (type) {
case 'mention':
newType = 'accounts'
break
case 'hashtag':
newType = 'hashtags'
break
default:
newType = 'url'
break
}
// @ts-ignore
tags.push({
type: newType,
text: props.getMatchedText(),
offset: props.getOffset()
})
return
}
})
const changedTag = differenceWith(prevTags, tags, isEqual)
// quick delete causes flicking of suggestion box
if (changedTag.length && tags.length && !disableDebounce) {
if (changedTag[0]!.type !== 'url') {
debouncedSuggestions(changedTag[0])
}
} else {
postDispatch({ type: 'overlay', payload: null })
postDispatch({ type: 'tag', payload: undefined })
}
prevTags = tags
let _content = content
let contentLength: number = 0
const children = []
tags.forEach(tag => {
const parts = _content.split(tag!.text)
const prevPart = parts.shift()
children.push(prevPart)
contentLength = contentLength + prevPart.length
children.push(
<Text style={{ color: 'red' }} key={Math.random()}>
{tag!.text}
</Text>
)
switch (tag!.type) {
case 'url':
contentLength = contentLength + 23
break
case 'accounts':
contentLength =
contentLength + tag!.text.split(new RegExp('(@.*)@?'))[1].length
break
case 'hashtags':
contentLength = contentLength + tag!.text.length
break
}
_content = parts.join()
})
children.push(_content)
contentLength = contentLength + _content.length
postDispatch({
type: 'text',
payload: {
count: 500 - contentLength,
raw: content,
formatted: createElement(Text, null, children)
}
})
}, [])
return (
<SafeAreaView
style={styles.main}
edges={['left', 'right', 'bottom']}
onLayout={({ nativeEvent }) =>
postDispatch({
type: 'height',
payload: { view: nativeEvent.layout.height }
})
}
>
<View
style={{ height: postState.height.view - postState.height.keyboard }}
>
<TextInput
style={[
styles.textInput,
{
flex: postState.overlay ? 0 : 1,
minHeight: postState.height.editor + 14
}
]}
autoCapitalize='none'
autoCorrect={false}
autoFocus
enablesReturnKeyAutomatically
multiline
placeholder='想说点什么'
onChangeText={content => onChangeText({ content })}
onContentSizeChange={({ nativeEvent }) => {
postDispatch({
type: 'height',
payload: { editor: nativeEvent.contentSize.height }
})
}}
onSelectionChange={({
nativeEvent: {
selection: { start, end }
}
}) => {
postDispatch({ type: 'selection', payload: { start, end } })
}}
scrollEnabled
>
<Text>{postState.text.formatted}</Text>
</TextInput>
{postState.overlay === 'suggestions' ? (
<View style={[styles.suggestions]}>
<PostSuggestions
onChangeText={onChangeText}
postState={postState}
postDispatch={postDispatch}
/>
</View>
) : (
<></>
)}
{postState.overlay === 'emojis' ? (
<View style={[styles.emojis]}>
<PostEmojis
onChangeText={onChangeText}
postState={postState}
postDispatch={postDispatch}
/>
</View>
) : (
<></>
)}
<Pressable style={styles.additions} onPress={() => Keyboard.dismiss()}>
<Feather name='paperclip' size={24} />
<Feather name='bar-chart-2' size={24} />
<Feather name='eye-off' size={24} />
<Feather
name='smile'
size={24}
color={postState.emojis?.length ? 'black' : 'white'}
onPress={() => {
if (postState.emojis?.length && postState.overlay === null) {
postDispatch({ type: 'overlay', payload: 'emojis' })
}
}}
/>
<Text>{postState.text.count}</Text>
</Pressable>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
main: {
flex: 1
},
textInput: {
backgroundColor: 'gray'
},
suggestions: {
flex: 1,
backgroundColor: 'lightyellow'
},
emojis: {
flex: 1,
backgroundColor: 'lightblue'
},
additions: {
height: 44,
backgroundColor: 'red',
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center'
}
})
export default PostMain

View File

@ -0,0 +1,97 @@
import React, { Dispatch } from 'react'
import { ActivityIndicator, Pressable, Text } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { useQuery } from 'react-query'
import { searchFetch } from '../../common/searchFetch'
import { PostAction, PostState } from './PostMain'
import updateText from './updateText'
declare module 'react' {
function memo<A, B> (
Component: (props: A) => B
): (props: A) => ReactElement | null
}
const Suggestion = React.memo(
({ onChangeText, postState, postDispatch, item, index }) => {
return (
<Pressable
key={index}
onPress={() => {
updateText({
onChangeText,
postState: {
...postState,
selection: {
start: postState.tag.offset,
end: postState.tag.offset + postState.tag.text.length + 1
}
},
newText: `@${item.acct ? item.acct : item.name} `
})
postDispatch({ type: 'overlay', payload: null })
}}
>
<Text>{item.acct ? item.acct : item.name}</Text>
</Pressable>
)
}
)
export interface Props {
onChangeText: any
postState: PostState
postDispatch: Dispatch<PostAction>
}
const PostSuggestions: React.FC<Props> = ({
onChangeText,
postState,
postDispatch
}) => {
if (!postState.tag) {
return <></>
}
const { status, data } = useQuery(
['Search', { type: postState.tag.type, term: postState.tag.text }],
searchFetch,
{ retry: false }
)
let content
switch (status) {
case 'success':
content = data[postState.tag.type].length ? (
<FlatList
data={data[postState.tag.type]}
renderItem={({ item, index, separators }) => (
<Suggestion
onChangeText={onChangeText}
postState={postState}
postDispatch={postDispatch}
item={item}
index={index}
/>
)}
/>
) : (
<Text></Text>
)
break
case 'loading':
content = <ActivityIndicator />
break
case 'error':
content = <Text></Text>
break
default:
content = <></>
}
return content
}
export default PostSuggestions

View File

@ -0,0 +1,24 @@
import { PostState } from './PostMain'
const updateText = ({
onChangeText,
postState,
newText
}: {
onChangeText: any
postState: PostState
newText: string
}) => {
onChangeText({
content: postState.text.raw
? [
postState.text.raw.slice(0, postState.selection.start),
newText,
postState.text.raw.slice(postState.selection.end)
].join('')
: newText,
disableDebounce: true
})
}
export default updateText

View File

@ -0,0 +1,10 @@
import client from 'src/api/client'
export const emojisFetch = async () => {
const res = await client({
method: 'get',
instance: 'local',
endpoint: 'custom_emojis'
})
return Promise.resolve(res.body)
}

View File

@ -1670,11 +1670,6 @@ atob@^2.1.2:
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
autolinker@./src/modules/autolinker:
version "3.14.2"
dependencies:
tslib "^1.9.3"
available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5"
@ -5796,10 +5791,10 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
type-fest@^0.7.1:
version "0.7.1"