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"