Hashtag done

This commit is contained in:
Zhiyuan Zheng 2020-12-04 01:17:10 +01:00
parent 5866d016bc
commit fcaea5b8d9
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
9 changed files with 424 additions and 334 deletions

View File

@ -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':

View File

@ -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={[

View File

@ -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} />

View File

@ -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

View File

@ -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

View 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
)

View 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

View File

@ -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
})

View File

@ -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)
}