From fcaea5b8d93964d68146f9e6b15401402b2d361d Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Fri, 4 Dec 2020 01:17:10 +0100 Subject: [PATCH] Hashtag done --- src/screens/Shared/Compose.tsx | 23 +- src/screens/Shared/Compose/Actions.tsx | 25 +- src/screens/Shared/Compose/Emojis.tsx | 20 +- src/screens/Shared/Compose/Root.tsx | 376 ++++++++++----------- src/screens/Shared/Compose/Suggestions.tsx | 97 ------ src/screens/Shared/Compose/TextInput.tsx | 71 ++++ src/screens/Shared/Compose/formatText.tsx | 118 +++++++ src/screens/Shared/Compose/updateText.ts | 24 +- src/utils/fetches/searchFetch.ts | 4 + 9 files changed, 424 insertions(+), 334 deletions(-) delete mode 100644 src/screens/Shared/Compose/Suggestions.tsx create mode 100644 src/screens/Shared/Compose/TextInput.tsx create mode 100644 src/screens/Shared/Compose/formatText.tsx diff --git a/src/screens/Shared/Compose.tsx b/src/screens/Shared/Compose.tsx index 6743e2ab..6077dcc9 100644 --- a/src/screens/Shared/Compose.tsx +++ b/src/screens/Shared/Compose.tsx @@ -19,7 +19,6 @@ export type PostState = { formatted: ReactNode } selection: { start: number; end: number } - overlay: null | 'suggestions' | 'emojis' tag: | { type: 'url' | 'accounts' | 'hashtags' @@ -27,7 +26,10 @@ export type PostState = { offset: number } | undefined - emojis: Mastodon.Emoji[] | undefined + emoji: { + active: boolean + emojis: { title: string; data: Mastodon.Emoji[] }[] | undefined + } poll: { active: boolean total: number @@ -67,17 +69,13 @@ export type PostAction = type: 'selection' payload: PostState['selection'] } - | { - type: 'overlay' - payload: PostState['overlay'] - } | { type: 'tag' payload: PostState['tag'] } | { - type: 'emojis' - payload: PostState['emojis'] + type: 'emoji' + payload: PostState['emoji'] } | { type: 'poll' @@ -110,9 +108,8 @@ const postInitialState: PostState = { formatted: undefined }, selection: { start: 0, end: 0 }, - overlay: null, tag: undefined, - emojis: undefined, + emoji: { active: false, emojis: undefined }, poll: { active: false, total: 2, @@ -137,12 +134,10 @@ const postReducer = (state: PostState, action: PostAction): PostState => { 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 'emoji': + return { ...state, emoji: action.payload } case 'poll': return { ...state, poll: action.payload } case 'attachments/add': diff --git a/src/screens/Shared/Compose/Actions.tsx b/src/screens/Shared/Compose/Actions.tsx index eb4cd0b9..85d5f563 100644 --- a/src/screens/Shared/Compose/Actions.tsx +++ b/src/screens/Shared/Compose/Actions.tsx @@ -106,16 +106,23 @@ const ComposeActions: React.FC = ({ { - if (postState.emojis?.length && postState.overlay === null) { - Keyboard.dismiss() - postDispatch({ type: 'overlay', payload: 'emojis' }) + color={postState.emoji.emojis?.length ? theme.primary : theme.secondary} + {...(postState.emoji.emojis && { + onPress: () => { + if (postState.emoji.active) { + postDispatch({ + type: 'emoji', + payload: { ...postState.emoji, active: false } + }) + } else { + Keyboard.dismiss() + postDispatch({ + type: 'emoji', + payload: { ...postState.emoji, active: true } + }) + } } - if (postState.overlay === 'emojis') { - postDispatch({ type: 'overlay', payload: null }) - } - }} + })} /> - onChangeText: any postState: PostState postDispatch: Dispatch } const ComposeEmojis: React.FC = ({ textInputRef, - onChangeText, postState, postDispatch }) => { const { theme } = useTheme() - let sortedEmojis: { title: string; data: Mastodon.Emoji[] }[] = [] - forEach( - groupBy(sortBy(postState.emojis, ['category', 'shortcode']), 'category'), - (value, key) => sortedEmojis.push({ title: key, data: value }) - ) - return ( item.shortcode} renderSectionHeader={({ section: { title } }) => ( @@ -56,12 +48,16 @@ const ComposeEmojis: React.FC = ({ key={emoji.shortcode} onPress={() => { updateText({ - onChangeText, postState, - newText: `:${emoji.shortcode}:` + postDispatch, + newText: `:${emoji.shortcode}:`, + type: 'emoji' }) textInputRef.current?.focus() - postDispatch({ type: 'overlay', payload: null }) + postDispatch({ + type: 'emoji', + payload: { ...postState.emoji, active: false } + }) }} > diff --git a/src/screens/Shared/Compose/Root.tsx b/src/screens/Shared/Compose/Root.tsx index 7e598656..c410b384 100644 --- a/src/screens/Shared/Compose/Root.tsx +++ b/src/screens/Shared/Compose/Root.tsx @@ -1,37 +1,29 @@ -import React, { - createElement, - Dispatch, - useCallback, - useEffect, - useRef, - useState -} from 'react' +import ImagePicker from 'expo-image-picker' +import { forEach, groupBy, sortBy } from 'lodash' +import React, { Dispatch, useEffect, useMemo, useRef } from 'react' import { - ActionSheetIOS, - Keyboard, + View, + ActivityIndicator, + FlatList, Pressable, - ScrollView, StyleSheet, Text, TextInput, - View + Image } from 'react-native' import { useQuery } from 'react-query' -import { Feather } from '@expo/vector-icons' -import * as ImagePicker from 'expo-image-picker' -import { debounce, differenceWith, isEqual } from 'lodash' - -import Autolinker from 'src/modules/autolinker' +import Emojis from 'src/components/Timelines/Timeline/Shared/Emojis' +import { emojisFetch } from 'src/utils/fetches/emojisFetch' +import { searchFetch } from 'src/utils/fetches/searchFetch' +import { StyleConstants } from 'src/utils/styles/constants' +import { useTheme } from 'src/utils/styles/ThemeManager' +import { PostAction, PostState } from '../Compose' +import ComposeActions from './Actions' +import ComposeAttachments from './Attachments' import ComposeEmojis from './Emojis' import ComposePoll from './Poll' -import ComposeSuggestions from './Suggestions' -import { emojisFetch } from 'src/utils/fetches/emojisFetch' -import { PostAction, PostState } from 'src/screens/Shared/Compose' -import addAttachments from './addAttachments' -import ComposeAttachments from './Attachments' -import { useTheme } from 'src/utils/styles/ThemeManager' -import { StyleConstants } from 'src/utils/styles/constants' -import ComposeActions from './Actions' +import ComposeTextInput from './TextInput' +import updateText from './updateText' export interface Props { postState: PostState @@ -40,6 +32,19 @@ export interface Props { const ComposeRoot: React.FC = ({ postState, postDispatch }) => { const { theme } = useTheme() + + const { isFetching, isSuccess, data, refetch } = useQuery( + [ + 'Search', + { type: postState.tag?.type, term: postState.tag?.text.substring(1) } + ], + searchFetch, + { enabled: false } + ) + useEffect(() => { + refetch() + }, [postState.tag?.text]) + useEffect(() => { ;(async () => { const { status } = await ImagePicker.requestCameraRollPermissionsAsync() @@ -52,176 +57,32 @@ const ComposeRoot: React.FC = ({ postState, postDispatch }) => { const { data: emojisData } = useQuery(['Emojis'], emojisFetch) useEffect(() => { if (emojisData && emojisData.length) { - postDispatch({ type: 'emojis', payload: emojisData }) + let sortedEmojis: { title: string; data: Mastodon.Emoji[] }[] = [] + forEach( + groupBy(sortBy(emojisData, ['category', 'shortcode']), 'category'), + (value, key) => sortedEmojis.push({ title: key, data: value }) + ) + postDispatch({ + type: 'emoji', + payload: { ...postState.emoji, emojis: sortedEmojis } + }) } }, [emojisData]) - const debouncedSuggestions = useCallback( - debounce(tag => { - postDispatch({ type: 'overlay', payload: 'suggestions' }) - postDispatch({ type: 'tag', payload: tag }) - }, 500), - [] - ) - 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 - } - 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 > 0 && - tags.length > 0 && - content.length > 0 && - !disableDebounce - ) { - console.log('changedTag length') - console.log(changedTag.length) - console.log('tags length') - console.log(tags.length) - console.log('changed Tag') - console.log(changedTag) - 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) - } - }) - }, []) - const textInputRef = useRef(null) - const renderOverlay = (overlay: PostState['overlay']) => { - switch (overlay) { - case 'emojis': - return ( + const listFooter = useMemo(() => { + return ( + <> + {postState.emoji.active && ( - ) - case 'suggestions': - return ( - - - - ) - } - } - - return ( - - - onChangeText({ content })} - onSelectionChange={({ - nativeEvent: { - selection: { start, end } - } - }) => { - postDispatch({ type: 'selection', payload: { start, end } }) - }} - ref={textInputRef} - scrollEnabled - > - {postState.text.formatted} - - - {renderOverlay(postState.overlay)} + )} {postState.attachments.length > 0 && ( @@ -236,7 +97,106 @@ const ComposeRoot: React.FC = ({ postState, postDispatch }) => { )} - + + ) + }, [ + postState.emoji.active, + postState.attachments.length, + postState.poll.active + ]) + + const listEmpty = useMemo(() => { + if (isFetching) { + return + } + }, [isFetching]) + + return ( + + + } + ListFooterComponent={listFooter} + ListEmptyComponent={listEmpty} + data={postState.tag && isSuccess ? data[postState.tag.type] : []} + renderItem={({ item, index }) => ( + { + updateText({ + postState: { + ...postState, + selection: { + start: postState.tag!.offset, + end: postState.tag!.offset + postState.tag!.text.length + 1 + } + }, + postDispatch, + newText: item.acct ? `@${item.acct}` : `#${item.name}`, + type: 'suggestion' + }) + textInputRef.current?.focus() + }} + style={styles.suggestion} + > + {item.acct ? ( + + + + + {item.emojis.length ? ( + + ) : ( + item.display_name || item.username + )} + + + @{item.acct} + + + + ) : ( + + + #{item.name} + + + )} + + )} + /> ( - 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 ComposeSuggestions: 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 ComposeSuggestions diff --git a/src/screens/Shared/Compose/TextInput.tsx b/src/screens/Shared/Compose/TextInput.tsx new file mode 100644 index 00000000..988c8b7b --- /dev/null +++ b/src/screens/Shared/Compose/TextInput.tsx @@ -0,0 +1,71 @@ +import React, { Dispatch, RefObject } from 'react' +import { StyleSheet, Text, TextInput } from 'react-native' +import { StyleConstants } from 'src/utils/styles/constants' +import { useTheme } from 'src/utils/styles/ThemeManager' +import { PostAction, PostState } from '../Compose' +import formatText from './formatText' + +export interface Props { + postState: PostState + postDispatch: Dispatch + textInputRef: RefObject +} + +const ComposeTextInput: React.FC = ({ + postState, + postDispatch, + textInputRef +}) => { + const { theme } = useTheme() + + return ( + + formatText({ + postDispatch, + content + }) + } + onSelectionChange={({ + nativeEvent: { + selection: { start, end } + } + }) => { + postDispatch({ type: 'selection', payload: { start, end } }) + }} + ref={textInputRef} + scrollEnabled + > + {postState.text.formatted} + + ) +} + +const styles = StyleSheet.create({ + textInput: { + fontSize: StyleConstants.Font.Size.M, + marginTop: StyleConstants.Spacing.S, + marginBottom: StyleConstants.Spacing.M, + paddingLeft: StyleConstants.Spacing.Global.PagePadding, + paddingRight: StyleConstants.Spacing.Global.PagePadding + } +}) + +export default React.memo( + ComposeTextInput, + (prev, next) => + prev.postState.text.formatted === next.postState.text.formatted +) diff --git a/src/screens/Shared/Compose/formatText.tsx b/src/screens/Shared/Compose/formatText.tsx new file mode 100644 index 00000000..16d00fab --- /dev/null +++ b/src/screens/Shared/Compose/formatText.tsx @@ -0,0 +1,118 @@ +import { debounce, differenceWith, isEqual } from 'lodash' +import React, { createElement, Dispatch } from 'react' +import { Text } from 'react-native' +import { RefetchOptions } from 'react-query/types/core/query' +import Autolinker from 'src/modules/autolinker' +import { PostAction, PostState } from '../Compose' + +export interface Params { + postDispatch: Dispatch + content: string + refetch?: (options?: RefetchOptions | undefined) => Promise + disableDebounce?: boolean +} + +const debouncedSuggestions = debounce((postDispatch, tag) => { + console.log('debounced!!!') + postDispatch({ type: 'tag', payload: tag }) +}, 500) + +let prevTags: PostState['tag'][] = [] + +const formatText = ({ + postDispatch, + content, + refetch, + disableDebounce = false +}: Params) => { + 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 + } + 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 > 0 && + tags.length > 0 && + content.length > 0 && + !disableDebounce + ) { + // console.log('changedTag length') + // console.log(changedTag.length) + // console.log('tags length') + // console.log(tags.length) + // console.log('changed Tag') + // console.log(changedTag) + if (changedTag[0]!.type !== 'url') { + debouncedSuggestions(postDispatch, changedTag[0]) + } + } else { + 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 ? prevPart.length : 0) + 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) + } + }) +} + +export default formatText diff --git a/src/screens/Shared/Compose/updateText.ts b/src/screens/Shared/Compose/updateText.ts index 1ac20e36..d8d1b7c4 100644 --- a/src/screens/Shared/Compose/updateText.ts +++ b/src/screens/Shared/Compose/updateText.ts @@ -1,13 +1,17 @@ -import { PostState } from '../Compose' +import { Dispatch } from 'react' +import { PostAction, PostState } from '../Compose' +import formatText from './formatText' const updateText = ({ - onChangeText, postState, - newText + postDispatch, + newText, + type }: { - onChangeText: any postState: PostState + postDispatch: Dispatch newText: string + type: 'emoji' | 'suggestion' }) => { if (postState.text.raw.length) { const contentFront = postState.text.raw.slice(0, postState.selection.start) @@ -16,16 +20,18 @@ const updateText = ({ const whiteSpaceFront = /\s/g.test(contentFront.slice(-1)) const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) - const newTextWithSpace = `${whiteSpaceFront ? '' : ' '}${newText}${ - whiteSpaceRear ? '' : ' ' - }` + const newTextWithSpace = `${ + whiteSpaceFront || type === 'suggestion' ? '' : ' ' + }${newText}${whiteSpaceRear ? '' : ' '}` - onChangeText({ + formatText({ + postDispatch, content: [contentFront, newTextWithSpace, contentRear].join(''), disableDebounce: true }) } else { - onChangeText({ + formatText({ + postDispatch, content: `${newText} `, disableDebounce: true }) diff --git a/src/utils/fetches/searchFetch.ts b/src/utils/fetches/searchFetch.ts index d7a2a437..bb01c1e1 100644 --- a/src/utils/fetches/searchFetch.ts +++ b/src/utils/fetches/searchFetch.ts @@ -19,5 +19,9 @@ export const searchFetch = async ( endpoint: 'search', query: { type, q: term, limit } }) + console.log('search query') + console.log({ type, q: term, limit }) + console.log('search result') + console.log(res.body) return Promise.resolve(res.body) }