mirror of
https://github.com/tooot-app/app
synced 2025-02-11 09:20:46 +01:00
Hashtag done
This commit is contained in:
parent
5866d016bc
commit
fcaea5b8d9
@ -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':
|
||||
|
@ -106,16 +106,23 @@ const ComposeActions: React.FC<Props> = ({
|
||||
<Feather
|
||||
name='smile'
|
||||
size={24}
|
||||
color={postState.emojis?.length ? theme.primary : theme.secondary}
|
||||
onPress={() => {
|
||||
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 })
|
||||
}
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
|
@ -17,30 +17,22 @@ import updateText from './updateText'
|
||||
|
||||
export interface Props {
|
||||
textInputRef: React.RefObject<TextInput>
|
||||
onChangeText: any
|
||||
postState: PostState
|
||||
postDispatch: Dispatch<PostAction>
|
||||
}
|
||||
|
||||
const ComposeEmojis: React.FC<Props> = ({
|
||||
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 (
|
||||
<View style={styles.base}>
|
||||
<SectionList
|
||||
horizontal
|
||||
sections={sortedEmojis}
|
||||
sections={postState.emoji.emojis!}
|
||||
keyExtractor={item => item.shortcode}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<Text style={[styles.group, { color: theme.secondary }]}>
|
||||
@ -56,12 +48,16 @@ const ComposeEmojis: React.FC<Props> = ({
|
||||
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 }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Image source={{ uri: emoji.url }} style={styles.emoji} />
|
||||
|
@ -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<Props> = ({ 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<Props> = ({ 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(
|
||||
<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)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
|
||||
const renderOverlay = (overlay: PostState['overlay']) => {
|
||||
switch (overlay) {
|
||||
case 'emojis':
|
||||
return (
|
||||
const listFooter = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
{postState.emoji.active && (
|
||||
<View style={styles.emojis}>
|
||||
<ComposeEmojis
|
||||
textInputRef={textInputRef}
|
||||
onChangeText={onChangeText}
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
case 'suggestions':
|
||||
return (
|
||||
<View style={styles.suggestions}>
|
||||
<ComposeSuggestions
|
||||
onChangeText={onChangeText}
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<ScrollView
|
||||
style={[styles.contentView]}
|
||||
alwaysBounceVertical={false}
|
||||
keyboardDismissMode='interactive'
|
||||
// child touch event not picked up
|
||||
keyboardShouldPersistTaps='always'
|
||||
>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{
|
||||
color: theme.primary
|
||||
}
|
||||
]}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
enablesReturnKeyAutomatically
|
||||
multiline
|
||||
placeholder='想说点什么'
|
||||
placeholderTextColor={theme.secondary}
|
||||
onChangeText={content => onChangeText({ content })}
|
||||
onSelectionChange={({
|
||||
nativeEvent: {
|
||||
selection: { start, end }
|
||||
}
|
||||
}) => {
|
||||
postDispatch({ type: 'selection', payload: { start, end } })
|
||||
}}
|
||||
ref={textInputRef}
|
||||
scrollEnabled
|
||||
>
|
||||
<Text>{postState.text.formatted}</Text>
|
||||
</TextInput>
|
||||
|
||||
{renderOverlay(postState.overlay)}
|
||||
)}
|
||||
|
||||
{postState.attachments.length > 0 && (
|
||||
<View style={styles.attachments}>
|
||||
@ -236,7 +97,106 @@ const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
||||
<ComposePoll postState={postState} postDispatch={postDispatch} />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}, [
|
||||
postState.emoji.active,
|
||||
postState.attachments.length,
|
||||
postState.poll.active
|
||||
])
|
||||
|
||||
const listEmpty = useMemo(() => {
|
||||
if (isFetching) {
|
||||
return <ActivityIndicator />
|
||||
}
|
||||
}, [isFetching])
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<FlatList
|
||||
ListHeaderComponent={
|
||||
<ComposeTextInput
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={listFooter}
|
||||
ListEmptyComponent={listEmpty}
|
||||
data={postState.tag && isSuccess ? data[postState.tag.type] : []}
|
||||
renderItem={({ item, index }) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
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 ? (
|
||||
<View
|
||||
style={[
|
||||
styles.account,
|
||||
{ borderBottomColor: theme.border },
|
||||
index === 0 && {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.border
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.avatar }}
|
||||
style={styles.accountAvatar}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[styles.accountName, { color: theme.primary }]}>
|
||||
{item.emojis.length ? (
|
||||
<Emojis
|
||||
content={item.display_name || item.username}
|
||||
emojis={item.emojis}
|
||||
size={StyleConstants.Font.Size.S}
|
||||
/>
|
||||
) : (
|
||||
item.display_name || item.username
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.accountAccount, { color: theme.primary }]}
|
||||
>
|
||||
@{item.acct}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.hashtag,
|
||||
{ borderBottomColor: theme.border },
|
||||
index === 0 && {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.border
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.hashtagText, { color: theme.primary }]}>
|
||||
#{item.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
<ComposeActions
|
||||
textInputRef={textInputRef}
|
||||
postState={postState}
|
||||
@ -251,13 +211,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1
|
||||
},
|
||||
contentView: { flex: 1 },
|
||||
textInput: {
|
||||
fontSize: StyleConstants.Font.Size.M,
|
||||
marginTop: StyleConstants.Spacing.S,
|
||||
marginBottom: StyleConstants.Spacing.M,
|
||||
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingRight: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
|
||||
attachments: {
|
||||
flex: 1,
|
||||
height: 100
|
||||
@ -266,9 +220,45 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
suggestions: {
|
||||
suggestion: {
|
||||
flex: 1
|
||||
},
|
||||
account: {
|
||||
flex: 1,
|
||||
backgroundColor: 'lightyellow'
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: StyleConstants.Spacing.S,
|
||||
paddingBottom: StyleConstants.Spacing.S,
|
||||
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingRight: StyleConstants.Spacing.Global.PagePadding,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth
|
||||
},
|
||||
accountAvatar: {
|
||||
width: StyleConstants.Font.LineHeight.M * 2,
|
||||
height: StyleConstants.Font.LineHeight.M * 2,
|
||||
marginRight: StyleConstants.Spacing.S,
|
||||
borderRadius: StyleConstants.Avatar.S
|
||||
},
|
||||
accountName: {
|
||||
fontSize: StyleConstants.Font.Size.S,
|
||||
fontWeight: StyleConstants.Font.Weight.Bold,
|
||||
marginBottom: StyleConstants.Spacing.XS
|
||||
},
|
||||
accountAccount: {
|
||||
fontSize: StyleConstants.Font.Size.S
|
||||
},
|
||||
hashtag: {
|
||||
flex: 1,
|
||||
paddingTop: StyleConstants.Spacing.S,
|
||||
paddingBottom: StyleConstants.Spacing.S,
|
||||
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingRight: StyleConstants.Spacing.Global.PagePadding,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth
|
||||
},
|
||||
hashtagText: {
|
||||
fontSize: StyleConstants.Font.Size.S,
|
||||
fontWeight: StyleConstants.Font.Weight.Bold,
|
||||
marginBottom: StyleConstants.Spacing.XS
|
||||
},
|
||||
emojis: {
|
||||
flex: 1
|
||||
|
@ -1,97 +0,0 @@
|
||||
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 'src/utils/fetches/searchFetch'
|
||||
import { PostAction, PostState } from '../Compose'
|
||||
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 ComposeSuggestions: 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 ComposeSuggestions
|
71
src/screens/Shared/Compose/TextInput.tsx
Normal file
71
src/screens/Shared/Compose/TextInput.tsx
Normal file
@ -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<PostAction>
|
||||
textInputRef: RefObject<TextInput>
|
||||
}
|
||||
|
||||
const ComposeTextInput: React.FC<Props> = ({
|
||||
postState,
|
||||
postDispatch,
|
||||
textInputRef
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{
|
||||
color: theme.primary
|
||||
}
|
||||
]}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
enablesReturnKeyAutomatically
|
||||
multiline
|
||||
placeholder='想说点什么'
|
||||
placeholderTextColor={theme.secondary}
|
||||
onChangeText={content =>
|
||||
formatText({
|
||||
postDispatch,
|
||||
content
|
||||
})
|
||||
}
|
||||
onSelectionChange={({
|
||||
nativeEvent: {
|
||||
selection: { start, end }
|
||||
}
|
||||
}) => {
|
||||
postDispatch({ type: 'selection', payload: { start, end } })
|
||||
}}
|
||||
ref={textInputRef}
|
||||
scrollEnabled
|
||||
>
|
||||
<Text>{postState.text.formatted}</Text>
|
||||
</TextInput>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
118
src/screens/Shared/Compose/formatText.tsx
Normal file
118
src/screens/Shared/Compose/formatText.tsx
Normal file
@ -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<PostAction>
|
||||
content: string
|
||||
refetch?: (options?: RefetchOptions | undefined) => Promise<any>
|
||||
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(
|
||||
<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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default formatText
|
@ -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<PostAction>
|
||||
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
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user