1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

Partially fixed #113

This commit is contained in:
Zhiyuan Zheng
2021-05-09 21:59:03 +02:00
parent 006edd5c87
commit 0b659913dc
62 changed files with 2308 additions and 703 deletions

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useEffect, useMemo, useRef } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
AccessibilityProps,
Pressable,
@ -121,9 +121,6 @@ const Button: React.FC<Props> = ({
color: mainColor,
fontSize:
StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1),
fontWeight: destructive
? StyleConstants.Font.Weight.Bold
: undefined,
opacity: loading ? 0 : 1
}}
children={content}
@ -135,12 +132,7 @@ const Button: React.FC<Props> = ({
}
}, [mode, content, loading, disabled])
enum spacingMapping {
XS = 'S',
S = 'M',
M = 'L',
L = 'XL'
}
const [layoutHeight, setLayoutHeight] = useState<number | undefined>()
return (
<Pressable
@ -161,10 +153,15 @@ const Button: React.FC<Props> = ({
backgroundColor: colorBackground,
paddingVertical: StyleConstants.Spacing[spacing],
paddingHorizontal:
StyleConstants.Spacing[round ? spacing : spacingMapping[spacing]]
StyleConstants.Spacing[spacing] + StyleConstants.Spacing.XS,
width: round && layoutHeight ? layoutHeight : undefined
},
customStyle
]}
{...(round && {
onLayout: ({ nativeEvent }) =>
setLayoutHeight(nativeEvent.layout.height)
})}
testID='base'
onPress={onPress}
children={children}
@ -176,7 +173,6 @@ const Button: React.FC<Props> = ({
const styles = StyleSheet.create({
button: {
borderRadius: 100,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center'
}

161
src/components/Emojis.tsx Normal file
View File

@ -0,0 +1,161 @@
import EmojisButton from '@components/Emojis/Button'
import EmojisList from '@components/Emojis/List'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useEmojisQuery } from '@utils/queryHooks/emojis'
import { chunk, forEach, groupBy, sortBy } from 'lodash'
import React, {
createContext,
Dispatch,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useReducer
} from 'react'
import FastImage from 'react-native-fast-image'
type EmojisState = {
enabled: boolean
active: boolean
emojis: { title: string; data: Mastodon.Emoji[][] }[]
shortcode: Mastodon.Emoji['shortcode'] | null
}
type EmojisAction =
| {
type: 'load'
payload: NonNullable<EmojisState['emojis']>
}
| {
type: 'activate'
payload: EmojisState['active']
}
| {
type: 'shortcode'
payload: EmojisState['shortcode']
}
const emojisReducer = (state: EmojisState, action: EmojisAction) => {
switch (action.type) {
case 'activate':
return { ...state, active: action.payload }
case 'load':
return { ...state, emojis: action.payload }
case 'shortcode':
return { ...state, shortcode: action.payload }
}
}
type ContextType = {
emojisState: EmojisState
emojisDispatch: Dispatch<EmojisAction>
}
const EmojisContext = createContext<ContextType>({} as ContextType)
const prefetchEmojis = (
sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[],
reduceMotionEnabled: boolean
) => {
const prefetches: { uri: string }[] = []
let requestedIndex = 0
sortedEmojis.forEach(sorted => {
sorted.data.forEach(emojis =>
emojis.forEach(emoji => {
if (requestedIndex > 40) {
return
}
prefetches.push({
uri: reduceMotionEnabled ? emoji.static_url : emoji.url
})
requestedIndex++
})
)
})
try {
FastImage.preload(prefetches)
} catch {}
}
export interface Props {
enabled?: boolean
value?: string
setValue:
| Dispatch<SetStateAction<string | undefined>>
| Dispatch<SetStateAction<string>>
selectionRange: MutableRefObject<{
start: number
end: number
}>
}
const ComponentEmojis: React.FC<Props> = ({
enabled = false,
value,
setValue,
selectionRange,
children
}) => {
const { reduceMotionEnabled } = useAccessibility()
const [emojisState, emojisDispatch] = useReducer(emojisReducer, {
enabled,
active: false,
emojis: [],
shortcode: null
})
useEffect(() => {
if (emojisState.shortcode) {
addEmoji(emojisState.shortcode)
emojisDispatch({
type: 'shortcode',
payload: null
})
}
}, [emojisState.shortcode])
const addEmoji = useCallback(
(emojiShortcode: string) => {
console.log(selectionRange.current)
if (value?.length) {
const contentFront = value.slice(0, selectionRange.current?.start)
const contentRear = value.slice(selectionRange.current?.end)
const whiteSpaceRear = /\s/g.test(contentRear.slice(-1))
const newTextWithSpace = ` ${emojiShortcode}${
whiteSpaceRear ? '' : ' '
}`
setValue([contentFront, newTextWithSpace, contentRear].join(''))
} else {
setValue(`${emojiShortcode} `)
}
},
[value, selectionRange.current?.start, selectionRange.current?.end]
)
const { data } = useEmojisQuery({ options: { enabled } })
useEffect(() => {
if (data && data.length) {
let sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[] = []
forEach(
groupBy(sortBy(data, ['category', 'shortcode']), 'category'),
(value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) })
)
emojisDispatch({
type: 'load',
payload: sortedEmojis
})
prefetchEmojis(sortedEmojis, reduceMotionEnabled)
}
}, [data, reduceMotionEnabled])
return (
<EmojisContext.Provider
value={{ emojisState, emojisDispatch }}
children={children}
/>
)
}
export { ComponentEmojis, EmojisContext, EmojisButton, EmojisList }

View File

@ -0,0 +1,50 @@
import { EmojisContext } from '@components/Emojis'
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { Pressable, StyleSheet } from 'react-native'
const EmojisButton = React.memo(
() => {
const { theme } = useTheme()
const { emojisState, emojisDispatch } = useContext(EmojisContext)
return emojisState.enabled ? (
<Pressable
disabled={!emojisState.emojis || !emojisState.emojis.length}
onPress={() =>
emojisDispatch({ type: 'activate', payload: !emojisState.active })
}
hitSlop={StyleConstants.Spacing.S}
style={styles.base}
children={
<Icon
name={
emojisState.emojis && emojisState.emojis.length
? emojisState.active
? 'Type'
: 'Smile'
: 'Meh'
}
size={StyleConstants.Font.Size.L}
color={
emojisState.emojis && emojisState.emojis.length
? theme.primaryDefault
: theme.disabled
}
/>
}
/>
) : null
},
() => true
)
const styles = StyleSheet.create({
base: {
paddingLeft: StyleConstants.Spacing.S
}
})
export default EmojisButton

View File

@ -0,0 +1,122 @@
import { EmojisContext } from '@components/Emojis'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useContext, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
AccessibilityInfo,
findNodeHandle,
Pressable,
SectionList,
StyleSheet,
Text,
View
} from 'react-native'
import FastImage from 'react-native-fast-image'
import validUrl from 'valid-url'
const EmojisList = React.memo(
() => {
const { reduceMotionEnabled } = useAccessibility()
const { t } = useTranslation()
const { emojisState, emojisDispatch } = useContext(EmojisContext)
const { theme } = useTheme()
const listHeader = useCallback(
({ section: { title } }) => (
<Text style={[styles.group, { color: theme.secondary }]}>{title}</Text>
),
[]
)
const listItem = useCallback(
({ index, item }: { item: Mastodon.Emoji[]; index: number }) => {
return (
<View key={index} style={styles.emojis}>
{item.map(emoji => {
const uri = reduceMotionEnabled ? emoji.static_url : emoji.url
if (validUrl.isHttpsUri(uri)) {
return (
<Pressable
key={emoji.shortcode}
onPress={() =>
emojisDispatch({
type: 'shortcode',
payload: `:${emoji.shortcode}:`
})
}
>
<FastImage
accessibilityLabel={t(
'common:customEmoji.accessibilityLabel',
{
emoji: emoji.shortcode
}
)}
accessibilityHint={t(
'screenCompose:content.root.footer.emojis.accessibilityHint'
)}
source={{ uri }}
style={styles.emoji}
/>
</Pressable>
)
} else {
return null
}
})}
</View>
)
},
[]
)
const listRef = useRef<SectionList>(null)
useEffect(() => {
layoutAnimation()
const tagEmojis = findNodeHandle(listRef.current)
if (emojisState.active) {
tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis)
}
}, [emojisState.active])
return emojisState.active ? (
<SectionList
accessible
ref={listRef}
horizontal
keyboardShouldPersistTaps='always'
sections={emojisState.emojis}
keyExtractor={item => item[0].shortcode}
renderSectionHeader={listHeader}
renderItem={listItem}
windowSize={4}
/>
) : null
},
() => true
)
const styles = StyleSheet.create({
group: {
position: 'absolute',
...StyleConstants.FontStyle.S
},
emojis: {
flex: 1,
flexWrap: 'wrap',
marginTop: StyleConstants.Spacing.M,
marginRight: StyleConstants.Spacing.S
},
emoji: {
width: 32,
height: 32,
padding: StyleConstants.Spacing.S,
margin: StyleConstants.Spacing.S
}
})
export default EmojisList

