mirror of
https://github.com/tooot-app/app
synced 2025-02-14 19:00:50 +01:00
Hashtag done
This commit is contained in:
parent
5866d016bc
commit
fcaea5b8d9
@ -19,7 +19,6 @@ export type PostState = {
|
|||||||
formatted: ReactNode
|
formatted: ReactNode
|
||||||
}
|
}
|
||||||
selection: { start: number; end: number }
|
selection: { start: number; end: number }
|
||||||
overlay: null | 'suggestions' | 'emojis'
|
|
||||||
tag:
|
tag:
|
||||||
| {
|
| {
|
||||||
type: 'url' | 'accounts' | 'hashtags'
|
type: 'url' | 'accounts' | 'hashtags'
|
||||||
@ -27,7 +26,10 @@ export type PostState = {
|
|||||||
offset: number
|
offset: number
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
emojis: Mastodon.Emoji[] | undefined
|
emoji: {
|
||||||
|
active: boolean
|
||||||
|
emojis: { title: string; data: Mastodon.Emoji[] }[] | undefined
|
||||||
|
}
|
||||||
poll: {
|
poll: {
|
||||||
active: boolean
|
active: boolean
|
||||||
total: number
|
total: number
|
||||||
@ -67,17 +69,13 @@ export type PostAction =
|
|||||||
type: 'selection'
|
type: 'selection'
|
||||||
payload: PostState['selection']
|
payload: PostState['selection']
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'overlay'
|
|
||||||
payload: PostState['overlay']
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: 'tag'
|
type: 'tag'
|
||||||
payload: PostState['tag']
|
payload: PostState['tag']
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'emojis'
|
type: 'emoji'
|
||||||
payload: PostState['emojis']
|
payload: PostState['emoji']
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'poll'
|
type: 'poll'
|
||||||
@ -110,9 +108,8 @@ const postInitialState: PostState = {
|
|||||||
formatted: undefined
|
formatted: undefined
|
||||||
},
|
},
|
||||||
selection: { start: 0, end: 0 },
|
selection: { start: 0, end: 0 },
|
||||||
overlay: null,
|
|
||||||
tag: undefined,
|
tag: undefined,
|
||||||
emojis: undefined,
|
emoji: { active: false, emojis: undefined },
|
||||||
poll: {
|
poll: {
|
||||||
active: false,
|
active: false,
|
||||||
total: 2,
|
total: 2,
|
||||||
@ -137,12 +134,10 @@ const postReducer = (state: PostState, action: PostAction): PostState => {
|
|||||||
return { ...state, text: { ...state.text, ...action.payload } }
|
return { ...state, text: { ...state.text, ...action.payload } }
|
||||||
case 'selection':
|
case 'selection':
|
||||||
return { ...state, selection: action.payload }
|
return { ...state, selection: action.payload }
|
||||||
case 'overlay':
|
|
||||||
return { ...state, overlay: action.payload }
|
|
||||||
case 'tag':
|
case 'tag':
|
||||||
return { ...state, tag: action.payload }
|
return { ...state, tag: action.payload }
|
||||||
case 'emojis':
|
case 'emoji':
|
||||||
return { ...state, emojis: action.payload }
|
return { ...state, emoji: action.payload }
|
||||||
case 'poll':
|
case 'poll':
|
||||||
return { ...state, poll: action.payload }
|
return { ...state, poll: action.payload }
|
||||||
case 'attachments/add':
|
case 'attachments/add':
|
||||||
|
@ -106,16 +106,23 @@ const ComposeActions: React.FC<Props> = ({
|
|||||||
<Feather
|
<Feather
|
||||||
name='smile'
|
name='smile'
|
||||||
size={24}
|
size={24}
|
||||||
color={postState.emojis?.length ? theme.primary : theme.secondary}
|
color={postState.emoji.emojis?.length ? theme.primary : theme.secondary}
|
||||||
onPress={() => {
|
{...(postState.emoji.emojis && {
|
||||||
if (postState.emojis?.length && postState.overlay === null) {
|
onPress: () => {
|
||||||
Keyboard.dismiss()
|
if (postState.emoji.active) {
|
||||||
postDispatch({ type: 'overlay', payload: 'emojis' })
|
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
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
@ -17,30 +17,22 @@ import updateText from './updateText'
|
|||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
textInputRef: React.RefObject<TextInput>
|
textInputRef: React.RefObject<TextInput>
|
||||||
onChangeText: any
|
|
||||||
postState: PostState
|
postState: PostState
|
||||||
postDispatch: Dispatch<PostAction>
|
postDispatch: Dispatch<PostAction>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposeEmojis: React.FC<Props> = ({
|
const ComposeEmojis: React.FC<Props> = ({
|
||||||
textInputRef,
|
textInputRef,
|
||||||
onChangeText,
|
|
||||||
postState,
|
postState,
|
||||||
postDispatch
|
postDispatch
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme()
|
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 (
|
return (
|
||||||
<View style={styles.base}>
|
<View style={styles.base}>
|
||||||
<SectionList
|
<SectionList
|
||||||
horizontal
|
horizontal
|
||||||
sections={sortedEmojis}
|
sections={postState.emoji.emojis!}
|
||||||
keyExtractor={item => item.shortcode}
|
keyExtractor={item => item.shortcode}
|
||||||
renderSectionHeader={({ section: { title } }) => (
|
renderSectionHeader={({ section: { title } }) => (
|
||||||
<Text style={[styles.group, { color: theme.secondary }]}>
|
<Text style={[styles.group, { color: theme.secondary }]}>
|
||||||
@ -56,12 +48,16 @@ const ComposeEmojis: React.FC<Props> = ({
|
|||||||
key={emoji.shortcode}
|
key={emoji.shortcode}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateText({
|
updateText({
|
||||||
onChangeText,
|
|
||||||
postState,
|
postState,
|
||||||
newText: `:${emoji.shortcode}:`
|
postDispatch,
|
||||||
|
newText: `:${emoji.shortcode}:`,
|
||||||
|
type: 'emoji'
|
||||||
})
|
})
|
||||||
textInputRef.current?.focus()
|
textInputRef.current?.focus()
|
||||||
postDispatch({ type: 'overlay', payload: null })
|
postDispatch({
|
||||||
|
type: 'emoji',
|
||||||
|
payload: { ...postState.emoji, active: false }
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image source={{ uri: emoji.url }} style={styles.emoji} />
|
<Image source={{ uri: emoji.url }} style={styles.emoji} />
|
||||||
|
@ -1,37 +1,29 @@
|
|||||||
import React, {
|
import ImagePicker from 'expo-image-picker'
|
||||||
createElement,
|
import { forEach, groupBy, sortBy } from 'lodash'
|
||||||
Dispatch,
|
import React, { Dispatch, useEffect, useMemo, useRef } from 'react'
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from 'react'
|
|
||||||
import {
|
import {
|
||||||
ActionSheetIOS,
|
View,
|
||||||
Keyboard,
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View
|
Image
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useQuery } from 'react-query'
|
import { useQuery } from 'react-query'
|
||||||
import { Feather } from '@expo/vector-icons'
|
import Emojis from 'src/components/Timelines/Timeline/Shared/Emojis'
|
||||||
import * as ImagePicker from 'expo-image-picker'
|
import { emojisFetch } from 'src/utils/fetches/emojisFetch'
|
||||||
import { debounce, differenceWith, isEqual } from 'lodash'
|
import { searchFetch } from 'src/utils/fetches/searchFetch'
|
||||||
|
import { StyleConstants } from 'src/utils/styles/constants'
|
||||||
import Autolinker from 'src/modules/autolinker'
|
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 ComposeEmojis from './Emojis'
|
||||||
import ComposePoll from './Poll'
|
import ComposePoll from './Poll'
|
||||||
import ComposeSuggestions from './Suggestions'
|
import ComposeTextInput from './TextInput'
|
||||||
import { emojisFetch } from 'src/utils/fetches/emojisFetch'
|
import updateText from './updateText'
|
||||||
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'
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
postState: PostState
|
postState: PostState
|
||||||
@ -40,6 +32,19 @@ export interface Props {
|
|||||||
|
|
||||||
const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
||||||
const { theme } = useTheme()
|
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(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const { status } = await ImagePicker.requestCameraRollPermissionsAsync()
|
const { status } = await ImagePicker.requestCameraRollPermissionsAsync()
|
||||||
@ -52,176 +57,32 @@ const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
|||||||
const { data: emojisData } = useQuery(['Emojis'], emojisFetch)
|
const { data: emojisData } = useQuery(['Emojis'], emojisFetch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojisData && emojisData.length) {
|
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])
|
}, [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 textInputRef = useRef<TextInput>(null)
|
||||||
|
|
||||||
const renderOverlay = (overlay: PostState['overlay']) => {
|
const listFooter = useMemo(() => {
|
||||||
switch (overlay) {
|
return (
|
||||||
case 'emojis':
|
<>
|
||||||
return (
|
{postState.emoji.active && (
|
||||||
<View style={styles.emojis}>
|
<View style={styles.emojis}>
|
||||||
<ComposeEmojis
|
<ComposeEmojis
|
||||||
textInputRef={textInputRef}
|
textInputRef={textInputRef}
|
||||||
onChangeText={onChangeText}
|
|
||||||
postState={postState}
|
postState={postState}
|
||||||
postDispatch={postDispatch}
|
postDispatch={postDispatch}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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 && (
|
{postState.attachments.length > 0 && (
|
||||||
<View style={styles.attachments}>
|
<View style={styles.attachments}>
|
||||||
@ -236,7 +97,106 @@ const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
|||||||
<ComposePoll postState={postState} postDispatch={postDispatch} />
|
<ComposePoll postState={postState} postDispatch={postDispatch} />
|
||||||
</View>
|
</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
|
<ComposeActions
|
||||||
textInputRef={textInputRef}
|
textInputRef={textInputRef}
|
||||||
postState={postState}
|
postState={postState}
|
||||||
@ -251,13 +211,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1
|
flex: 1
|
||||||
},
|
},
|
||||||
contentView: { 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: {
|
attachments: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 100
|
height: 100
|
||||||
@ -266,9 +220,45 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
padding: StyleConstants.Spacing.Global.PagePadding
|
padding: StyleConstants.Spacing.Global.PagePadding
|
||||||
},
|
},
|
||||||
suggestions: {
|
suggestion: {
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
account: {
|
||||||
flex: 1,
|
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: {
|
emojis: {
|
||||||
flex: 1
|
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 = ({
|
const updateText = ({
|
||||||
onChangeText,
|
|
||||||
postState,
|
postState,
|
||||||
newText
|
postDispatch,
|
||||||
|
newText,
|
||||||
|
type
|
||||||
}: {
|
}: {
|
||||||
onChangeText: any
|
|
||||||
postState: PostState
|
postState: PostState
|
||||||
|
postDispatch: Dispatch<PostAction>
|
||||||
newText: string
|
newText: string
|
||||||
|
type: 'emoji' | 'suggestion'
|
||||||
}) => {
|
}) => {
|
||||||
if (postState.text.raw.length) {
|
if (postState.text.raw.length) {
|
||||||
const contentFront = postState.text.raw.slice(0, postState.selection.start)
|
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 whiteSpaceFront = /\s/g.test(contentFront.slice(-1))
|
||||||
const whiteSpaceRear = /\s/g.test(contentRear.slice(-1))
|
const whiteSpaceRear = /\s/g.test(contentRear.slice(-1))
|
||||||
|
|
||||||
const newTextWithSpace = `${whiteSpaceFront ? '' : ' '}${newText}${
|
const newTextWithSpace = `${
|
||||||
whiteSpaceRear ? '' : ' '
|
whiteSpaceFront || type === 'suggestion' ? '' : ' '
|
||||||
}`
|
}${newText}${whiteSpaceRear ? '' : ' '}`
|
||||||
|
|
||||||
onChangeText({
|
formatText({
|
||||||
|
postDispatch,
|
||||||
content: [contentFront, newTextWithSpace, contentRear].join(''),
|
content: [contentFront, newTextWithSpace, contentRear].join(''),
|
||||||
disableDebounce: true
|
disableDebounce: true
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
onChangeText({
|
formatText({
|
||||||
|
postDispatch,
|
||||||
content: `${newText} `,
|
content: `${newText} `,
|
||||||
disableDebounce: true
|
disableDebounce: true
|
||||||
})
|
})
|
||||||
|
@ -19,5 +19,9 @@ export const searchFetch = async (
|
|||||||
endpoint: 'search',
|
endpoint: 'search',
|
||||||
query: { type, q: term, limit }
|
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)
|
return Promise.resolve(res.body)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user