mirror of https://github.com/tooot-app/app
Refined posting part
This commit is contained in:
parent
6804e3bde6
commit
8013f6b9a2
|
@ -17,7 +17,6 @@
|
||||||
"@react-navigation/native": "^5.8.6",
|
"@react-navigation/native": "^5.8.6",
|
||||||
"@react-navigation/stack": "^5.12.3",
|
"@react-navigation/stack": "^5.12.3",
|
||||||
"@reduxjs/toolkit": "^1.4.0",
|
"@reduxjs/toolkit": "^1.4.0",
|
||||||
"autolinker": "./src/modules/autolinker",
|
|
||||||
"expo": "~39.0.4",
|
"expo": "~39.0.4",
|
||||||
"expo-app-auth": "~9.2.0",
|
"expo-app-auth": "~9.2.0",
|
||||||
"expo-av": "~8.6.0",
|
"expo-av": "~8.6.0",
|
||||||
|
@ -42,7 +41,8 @@
|
||||||
"react-native-webview": "10.7.0",
|
"react-native-webview": "10.7.0",
|
||||||
"react-navigation": "^4.4.3",
|
"react-navigation": "^4.4.3",
|
||||||
"react-query": "^2.26.2",
|
"react-query": "^2.26.2",
|
||||||
"react-redux": "^7.2.2"
|
"react-redux": "^7.2.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "~7.12.3",
|
"@babel/core": "~7.12.3",
|
||||||
|
|
113
src/Index.tsx
113
src/Index.tsx
|
@ -7,6 +7,7 @@ import React from 'react'
|
||||||
import { Feather } from '@expo/vector-icons'
|
import { Feather } from '@expo/vector-icons'
|
||||||
import store from 'src/stacks/common/store'
|
import store from 'src/stacks/common/store'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||||
import Toast from 'react-native-toast-message'
|
import Toast from 'react-native-toast-message'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
|
|
||||||
|
@ -24,64 +25,66 @@ export const Index: React.FC = () => {
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<StatusBar style='auto' />
|
<StatusBar style='auto' />
|
||||||
|
|
||||||
<NavigationContainer>
|
<SafeAreaProvider>
|
||||||
<Tab.Navigator
|
<NavigationContainer>
|
||||||
screenOptions={({ route }) => ({
|
<Tab.Navigator
|
||||||
tabBarIcon: ({ focused, color, size }) => {
|
screenOptions={({ route }) => ({
|
||||||
let name: string
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
switch (route.name) {
|
let name: string
|
||||||
case 'Local':
|
switch (route.name) {
|
||||||
name = 'home'
|
case 'Local':
|
||||||
break
|
name = 'home'
|
||||||
case 'Public':
|
break
|
||||||
name = 'globe'
|
case 'Public':
|
||||||
break
|
name = 'globe'
|
||||||
case 'PostRoot':
|
break
|
||||||
name = 'plus'
|
case 'PostRoot':
|
||||||
break
|
name = 'plus'
|
||||||
case 'Notifications':
|
break
|
||||||
name = 'bell'
|
case 'Notifications':
|
||||||
break
|
name = 'bell'
|
||||||
case 'Me':
|
break
|
||||||
name = focused ? 'smile' : 'meh'
|
case 'Me':
|
||||||
break
|
name = focused ? 'smile' : 'meh'
|
||||||
default:
|
break
|
||||||
name = 'alert-octagon'
|
default:
|
||||||
break
|
name = 'alert-octagon'
|
||||||
}
|
break
|
||||||
return <Feather name={name} size={size} color={color} />
|
}
|
||||||
}
|
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'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
/>
|
tabBarOptions={{
|
||||||
<Tab.Screen name='Notifications' component={Notifications} />
|
activeTintColor: 'black',
|
||||||
<Tab.Screen name='Me' component={Me} />
|
inactiveTintColor: 'gray',
|
||||||
</Tab.Navigator>
|
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)} />
|
<Toast ref={(ref: any) => Toast.setRef(ref)} />
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
</SafeAreaProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Emojis from './Emojis'
|
||||||
export interface Props {
|
export interface Props {
|
||||||
poll: mastodon.Poll
|
poll: mastodon.Poll
|
||||||
}
|
}
|
||||||
|
// When haven't voted, result should not be shown but intead let people vote
|
||||||
const Poll: React.FC<Props> = ({ poll }) => {
|
const Poll: React.FC<Props> = ({ poll }) => {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
|
|
@ -1,235 +1,20 @@
|
||||||
import { Feather } from '@expo/vector-icons'
|
import React from 'react'
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import { Alert, Pressable, Text } from 'react-native'
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Pressable,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View
|
|
||||||
} from 'react-native'
|
|
||||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||||
import Autolinker from 'src/modules/autolinker'
|
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import { debounce, differenceWith, isEqual } from 'lodash'
|
|
||||||
import { searchFetch } from '../common/searchFetch'
|
import PostMain from './PostToot/PostMain'
|
||||||
import { useQuery } from 'react-query'
|
|
||||||
import { FlatList } from 'react-native-gesture-handler'
|
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator()
|
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 PostToot: React.FC = () => {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='PostTootMain'
|
name='PostMain'
|
||||||
component={PostTootMain}
|
component={PostMain}
|
||||||
options={{
|
options={{
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<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
|
export default PostToot
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
13
yarn.lock
13
yarn.lock
|
@ -1670,11 +1670,6 @@ atob@^2.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
|
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
|
||||||
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
|
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:
|
available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5"
|
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"
|
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||||
|
|
||||||
tslib@^1.9.3:
|
tslib@^2.0.3:
|
||||||
version "1.14.1"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
|
||||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
|
||||||
|
|
||||||
type-fest@^0.7.1:
|
type-fest@^0.7.1:
|
||||||
version "0.7.1"
|
version "0.7.1"
|
||||||
|
|
Loading…
Reference in New Issue