163
src/components/Input.tsx Normal file
View File

@ -0,0 +1,163 @@
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import {
ComponentEmojis,
EmojisButton,
EmojisContext,
EmojisList
} from './Emojis'
export interface Props {
autoFocus?: boolean
title?: string
maxLength?: number
multiline?: boolean
emoji?: boolean
value?: string
setValue:
| Dispatch<SetStateAction<string | undefined>>
| Dispatch<SetStateAction<string>>
}
const Input: React.FC<Props> = ({
autoFocus = true,
title,
maxLength,
multiline = false,
emoji = false,
value,
setValue
}) => {
const { mode, theme } = useTheme()
const animateTitle = useAnimatedStyle(() => {
if (value) {
return {
fontSize: withTiming(StyleConstants.Font.Size.S),
paddingHorizontal: withTiming(StyleConstants.Spacing.XS),
left: withTiming(StyleConstants.Spacing.S),
top: withTiming(-(StyleConstants.Font.Size.S / 2) - 2),
backgroundColor: withTiming(theme.backgroundDefault)
}
} else {
return {
fontSize: withTiming(StyleConstants.Font.Size.M),
paddingHorizontal: withTiming(0),
left: withTiming(StyleConstants.Spacing.S),
top: withTiming(StyleConstants.Spacing.S + 1),
backgroundColor: withTiming(theme.backgroundDefaultTransparent)
}
}
}, [mode, value])
const selectionRange = useRef<{ start: number; end: number }>(
value
? {
start: value.length,
end: value.length
}
: { start: 0, end: 0 }
)
const onSelectionChange = useCallback(
({ nativeEvent: { selection } }) => (selectionRange.current = selection),
[]
)
const [inputFocused, setInputFocused] = useState(false)
useEffect(() => {
layoutAnimation()
}, [inputFocused])
return (
<ComponentEmojis
enabled={emoji}
value={value}
setValue={setValue}
selectionRange={selectionRange}
>
<View style={[styles.base, { borderColor: theme.border }]}>
<EmojisContext.Consumer>
{({ emojisDispatch }) => (
<TextInput
autoFocus={autoFocus}
onFocus={() => setInputFocused(true)}
onBlur={() => {
setInputFocused(false)
emojisDispatch({ type: 'activate', payload: false })
}}
style={[
styles.textInput,
{
color: theme.primaryDefault,
minHeight:
Platform.OS === 'ios' && multiline
? StyleConstants.Font.LineHeight.M * 5
: undefined
}
]}
onChangeText={setValue}
onSelectionChange={onSelectionChange}
value={value}
maxLength={maxLength}
{...(multiline && {
multiline,
numberOfLines: Platform.OS === 'android' ? 5 : undefined
})}
/>
)}
</EmojisContext.Consumer>
{title ? (
<Animated.Text
style={[styles.title, animateTitle, { color: theme.secondary }]}
>
{title}
</Animated.Text>
) : null}
{maxLength && value?.length ? (
<Text style={[styles.maxLength, { color: theme.secondary }]}>
{value?.length} / {maxLength}
</Text>
) : null}
{inputFocused ? <EmojisButton /> : null}
</View>
<EmojisList />
</ComponentEmojis>
)
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'flex-end',
borderWidth: 1,
marginVertical: StyleConstants.Spacing.S,
padding: StyleConstants.Spacing.S
},
title: {
position: 'absolute'
},
textInput: {
flex: 1,
fontSize: StyleConstants.Font.Size.M
},
maxLength: {
...StyleConstants.FontStyle.S
}
})
export default Input

