diff --git a/package.json b/package.json
index fb595589..8845d9b3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/Index.tsx b/src/Index.tsx
index 5cffe4a8..d8cbc5e2 100644
--- a/src/Index.tsx
+++ b/src/Index.tsx
@@ -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 = () => {
-
- ({
- 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
- }
- })}
- tabBarOptions={{
- activeTintColor: 'black',
- inactiveTintColor: 'gray',
- showLabel: false
- }}
- >
-
-
- ({
- tabPress: e => {
- e.preventDefault()
- const {
- length,
- [length - 1]: last
- } = navigation.dangerouslyGetState().history
- navigation.navigate(last.key.split(new RegExp(/(.*?)-/))[1], {
- screen: 'PostToot'
- })
+
+
+ ({
+ 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
}
})}
- />
-
-
-
+ tabBarOptions={{
+ activeTintColor: 'black',
+ inactiveTintColor: 'gray',
+ showLabel: false
+ }}
+ >
+
+
+ ({
+ tabPress: e => {
+ e.preventDefault()
+ const {
+ length,
+ [length - 1]: last
+ } = navigation.dangerouslyGetState().history
+ navigation.navigate(last.key.split(new RegExp(/(.*?)-/))[1], {
+ screen: 'PostToot'
+ })
+ }
+ })}
+ />
+
+
+
- Toast.setRef(ref)} />
-
+ Toast.setRef(ref)} />
+
+
)
}
diff --git a/src/components/Status/Poll.tsx b/src/components/Status/Poll.tsx
index 360dde37..a3c78b53 100644
--- a/src/components/Status/Poll.tsx
+++ b/src/components/Status/Poll.tsx
@@ -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 = ({ poll }) => {
return (
diff --git a/src/stacks/Shared/PostToot.tsx b/src/stacks/Shared/PostToot.tsx
index 052fe47e..61694233 100644
--- a/src/stacks/Shared/PostToot.tsx
+++ b/src/stacks/Shared/PostToot.tsx
@@ -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 (
-
- {item.acct ? item.acct : item.name}
-
- )
-})
-
-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 ? (
- (
-
- )}
- />
- ) : (
- 空无一物
- )
- break
- case 'loading':
- content =
- break
- case 'error':
- content = 搜索错误
- 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()
- 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(
-
- {tag.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 (
- setViewHeight(nativeEvent.layout.height)}
- >
-
- {
- setContentHeight(nativeEvent.contentSize.height)
- }}
- scrollEnabled
- >
- {formattedText}
-
- {suggestionsShown.display ? (
-
-
-
- ) : (
- <>>
- )}
- Keyboard.dismiss()}>
-
-
-
- {charCount}
-
-
-
- )
-}
-
const PostToot: React.FC = () => {
const navigation = useNavigation()
return (
(
{
)
}
-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
diff --git a/src/stacks/Shared/PostToot/PostEmojis.tsx b/src/stacks/Shared/PostToot/PostEmojis.tsx
new file mode 100644
index 00000000..b65ad8f3
--- /dev/null
+++ b/src/stacks/Shared/PostToot/PostEmojis.tsx
@@ -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
+}
+
+const PostEmojis: React.FC = ({
+ onChangeText,
+ postState,
+ postDispatch
+}) => {
+ return (
+ <>
+ {postState.emojis?.map((emoji, index) => (
+ {
+ updateText({
+ onChangeText,
+ postState,
+ newText: `:${emoji.shortcode}:`
+ })
+
+ postDispatch({ type: 'overlay', payload: null })
+ }}
+ >
+
+
+ ))}
+ >
+ )
+}
+
+export default PostEmojis
diff --git a/src/stacks/Shared/PostToot/PostMain.tsx b/src/stacks/Shared/PostToot/PostMain.tsx
new file mode 100644
index 00000000..f1123cb1
--- /dev/null
+++ b/src/stacks/Shared/PostToot/PostMain.tsx
@@ -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
+ }
+ | {
+ type: 'selection'
+ payload: PostState['selection']
+ }
+ | {
+ type: 'overlay'
+ payload: PostState['overlay']
+ }
+ | {
+ type: 'tag'
+ payload: PostState['tag']
+ }
+ | {
+ type: 'emojis'
+ payload: PostState['emojis']
+ }
+ | {
+ type: 'height'
+ payload: Partial
+ }
+
+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(
+
+ {tag!.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 (
+
+ postDispatch({
+ type: 'height',
+ payload: { view: nativeEvent.layout.height }
+ })
+ }
+ >
+
+ onChangeText({ content })}
+ onContentSizeChange={({ nativeEvent }) => {
+ postDispatch({
+ type: 'height',
+ payload: { editor: nativeEvent.contentSize.height }
+ })
+ }}
+ onSelectionChange={({
+ nativeEvent: {
+ selection: { start, end }
+ }
+ }) => {
+ postDispatch({ type: 'selection', payload: { start, end } })
+ }}
+ scrollEnabled
+ >
+ {postState.text.formatted}
+
+ {postState.overlay === 'suggestions' ? (
+
+
+
+ ) : (
+ <>>
+ )}
+ {postState.overlay === 'emojis' ? (
+
+
+
+ ) : (
+ <>>
+ )}
+ Keyboard.dismiss()}>
+
+
+
+ {
+ if (postState.emojis?.length && postState.overlay === null) {
+ postDispatch({ type: 'overlay', payload: 'emojis' })
+ }
+ }}
+ />
+ {postState.text.count}
+
+
+
+ )
+}
+
+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
diff --git a/src/stacks/Shared/PostToot/PostSuggestions.tsx b/src/stacks/Shared/PostToot/PostSuggestions.tsx
new file mode 100644
index 00000000..1c60e6d0
--- /dev/null
+++ b/src/stacks/Shared/PostToot/PostSuggestions.tsx
@@ -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 (
+ Component: (props: A) => B
+ ): (props: A) => ReactElement | null
+}
+
+const Suggestion = React.memo(
+ ({ onChangeText, postState, postDispatch, item, index }) => {
+ return (
+ {
+ 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 })
+ }}
+ >
+ {item.acct ? item.acct : item.name}
+
+ )
+ }
+)
+
+export interface Props {
+ onChangeText: any
+ postState: PostState
+ postDispatch: Dispatch
+}
+
+const PostSuggestions: React.FC = ({
+ 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 ? (
+ (
+
+ )}
+ />
+ ) : (
+ 空无一物
+ )
+ break
+ case 'loading':
+ content =
+ break
+ case 'error':
+ content = 搜索错误
+ break
+ default:
+ content = <>>
+ }
+
+ return content
+}
+
+export default PostSuggestions
diff --git a/src/stacks/Shared/PostToot/updateText.ts b/src/stacks/Shared/PostToot/updateText.ts
new file mode 100644
index 00000000..f61b451a
--- /dev/null
+++ b/src/stacks/Shared/PostToot/updateText.ts
@@ -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
diff --git a/src/stacks/common/emojisFetch.ts b/src/stacks/common/emojisFetch.ts
new file mode 100644
index 00000000..ce864e04
--- /dev/null
+++ b/src/stacks/common/emojisFetch.ts
@@ -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)
+}
diff --git a/yarn.lock b/yarn.lock
index d630388c..9cf0dff2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"