View File

@ -7,16 +7,13 @@ export interface Props {
}
const MenuContainer: React.FC<Props> = ({ children }) => {
return (
<View style={styles.base}>
{children}
</View>
)
return <View style={styles.base}>{children}</View>
}
const styles = StyleSheet.create({
base: {
marginBottom: StyleConstants.Spacing.L
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}
})

View File

@ -19,8 +19,6 @@ const MenuHeader: React.FC<Props> = ({ heading }) => {
const styles = StyleSheet.create({
base: {
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: StyleConstants.Spacing.S
},
text: {

View File

@ -15,6 +15,7 @@ export interface Props {
title: string
description?: string
content?: string | React.ReactNode
badge?: boolean
switchValue?: boolean
switchDisabled?: boolean
@ -33,6 +34,7 @@ const MenuRow: React.FC<Props> = ({
title,
description,
content,
badge = false,
switchValue,
switchDisabled,
switchOnValueChange,
@ -84,6 +86,17 @@ const MenuRow: React.FC<Props> = ({
style={styles.iconFront}
/>
)}
{badge ? (
<View
style={{
width: 8,
height: 8,
backgroundColor: theme.red,
borderRadius: 8,
marginRight: StyleConstants.Spacing.S
}}
/>
) : null}
<View style={styles.main}>
<Text
style={[styles.title, { color: theme.primaryDefault }]}
@ -147,12 +160,12 @@ const MenuRow: React.FC<Props> = ({
const styles = StyleSheet.create({
base: {
minHeight: 50
minHeight: 46,
paddingVertical: StyleConstants.Spacing.S
},
core: {
flex: 1,
flexDirection: 'row',
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
flexDirection: 'row'
},
front: {
flex: 2,
@ -167,7 +180,7 @@ const styles = StyleSheet.create({
marginLeft: StyleConstants.Spacing.M
},
iconFront: {
marginRight: 8
marginRight: StyleConstants.Spacing.S
},
main: {
flex: 1
@ -176,9 +189,7 @@ const styles = StyleSheet.create({
...StyleConstants.FontStyle.M
},
description: {
...StyleConstants.FontStyle.S,
marginTop: StyleConstants.Spacing.XS,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
...StyleConstants.FontStyle.S
},
content: {
...StyleConstants.FontStyle.M

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { getTheme } from '@utils/styles/themes'
import React from 'react'
import React, { RefObject } from 'react'
import { AccessibilityInfo } from 'react-native'
import FlashMessage, {
hideMessage,
@ -11,6 +11,7 @@ import FlashMessage, {
import haptics from './haptics'
const displayMessage = ({
ref,
duration = 'short',
autoHide = true,
message,
@ -20,6 +21,7 @@ const displayMessage = ({
type
}:
| {
ref?: RefObject<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
@ -29,6 +31,7 @@ const displayMessage = ({
type?: undefined
}
| {
ref?: RefObject<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
@ -54,63 +57,88 @@ const displayMessage = ({
haptics('Error')
}
showMessage({
duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000,
autoHide,
message,
description,
onPress,
...(mode &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
if (ref) {
ref.current?.showMessage({
duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000,
autoHide,
message,
description,
onPress,
...(mode &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
} else {
showMessage({
duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000,
autoHide,
message,
description,
onPress,
...(mode &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
}
}
const removeMessage = () => {
// if (ref) {
// ref.current?.hideMessage()
// } else {
hideMessage()
// }
}
const Message = React.memo(
() => {
const { mode, theme } = useTheme()
const Message = React.forwardRef<FlashMessage>((_, ref) => {
const { mode, theme } = useTheme()
return (
<FlashMessage
icon='auto'
position='top'
floating
style={{
backgroundColor: theme.backgroundDefault,
shadowColor: theme.primaryDefault,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: mode === 'light' ? 0.16 : 0.24,
shadowRadius: 4
}}
titleStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.M,
fontWeight: StyleConstants.Font.Weight.Bold
}}
textStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.S
}}
// @ts-ignore
textProps={{ numberOfLines: 2 }}
/>
)
},
() => true
)
return (
<FlashMessage
ref={ref}
icon='auto'
position='top'
floating
style={{
backgroundColor: theme.backgroundDefault,
shadowColor: theme.primaryDefault,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: mode === 'light' ? 0.16 : 0.24,
shadowRadius: 4
}}
titleStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.M,
fontWeight: StyleConstants.Font.Weight.Bold
}}
textStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.S
}}
// @ts-ignore
textProps={{ numberOfLines: 2 }}
/>
)
})
export { Message, displayMessage, removeMessage }

View File

@ -0,0 +1,133 @@
import * as ImagePicker from 'expo-image-picker'
import { Alert, Linking } from 'react-native'
import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import i18next from 'i18next'
import analytics from '@components/analytics'
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types'
export interface Props {
mediaTypes?: ImagePicker.MediaTypeOptions
uploader: (imageInfo: ImageInfo) => void
showActionSheetWithOptions: (
options: ActionSheetOptions,
callback: (i: number) => void
) => void
}
const mediaSelector = async ({
mediaTypes = ImagePicker.MediaTypeOptions.All,
uploader,
showActionSheetWithOptions
}: Props): Promise<any> => {
showActionSheetWithOptions(
{
title: i18next.t('componentMediaSelector:title'),
options: [
i18next.t('componentMediaSelector:options.library'),
i18next.t('componentMediaSelector:options.photo'),
i18next.t('componentMediaSelector:options.cancel')
],
cancelButtonIndex: 2
},
async buttonIndex => {
if (buttonIndex === 0) {
const {
status
} = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),
i18next.t('componentMediaSelector:library.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.cancel'
),
style: 'cancel',
onPress: () =>
analytics('mediaSelector_nopermission', { action: 'cancel' })
},
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('mediaSelector_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes,
exif: false
})
if (!result.cancelled) {
// https://github.com/expo/expo/issues/11214
const fixResult = {
...result,
uri: result.uri.replace('file:/data', 'file:///data')
}
uploader(fixResult)
return
}
}
} else if (buttonIndex === 1) {
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:photo.alert.title'),
i18next.t('componentMediaSelector:photo.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.cancel'
),
style: 'cancel',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'cancel'
})
}
},
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchCameraAsync({
mediaTypes,
exif: false
})
if (!result.cancelled) {
// https://github.com/expo/expo/issues/11214
const fixResult = {
...result,
uri: result.uri.replace('file:/data', 'file:///data')
}
uploader(fixResult)
return
}
}
}
}
)
}
export default mediaSelector