From 8a054f22051527e265ce2611b1e527e70892e793 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sun, 18 Sep 2022 01:02:25 +0200 Subject: [PATCH 01/20] Rewrite emoji component logic to be more generic --- src/components/Emojis.tsx | 137 ++++++------ src/components/Emojis/Button.tsx | 70 +++---- src/components/Emojis/List.tsx | 65 +++--- .../Emojis/helpers/EmojisContext.tsx | 39 ++-- src/components/Input.tsx | 198 ++++++------------ src/components/Menu/Row.tsx | 13 +- src/components/Timeline/Shared/Attachment.tsx | 1 + src/screens/Tabs/Me/Profile/Fields.tsx | 149 ++++++------- src/screens/Tabs/Me/Profile/Name.tsx | 49 ++--- src/screens/Tabs/Me/Profile/Note.tsx | 46 ++-- .../Tabs/Shared/Account/Information/Name.tsx | 47 ++--- .../Tabs/Shared/Account/Information/Note.tsx | 8 +- 12 files changed, 367 insertions(+), 455 deletions(-) diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index 044dce6d..2680b5ed 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -4,19 +4,14 @@ import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useEmojisQuery } from '@utils/queryHooks/emojis' import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' -import React, { - Dispatch, - MutableRefObject, - PropsWithChildren, - SetStateAction, - useCallback, - useEffect, - useReducer -} from 'react' +import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Keyboard, KeyboardAvoidingView, Text, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' +import { ScrollView } from 'react-native-gesture-handler' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' -import EmojisContext, { emojisReducer } from './Emojis/helpers/EmojisContext' +import EmojisContext, { emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext' const prefetchEmojis = ( sortedEmojis: { @@ -45,71 +40,29 @@ const prefetchEmojis = ( } catch {} } -export interface Props { - enabled?: boolean - value?: string - setValue: - | Dispatch> - | Dispatch> - selectionRange: MutableRefObject<{ - start: number - end: number - }> - maxLength?: number +export type Props = { + inputProps: EmojisState['inputProps'] + focusRef?: RefObject } const ComponentEmojis: React.FC = ({ - enabled = false, - value, - setValue, - selectionRange, - maxLength, - children + children, + inputProps, + focusRef }) => { const { reduceMotionEnabled } = useAccessibility() const [emojisState, emojisDispatch] = useReducer(emojisReducer, { - enabled, - active: false, emojis: [], - shortcode: null + targetProps: null, + inputProps }) - useEffect(() => { - if (emojisState.shortcode) { - addEmoji(emojisState.shortcode) - emojisDispatch({ - type: 'shortcode', - payload: null - }) - } - }, [emojisState.shortcode]) - - const addEmoji = useCallback( - (emojiShortcode: string) => { - 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('') - .slice(0, maxLength) - ) - } else { - setValue(`${emojiShortcode} `.slice(0, maxLength)) - } - }, - [value, selectionRange.current?.start, selectionRange.current?.end] - ) + emojisDispatch({ type: 'input', payload: inputProps }) + }, [inputProps]) const { t } = useTranslation() - const { data } = useEmojisQuery({ options: { enabled } }) + const { data } = useEmojisQuery({}) const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) useEffect(() => { if (data && data.length) { @@ -117,9 +70,8 @@ const ComponentEmojis: React.FC = ({ title: string data: Pick[][] }[] = [] - forEach( - groupBy(sortBy(data, ['category', 'shortcode']), 'category'), - (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) }) + forEach(groupBy(sortBy(data, ['category', 'shortcode']), 'category'), (value, key) => + sortedEmojis.push({ title: key, data: chunk(value, 5) }) ) if (frequentEmojis.length) { sortedEmojis.unshift({ @@ -130,19 +82,56 @@ const ComponentEmojis: React.FC = ({ ) }) } - emojisDispatch({ - type: 'load', - payload: sortedEmojis - }) + emojisDispatch({ type: 'load', payload: sortedEmojis }) prefetchEmojis(sortedEmojis, reduceMotionEnabled) } }, [data, reduceMotionEnabled]) + const insets = useSafeAreaInsets() + const [keyboardShown, setKeyboardShown] = useState(false) + useEffect(() => { + const showSubscription = Keyboard.addListener('keyboardWillShow', () => { + emojisDispatch({ type: 'target', payload: null }) + setKeyboardShown(true) + }) + const hideSubscription = Keyboard.addListener('keyboardWillHide', () => { + setKeyboardShown(false) + }) + + return () => { + showSubscription.remove() + hideSubscription.remove() + } + }, []) + useEffect(() => { + if (focusRef) { + setTimeout(() => focusRef.current?.focus(), 500) + } + }, []) + return ( - + + + + + + : } + /> + + + + ) } diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx index 92ce4620..bb0b495c 100644 --- a/src/components/Emojis/Button.tsx +++ b/src/components/Emojis/Button.tsx @@ -2,49 +2,41 @@ 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' +import { Keyboard, Pressable } from 'react-native' import EmojisContext from './helpers/EmojisContext' -const EmojisButton = React.memo( - () => { - const { colors } = useTheme() - const { emojisState, emojisDispatch } = useContext(EmojisContext) +const EmojisButton: React.FC = () => { + const { colors } = useTheme() + const { emojisState, emojisDispatch } = useContext(EmojisContext) - return emojisState.enabled ? ( - - emojisDispatch({ type: 'activate', payload: !emojisState.active }) + return ( + { + const targetProps = emojisState.inputProps?.find(props => props.ref.current?.isFocused()) + if (!targetProps) { + return } - hitSlop={StyleConstants.Spacing.S} - style={styles.base} - children={ - + if (emojisState.targetProps === null) { + Keyboard.dismiss() } - /> - ) : null - }, - () => true -) - -const styles = StyleSheet.create({ - base: { - paddingLeft: StyleConstants.Spacing.S - } -}) + emojisDispatch({ type: 'target', payload: targetProps }) + }} + hitSlop={StyleConstants.Spacing.S} + style={{ alignSelf: 'flex-end', padding: StyleConstants.Spacing.Global.PagePadding }} + children={ + + } + /> + ) +} export default EmojisButton diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index 32a2a931..a9facb2e 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -7,13 +7,7 @@ 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, - View -} from 'react-native' +import { AccessibilityInfo, findNodeHandle, Pressable, SectionList, View } from 'react-native' import FastImage from 'react-native-fast-image' import validUrl from 'valid-url' import EmojisContext from './helpers/EmojisContext' @@ -27,6 +21,33 @@ const EmojisList = React.memo( const { emojisState, emojisDispatch } = useContext(EmojisContext) const { colors } = useTheme() + const addEmoji = (shortcode: string) => { + if (!emojisState.targetProps) { + return + } + + const inputValue = emojisState.targetProps.value + const maxLength = emojisState.targetProps.maxLength + const selectionRange = emojisState.targetProps.selectionRange || { + start: emojisState.targetProps.value.length, + end: emojisState.targetProps.value.length + } + + if (inputValue?.length) { + const contentFront = inputValue.slice(0, selectionRange.start) + const contentRear = inputValue.slice(selectionRange.end) + + const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) + + const newTextWithSpace = ` ${shortcode}${whiteSpaceRear ? '' : ' '}` + emojisState.targetProps.setValue( + [contentFront, newTextWithSpace, contentRear].join('').slice(0, maxLength) + ) + } else { + emojisState.targetProps.setValue(`${shortcode} `.slice(0, maxLength)) + } + } + const listItem = useCallback( ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { return ( @@ -46,20 +67,14 @@ const EmojisList = React.memo( { - emojisDispatch({ - type: 'shortcode', - payload: `:${emoji.shortcode}:` - }) + addEmoji(`:${emoji.shortcode}:`) dispatch(countInstanceEmoji(emoji)) }} > ) }, - [] + [emojisState.targetProps] ) const listRef = useRef(null) useEffect(() => { - layoutAnimation() const tagEmojis = findNodeHandle(listRef.current) - if (emojisState.active) { + if (emojisState.targetProps) { + layoutAnimation() tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) } - }, [emojisState.active]) + }, [emojisState.targetProps]) - return emojisState.active ? ( + return emojisState.targetProps ? ( item[0].shortcode} renderSectionHeader={({ section: { title } }) => ( - + {title} )} renderItem={listItem} windowSize={4} + contentContainerStyle={{ paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }} /> ) : null }, diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index 121207de..1f2a2800 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -1,28 +1,27 @@ -import { createContext, Dispatch } from 'react' +import { createContext, Dispatch, RefObject, SetStateAction } from 'react' +import { TextInput } from 'react-native' + +type inputProps = { + ref: RefObject + value: string + setValue: Dispatch> + selectionRange?: { start: number; end: number } + maxLength?: number +} export type EmojisState = { - enabled: boolean - active: boolean emojis: { title: string data: Pick[][] }[] - shortcode: Mastodon.Emoji['shortcode'] | null + targetProps: inputProps | null + inputProps: inputProps[] } export type EmojisAction = - | { - type: 'load' - payload: NonNullable - } - | { - type: 'activate' - payload: EmojisState['active'] - } - | { - type: 'shortcode' - payload: EmojisState['shortcode'] - } + | { type: 'load'; payload: NonNullable } + | { type: 'target'; payload: EmojisState['targetProps'] } + | { type: 'input'; payload: EmojisState['inputProps'] } type ContextType = { emojisState: EmojisState @@ -32,12 +31,12 @@ const EmojisContext = createContext({} as ContextType) export 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 } + case 'target': + return { ...state, targetProps: action.payload } + case 'input': + return { ...state, inputProps: action.payload } } } diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 5c3752c2..0ba57800 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,99 +1,55 @@ import { StyleConstants } from '@utils/styles/constants' -import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' -import React, { - Dispatch, - SetStateAction, - useEffect, - useRef, - useState -} from 'react' +import React, { Dispatch, forwardRef, RefObject, SetStateAction, useRef } from 'react' import { Platform, TextInput, TextInputProps, View } from 'react-native' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { ComponentEmojis, EmojisButton, EmojisList } from './Emojis' -import EmojisContext from './Emojis/helpers/EmojisContext' import CustomText from './Text' -export interface Props { - autoFocus?: boolean +export type Props = { + value: string + setValue: Dispatch> + selectionRange?: { start: number; end: number } title?: string - multiline?: boolean +} & Omit< + TextInputProps, + | 'style' + | 'onChangeText' + | 'onSelectionChange' + | 'keyboardAppearance' + | 'textAlignVertical' + | 'multiline' +> - emoji?: boolean +const ComponentInput = forwardRef( + ( + { title, multiline = false, value, setValue, selectionRange, ...props }: Props, + ref: RefObject + ) => { + const { colors, mode } = useTheme() - value?: string - setValue: - | Dispatch> - | Dispatch> - - options?: Omit< - TextInputProps, - | 'autoFocus' - | 'onFocus' - | 'onBlur' - | 'style' - | 'onChangeText' - | 'onSelectionChange' - | 'keyboardAppearance' - | 'textAlignVertical' - > -} - -const Input: React.FC = ({ - autoFocus = true, - title, - multiline = false, - emoji = false, - value, - setValue, - options -}) => { - const { colors, mode } = 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(colors.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(colors.backgroundDefaultTransparent) - } - } - }, [mode, value]) - - const selectionRange = useRef<{ start: number; end: number }>( - value - ? { - start: value.length, - end: value.length + 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(colors.backgroundDefault) } - : { start: 0, end: 0 } - ) + } else { + return { + fontSize: withTiming(StyleConstants.Font.Size.M), + paddingHorizontal: withTiming(0), + left: withTiming(StyleConstants.Spacing.S), + top: withTiming(StyleConstants.Spacing.S + 1), + backgroundColor: withTiming(colors.backgroundDefaultTransparent) + } + } + }, [mode, value]) - const [inputFocused, setInputFocused] = useState(false) - useEffect(() => { - layoutAnimation() - }, [inputFocused]) - - return ( - + return ( = ({ alignItems: 'stretch' }} > - - {({ emojisDispatch }) => ( - setInputFocused(true)} - onBlur={() => { - setInputFocused(false) - emojisDispatch({ type: 'activate', payload: false }) - }} - style={{ - flex: 1, - fontSize: StyleConstants.Font.Size.M, - color: colors.primaryDefault, - minHeight: - Platform.OS === 'ios' && multiline - ? StyleConstants.Font.LineHeight.M * 5 - : undefined - }} - onChangeText={setValue} - onSelectionChange={({ nativeEvent: { selection } }) => - (selectionRange.current = selection) - } - value={value} - {...(multiline && { - multiline, - numberOfLines: Platform.OS === 'android' ? 5 : undefined - })} - keyboardAppearance={mode} - textAlignVertical='top' - {...options} - /> - )} - + (selectionRange = selection)} + value={value} + {...(multiline && { + multiline, + numberOfLines: Platform.OS === 'android' ? 5 : undefined + })} + keyboardAppearance={mode} + textAlignVertical='top' + {...props} + /> + {title ? ( - + {title} ) : null} + - {options?.maxLength && value?.length ? ( + {props?.maxLength && value?.length ? ( = ({ color: colors.secondary }} > - {value?.length} / {options.maxLength} + {value?.length} / {props.maxLength} ) : null} - {inputFocused ? : null} - - - ) -} + ) + } +) -export default Input +export default ComponentInput diff --git a/src/components/Menu/Row.tsx b/src/components/Menu/Row.tsx index d6097f0d..e5027ff1 100644 --- a/src/components/Menu/Row.tsx +++ b/src/components/Menu/Row.tsx @@ -50,10 +50,7 @@ const MenuRow: React.FC = ({ const loadingSpinkit = useMemo( () => ( - + ), [theme] @@ -111,11 +108,7 @@ const MenuRow: React.FC = ({ }} /> ) : null} - + {title} @@ -127,7 +120,7 @@ const MenuRow: React.FC = ({ flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', - marginLeft: StyleConstants.Spacing.M + paddingLeft: StyleConstants.Spacing.L }} > {content ? ( diff --git a/src/components/Timeline/Shared/Attachment.tsx b/src/components/Timeline/Shared/Attachment.tsx index 02857181..ba9fad2a 100644 --- a/src/components/Timeline/Shared/Attachment.tsx +++ b/src/components/Timeline/Shared/Attachment.tsx @@ -41,6 +41,7 @@ const TimelineAttachment = React.memo( } const [sensitiveShown, setSensitiveShown] = useState(defaultSensitive()) + // @ts-ignore const imageUrls: RootStackParamList['Screen-ImagesViewer']['imageUrls'] = status.media_attachments .map(attachment => { diff --git a/src/screens/Tabs/Me/Profile/Fields.tsx b/src/screens/Tabs/Me/Profile/Fields.tsx index ed57b808..58eea69b 100644 --- a/src/screens/Tabs/Me/Profile/Fields.tsx +++ b/src/screens/Tabs/Me/Profile/Fields.tsx @@ -1,27 +1,69 @@ +import { ComponentEmojis } from '@components/Emojis' +import { EmojisState } from '@components/Emojis/helpers/EmojisContext' import { HeaderLeft, HeaderRight } from '@components/Header' -import Input from '@components/Input' +import ComponentInput from '@components/Input' import CustomText from '@components/Text' import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { useProfileMutation } from '@utils/queryHooks/profile' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { isEqual } from 'lodash' -import React, { RefObject, useEffect, useState } from 'react' +import React, { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, View } from 'react-native' +import { Alert, TextInput, View } from 'react-native' import FlashMessage from 'react-native-flash-message' -import { ScrollView } from 'react-native-gesture-handler' -const prepareFields = ( - fields: Mastodon.Field[] | undefined -): Mastodon.Field[] => { - return Array.from(Array(4).keys()).map(index => { - if (fields && fields[index]) { - return fields[index] - } else { - return { name: '', value: '', verified_at: null } - } - }) +const Field: React.FC<{ + allProps: EmojisState['inputProps'] + setDirty: Dispatch> + index: number + field?: Mastodon.Field +}> = ({ allProps, setDirty, index, field }) => { + const { colors } = useTheme() + const { t } = useTranslation('screenTabs') + + const [name, setName] = useState(field?.name || '') + const [value, setValue] = useState(field?.value || '') + allProps[index * 2] = { + ref: useRef(null), + value: name, + setValue: setName, + selectionRange: name ? { start: name.length, end: name.length } : { start: 0, end: 0 }, + maxLength: 255 + } + allProps[index * 2 + 1] = { + ref: useRef(null), + value, + setValue, + selectionRange: value ? { start: value.length, end: value.length } : { start: 0, end: 0 }, + maxLength: 255 + } + + useEffect(() => { + setDirty(dirty => + dirty ? dirty : !isEqual(field?.name, name) || !isEqual(field?.value, value) + ) + }, [name, value]) + + return ( + <> + + {t('me.profile.fields.group', { index: index + 1 })} + + + + + ) } const TabMeProfileFields: React.FC< @@ -35,16 +77,13 @@ const TabMeProfileFields: React.FC< }, navigation }) => { - const { colors, theme } = useTheme() + const { theme } = useTheme() const { t, i18n } = useTranslation('screenTabs') const { mutateAsync, status } = useProfileMutation() - const [newFields, setNewFields] = useState(prepareFields(fields)) + const allProps: EmojisState['inputProps'] = [] const [dirty, setDirty] = useState(false) - useEffect(() => { - setDirty(!isEqual(prepareFields(fields), newFields)) - }, [newFields]) useEffect(() => { navigation.setOptions({ @@ -88,9 +127,15 @@ const TabMeProfileFields: React.FC< failed: true }, type: 'fields_attributes', - data: newFields - .filter(field => field.name.length && field.value.length) - .map(field => ({ name: field.name, value: field.value })) + data: Array.from(Array(4).keys()) + .filter( + index => + allProps[index * 2]?.value.length || allProps[index * 2 + 1]?.value.length + ) + .map(index => ({ + name: allProps[index * 2].value, + value: allProps[index * 2 + 1].value + })) }).then(() => { navigation.navigate('Tab-Me-Profile-Root') }) @@ -98,60 +143,22 @@ const TabMeProfileFields: React.FC< /> ) }) - }, [theme, i18n.language, dirty, status, newFields]) + }, [theme, i18n.language, dirty, status, allProps.map(p => p.value)]) return ( - - + + {Array.from(Array(4).keys()).map(index => ( - - - {t('me.profile.fields.group', { index: index + 1 })} - - - setNewFields( - newFields.map((field, i) => - i === index ? { ...field, name: v } : field - ) - ) - } - emoji - /> - - setNewFields( - newFields.map((field, i) => - i === index ? { ...field, value: v } : field - ) - ) - } - emoji - /> - + ))} - + ) } diff --git a/src/screens/Tabs/Me/Profile/Name.tsx b/src/screens/Tabs/Me/Profile/Name.tsx index 3c878245..736e7b35 100644 --- a/src/screens/Tabs/Me/Profile/Name.tsx +++ b/src/screens/Tabs/Me/Profile/Name.tsx @@ -1,14 +1,15 @@ +import { ComponentEmojis } from '@components/Emojis' +import { EmojisState } from '@components/Emojis/helpers/EmojisContext' import { HeaderLeft, HeaderRight } from '@components/Header' -import Input from '@components/Input' +import ComponentInput from '@components/Input' import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { useProfileMutation } from '@utils/queryHooks/profile' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { RefObject, useEffect, useState } from 'react' +import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, StyleSheet } from 'react-native' +import { Alert, TextInput, View } from 'react-native' import FlashMessage from 'react-native-flash-message' -import { ScrollView } from 'react-native-gesture-handler' const TabMeProfileName: React.FC< TabMeProfileStackScreenProps<'Tab-Me-Profile-Name'> & { @@ -26,6 +27,15 @@ const TabMeProfileName: React.FC< const { mutateAsync, status } = useProfileMutation() const [displayName, setDisplayName] = useState(display_name) + const displayNameProps: NonNullable = { + ref: useRef(null), + value: displayName, + setValue: setDisplayName, + selectionRange: displayName + ? { start: displayName.length, end: displayName.length } + : { start: 0, end: 0 }, + maxLength: 30 + } const [dirty, setDirty] = useState(false) useEffect(() => { @@ -85,27 +95,18 @@ const TabMeProfileName: React.FC< }, [theme, i18n.language, dirty, status, displayName]) return ( - - - + + + + + ) } -const styles = StyleSheet.create({ - base: { - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding - } -}) - export default TabMeProfileName diff --git a/src/screens/Tabs/Me/Profile/Note.tsx b/src/screens/Tabs/Me/Profile/Note.tsx index eefa7874..8a1a75bb 100644 --- a/src/screens/Tabs/Me/Profile/Note.tsx +++ b/src/screens/Tabs/Me/Profile/Note.tsx @@ -1,14 +1,15 @@ +import { ComponentEmojis } from '@components/Emojis' +import { EmojisState } from '@components/Emojis/helpers/EmojisContext' import { HeaderLeft, HeaderRight } from '@components/Header' -import Input from '@components/Input' +import ComponentInput from '@components/Input' import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { useProfileMutation } from '@utils/queryHooks/profile' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { RefObject, useEffect, useState } from 'react' +import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, StyleSheet, View } from 'react-native' +import { Alert, TextInput, View } from 'react-native' import FlashMessage from 'react-native-flash-message' -import { ScrollView } from 'react-native-gesture-handler' const TabMeProfileNote: React.FC< TabMeProfileStackScreenProps<'Tab-Me-Profile-Note'> & { @@ -25,12 +26,19 @@ const TabMeProfileNote: React.FC< const { t, i18n } = useTranslation('screenTabs') const { mutateAsync, status } = useProfileMutation() - const [newNote, setNewNote] = useState(note) + const [notes, setNotes] = useState(note) + const notesProps: NonNullable = { + ref: useRef(null), + value: notes, + setValue: setNotes, + selectionRange: notes ? { start: notes.length, end: notes.length } : { start: 0, end: 0 }, + maxLength: 500 + } const [dirty, setDirty] = useState(false) useEffect(() => { - setDirty(note !== newNote) - }, [newNote]) + setDirty(note !== notes) + }, [notes]) useEffect(() => { navigation.setOptions({ @@ -74,7 +82,7 @@ const TabMeProfileNote: React.FC< failed: true }, type: 'note', - data: newNote + data: notes }).then(() => { navigation.navigate('Tab-Me-Profile-Root') }) @@ -82,27 +90,15 @@ const TabMeProfileNote: React.FC< /> ) }) - }, [theme, i18n.language, dirty, status, newNote]) + }, [theme, i18n.language, dirty, status, notes]) return ( - - - + + + - + ) } -const styles = StyleSheet.create({ - base: { - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding - } -}) - export default TabMeProfileNote diff --git a/src/screens/Tabs/Shared/Account/Information/Name.tsx b/src/screens/Tabs/Shared/Account/Information/Name.tsx index 92bff3d5..af56628e 100644 --- a/src/screens/Tabs/Shared/Account/Information/Name.tsx +++ b/src/screens/Tabs/Shared/Account/Information/Name.tsx @@ -1,18 +1,16 @@ -import Input from '@components/Input' import { ParseEmojis } from '@components/Parse' import CustomText from '@components/Text' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { View } from 'react-native' import { PlaceholderLine } from 'rn-placeholder' export interface Props { account: Mastodon.Account | undefined - edit?: boolean // Editing mode } -const AccountInformationName: React.FC = ({ account, edit }) => { +const AccountInformationName: React.FC = ({ account }) => { const { colors } = useTheme() const movedContent = useMemo(() => { @@ -30,8 +28,6 @@ const AccountInformationName: React.FC = ({ account, edit }) => { } }, [account?.moved]) - const [displatName, setDisplayName] = useState(account?.display_name) - return ( = ({ account, edit }) => { }} > {account ? ( - edit ? ( - - ) : ( - <> - - - - {movedContent} - - ) + <> + + + + {movedContent} + ) : ( = ({ account, edit }) => { ) } -export default React.memo( - AccountInformationName, - (_, next) => next.account === undefined -) +export default React.memo(AccountInformationName, (_, next) => next.account === undefined) diff --git a/src/screens/Tabs/Shared/Account/Information/Note.tsx b/src/screens/Tabs/Shared/Account/Information/Note.tsx index b87beaaa..447ae74c 100644 --- a/src/screens/Tabs/Shared/Account/Information/Note.tsx +++ b/src/screens/Tabs/Shared/Account/Information/Note.tsx @@ -1,4 +1,3 @@ -import Input from '@components/Input' import { ParseHTML } from '@components/Parse' import { StyleConstants } from '@utils/styles/constants' import React, { useState } from 'react' @@ -7,16 +6,11 @@ import { StyleSheet, View } from 'react-native' export interface Props { account: Mastodon.Account | undefined myInfo?: boolean - edit?: boolean } const AccountInformationNote = React.memo( - ({ account, myInfo, edit }: Props) => { + ({ account, myInfo }: Props) => { const [note, setNote] = useState(account?.source?.note) - if (edit) { - return - } - if ( myInfo || !account?.note || From 725a061e78ccd3665a84b49bced4fa6e3b46d44e Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sun, 18 Sep 2022 16:49:18 +0200 Subject: [PATCH 02/20] Added emoji search --- src/components/Emojis.tsx | 27 ++-- src/components/Emojis/Button.tsx | 34 +++-- src/components/Emojis/List.tsx | 131 ++++++++++++++---- .../Emojis/helpers/EmojisContext.tsx | 1 + 4 files changed, 140 insertions(+), 53 deletions(-) diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index 2680b5ed..a053820f 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -6,7 +6,7 @@ import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, Text, TextInput, View } from 'react-native' +import { Keyboard, KeyboardAvoidingView, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' import { ScrollView } from 'react-native-gesture-handler' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' @@ -66,10 +66,7 @@ const ComponentEmojis: React.FC = ({ const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) useEffect(() => { if (data && data.length) { - let sortedEmojis: { - title: string - data: Pick[][] - }[] = [] + let sortedEmojis: EmojisState['emojis'] = [] forEach(groupBy(sortBy(data, ['category', 'shortcode']), 'category'), (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) }) ) @@ -79,7 +76,8 @@ const ComponentEmojis: React.FC = ({ data: chunk( frequentEmojis.map(e => e.emoji), 5 - ) + ), + type: 'frequent' }) } emojisDispatch({ type: 'load', payload: sortedEmojis }) @@ -91,7 +89,10 @@ const ComponentEmojis: React.FC = ({ const [keyboardShown, setKeyboardShown] = useState(false) useEffect(() => { const showSubscription = Keyboard.addListener('keyboardWillShow', () => { - emojisDispatch({ type: 'target', payload: null }) + const anyInputHasFocus = inputProps.filter(props => props.ref.current?.isFocused()).length + if (anyInputHasFocus) { + emojisDispatch({ type: 'target', payload: null }) + } setKeyboardShown(true) }) const hideSubscription = Keyboard.addListener('keyboardWillHide', () => { @@ -102,7 +103,7 @@ const ComponentEmojis: React.FC = ({ showSubscription.remove() hideSubscription.remove() } - }, []) + }, [inputProps]) useEffect(() => { if (focusRef) { setTimeout(() => focusRef.current?.focus(), 500) @@ -117,16 +118,10 @@ const ComponentEmojis: React.FC = ({ : } + children={emojisState.targetProps ? : } /> diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx index bb0b495c..6ccc2af8 100644 --- a/src/components/Emojis/Button.tsx +++ b/src/components/Emojis/Button.tsx @@ -2,7 +2,7 @@ import Icon from '@components/Icon' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { useContext } from 'react' -import { Keyboard, Pressable } from 'react-native' +import { Keyboard, Pressable, View } from 'react-native' import EmojisContext from './helpers/EmojisContext' const EmojisButton: React.FC = () => { @@ -23,17 +23,29 @@ const EmojisButton: React.FC = () => { emojisDispatch({ type: 'target', payload: targetProps }) }} hitSlop={StyleConstants.Spacing.S} - style={{ alignSelf: 'flex-end', padding: StyleConstants.Spacing.Global.PagePadding }} + style={{ + alignSelf: 'flex-end', + padding: StyleConstants.Spacing.Global.PagePadding / 2 + }} children={ - + + + } /> ) diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index a9facb2e..3c0c838c 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -1,3 +1,4 @@ +import Icon from '@components/Icon' import CustomText from '@components/Text' import { useAppDispatch } from '@root/store' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' @@ -5,9 +6,17 @@ import { countInstanceEmoji } from '@utils/slices/instancesSlice' 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 { chunk } from 'lodash' +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { AccessibilityInfo, findNodeHandle, Pressable, SectionList, View } from 'react-native' +import { + AccessibilityInfo, + findNodeHandle, + Pressable, + SectionList, + TextInput, + View +} from 'react-native' import FastImage from 'react-native-fast-image' import validUrl from 'valid-url' import EmojisContext from './helpers/EmojisContext' @@ -18,8 +27,8 @@ const EmojisList = React.memo( const { reduceMotionEnabled } = useAccessibility() const { t } = useTranslation() - const { emojisState, emojisDispatch } = useContext(EmojisContext) - const { colors } = useTheme() + const { emojisState } = useContext(EmojisContext) + const { colors, mode } = useTheme() const addEmoji = (shortcode: string) => { if (!emojisState.targetProps) { @@ -70,6 +79,7 @@ const EmojisList = React.memo( addEmoji(`:${emoji.shortcode}:`) dispatch(countInstanceEmoji(emoji)) }} + style={{ padding: StyleConstants.Spacing.S }} > ) @@ -107,23 +112,97 @@ const EmojisList = React.memo( } }, [emojisState.targetProps]) + const [search, setSearch] = useState('') + const searchLength = useRef(0) + useEffect(() => { + if ( + (search.length === 0 && searchLength.current === 1) || + (search.length === 1 && searchLength.current === 0) + ) { + layoutAnimation() + } + searchLength.current = search.length + }, [search.length, searchLength.current]) + return emojisState.targetProps ? ( - item[0].shortcode} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - renderItem={listItem} - windowSize={4} - contentContainerStyle={{ paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }} - /> + + + + + + + + e.type !== 'frequent') + .flatMap(e => + e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) + ), + 2 + ) + } + ] + : emojisState.emojis + } + keyExtractor={item => item[0]?.shortcode} + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + renderItem={listItem} + windowSize={4} + contentContainerStyle={{ + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, + minHeight: 32 * 2 + StyleConstants.Spacing.M * 3 + }} + /> + ) : null }, () => true diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index 1f2a2800..b7afe195 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -13,6 +13,7 @@ export type EmojisState = { emojis: { title: string data: Pick[][] + type?: 'frequent' }[] targetProps: inputProps | null inputProps: inputProps[] From 7282434e69d9d0a4ef1d87e4ee822ee97e64a424 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sun, 18 Sep 2022 23:28:14 +0200 Subject: [PATCH 03/20] Fix emoji state --- src/components/Emojis.tsx | 40 ++- src/components/Emojis/Button.tsx | 13 +- src/components/Emojis/List.tsx | 331 +++++++++--------- .../Emojis/helpers/EmojisContext.tsx | 18 +- src/components/Input.tsx | 43 ++- src/screens/Tabs/Me/Profile/Fields.tsx | 29 +- src/screens/Tabs/Me/Profile/Name.tsx | 27 +- src/screens/Tabs/Me/Profile/Note.tsx | 14 +- 8 files changed, 265 insertions(+), 250 deletions(-) diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index a053820f..a6393082 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -1,15 +1,15 @@ import EmojisButton from '@components/Emojis/Button' import EmojisList from '@components/Emojis/List' +import { PasteInputRef } from '@mattermost/react-native-paste-input' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useEmojisQuery } from '@utils/queryHooks/emojis' import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, TextInput, View } from 'react-native' +import { Keyboard, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' -import { ScrollView } from 'react-native-gesture-handler' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' import EmojisContext, { emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext' @@ -42,20 +42,26 @@ const prefetchEmojis = ( export type Props = { inputProps: EmojisState['inputProps'] - focusRef?: RefObject + focusRef?: RefObject + customButton?: boolean + customEdges?: Edge[] + customBehavior?: 'height' | 'padding' | 'position' } const ComponentEmojis: React.FC = ({ children, inputProps, - focusRef + focusRef, + customButton = false, + customEdges = ['bottom'], + customBehavior }) => { const { reduceMotionEnabled } = useAccessibility() const [emojisState, emojisDispatch] = useReducer(emojisReducer, { emojis: [], - targetProps: null, - inputProps + inputProps, + targetIndex: -1 }) useEffect(() => { emojisDispatch({ type: 'input', payload: inputProps }) @@ -89,9 +95,9 @@ const ComponentEmojis: React.FC = ({ const [keyboardShown, setKeyboardShown] = useState(false) useEffect(() => { const showSubscription = Keyboard.addListener('keyboardWillShow', () => { - const anyInputHasFocus = inputProps.filter(props => props.ref.current?.isFocused()).length + const anyInputHasFocus = inputProps.filter(props => props.isFocused.current).length if (anyInputHasFocus) { - emojisDispatch({ type: 'target', payload: null }) + emojisDispatch({ type: 'target', payload: -1 }) } setKeyboardShown(true) }) @@ -111,17 +117,23 @@ const ComponentEmojis: React.FC = ({ }, []) return ( - - - + + + - + {children} : } + children={ + emojisState.targetIndex !== -1 ? ( + + ) : customButton ? null : ( + + ) + } /> diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx index 6ccc2af8..3b7be527 100644 --- a/src/components/Emojis/Button.tsx +++ b/src/components/Emojis/Button.tsx @@ -9,18 +9,19 @@ const EmojisButton: React.FC = () => { const { colors } = useTheme() const { emojisState, emojisDispatch } = useContext(EmojisContext) + const focusedPropsIndex = emojisState.inputProps?.findIndex(props => props.isFocused.current) + if (focusedPropsIndex === -1) { + return null + } + return ( { - const targetProps = emojisState.inputProps?.find(props => props.ref.current?.isFocused()) - if (!targetProps) { - return - } - if (emojisState.targetProps === null) { + if (emojisState.targetIndex === -1) { Keyboard.dismiss() } - emojisDispatch({ type: 'target', payload: targetProps }) + emojisDispatch({ type: 'target', payload: focusedPropsIndex }) }} hitSlop={StyleConstants.Spacing.S} style={{ diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index 3c0c838c..9f5aeb14 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -7,7 +7,7 @@ import { StyleConstants } from '@utils/styles/constants' import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' import { chunk } from 'lodash' -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { AccessibilityInfo, @@ -21,191 +21,188 @@ import FastImage from 'react-native-fast-image' import validUrl from 'valid-url' import EmojisContext from './helpers/EmojisContext' -const EmojisList = React.memo( - () => { - const dispatch = useAppDispatch() - const { reduceMotionEnabled } = useAccessibility() - const { t } = useTranslation() +const EmojisList = () => { + const dispatch = useAppDispatch() + const { reduceMotionEnabled } = useAccessibility() + const { t } = useTranslation() - const { emojisState } = useContext(EmojisContext) - const { colors, mode } = useTheme() + const { emojisState } = useContext(EmojisContext) + const { colors, mode } = useTheme() - const addEmoji = (shortcode: string) => { - if (!emojisState.targetProps) { - return - } - - const inputValue = emojisState.targetProps.value - const maxLength = emojisState.targetProps.maxLength - const selectionRange = emojisState.targetProps.selectionRange || { - start: emojisState.targetProps.value.length, - end: emojisState.targetProps.value.length - } - - if (inputValue?.length) { - const contentFront = inputValue.slice(0, selectionRange.start) - const contentRear = inputValue.slice(selectionRange.end) - - const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) - - const newTextWithSpace = ` ${shortcode}${whiteSpaceRear ? '' : ' '}` - emojisState.targetProps.setValue( - [contentFront, newTextWithSpace, contentRear].join('').slice(0, maxLength) - ) - } else { - emojisState.targetProps.setValue(`${shortcode} `.slice(0, maxLength)) - } + const addEmoji = (shortcode: string) => { + if (emojisState.targetIndex === -1) { + return } - const listItem = useCallback( - ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { - return ( - - {item.map(emoji => { - const uri = reduceMotionEnabled ? emoji.static_url : emoji.url - if (validUrl.isHttpsUri(uri)) { - return ( - { - addEmoji(`:${emoji.shortcode}:`) - dispatch(countInstanceEmoji(emoji)) - }} - style={{ padding: StyleConstants.Spacing.S }} - > - - - ) - } else { - return null - } - })} - - ) - }, - [emojisState.targetProps] + const { + value: [value, setValue], + selection: [selection, setSelection], + ref, + maxLength + } = emojisState.inputProps[emojisState.targetIndex] + + const contentFront = value.slice(0, selection.start) + const contentRear = value.slice(selection.end) + + const spaceFront = /\s/g.test(contentFront.slice(-1)) ? '' : ' ' + const spaceRear = /\s/g.test(contentRear[0]) ? '' : ' ' + + setValue( + [contentFront, spaceFront, shortcode, spaceRear, contentRear].join('').slice(0, maxLength) ) - const listRef = useRef(null) - useEffect(() => { - const tagEmojis = findNodeHandle(listRef.current) - if (emojisState.targetProps) { - layoutAnimation() - tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) - } - }, [emojisState.targetProps]) + const addedLength = spaceFront.length + shortcode.length + spaceRear.length - const [search, setSearch] = useState('') - const searchLength = useRef(0) - useEffect(() => { - if ( - (search.length === 0 && searchLength.current === 1) || - (search.length === 1 && searchLength.current === 0) - ) { - layoutAnimation() - } - searchLength.current = search.length - }, [search.length, searchLength.current]) + setSelection({ start: selection.start + addedLength }) + ref?.current?.setNativeProps({ + selection: { start: selection.start + addedLength } + }) + } - return emojisState.targetProps ? ( + const listItem = ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { + return ( + + {item.map(emoji => { + const uri = reduceMotionEnabled ? emoji.static_url : emoji.url + if (validUrl.isHttpsUri(uri)) { + return ( + { + addEmoji(`:${emoji.shortcode}:`) + dispatch(countInstanceEmoji(emoji)) + }} + style={{ padding: StyleConstants.Spacing.S }} + > + + + ) + } else { + return null + } + })} + + ) + } + + const listRef = useRef(null) + useEffect(() => { + const tagEmojis = findNodeHandle(listRef.current) + if (emojisState.targetIndex !== -1) { + layoutAnimation() + tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) + } + }, [emojisState.targetIndex]) + + const [search, setSearch] = useState('') + const searchLength = useRef(0) + useEffect(() => { + if ( + (search.length === 0 && searchLength.current === 1) || + (search.length === 1 && searchLength.current === 0) + ) { + layoutAnimation() + } + searchLength.current = search.length + }, [search.length, searchLength.current]) + + return emojisState.targetIndex !== -1 ? ( + - - - - + - e.type !== 'frequent') - .flatMap(e => - e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) - ), - 2 - ) - } - ] - : emojisState.emojis - } - keyExtractor={item => item[0]?.shortcode} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - renderItem={listItem} - windowSize={4} - contentContainerStyle={{ - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, - minHeight: 32 * 2 + StyleConstants.Spacing.M * 3 + - ) : null - }, - () => true -) + e.type !== 'frequent') + .flatMap(e => + e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) + ), + 2 + ) + } + ] + : emojisState.emojis + } + keyExtractor={item => item[0]?.shortcode} + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + renderItem={listItem} + windowSize={4} + contentContainerStyle={{ + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, + minHeight: 32 * 2 + StyleConstants.Spacing.M * 3 + }} + /> + + ) : null +} export default EmojisList diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index b7afe195..f821ce03 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -1,11 +1,11 @@ -import { createContext, Dispatch, RefObject, SetStateAction } from 'react' +import { createContext, Dispatch, MutableRefObject, RefObject } from 'react' import { TextInput } from 'react-native' type inputProps = { - ref: RefObject - value: string - setValue: Dispatch> - selectionRange?: { start: number; end: number } + value: [string, (value: string) => void] + selection: [{ start: number; end?: number }, (selection: { start: number; end?: number }) => void] + isFocused: MutableRefObject + ref?: RefObject // For controlling focus maxLength?: number } @@ -15,14 +15,14 @@ export type EmojisState = { data: Pick[][] type?: 'frequent' }[] - targetProps: inputProps | null inputProps: inputProps[] + targetIndex: number } export type EmojisAction = | { type: 'load'; payload: NonNullable } - | { type: 'target'; payload: EmojisState['targetProps'] } | { type: 'input'; payload: EmojisState['inputProps'] } + | { type: 'target'; payload: EmojisState['targetIndex'] } type ContextType = { emojisState: EmojisState @@ -34,10 +34,10 @@ export const emojisReducer = (state: EmojisState, action: EmojisAction) => { switch (action.type) { case 'load': return { ...state, emojis: action.payload } - case 'target': - return { ...state, targetProps: action.payload } case 'input': return { ...state, inputProps: action.payload } + case 'target': + return { ...state, targetIndex: action.payload } } } diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 0ba57800..7354dc44 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,30 +1,37 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { Dispatch, forwardRef, RefObject, SetStateAction, useRef } from 'react' +import React, { forwardRef, RefObject } from 'react' import { Platform, TextInput, TextInputProps, View } from 'react-native' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { EmojisState } from './Emojis/helpers/EmojisContext' import CustomText from './Text' export type Props = { - value: string - setValue: Dispatch> - selectionRange?: { start: number; end: number } - title?: string multiline?: boolean -} & Omit< - TextInputProps, - | 'style' - | 'onChangeText' - | 'onSelectionChange' - | 'keyboardAppearance' - | 'textAlignVertical' - | 'multiline' -> +} & Pick, 'value' | 'selection' | 'isFocused'> & + Omit< + TextInputProps, + | 'style' + | 'onChangeText' + | 'onSelectionChange' + | 'keyboardAppearance' + | 'textAlignVertical' + | 'multiline' + | 'selection' + | 'value' + > const ComponentInput = forwardRef( ( - { title, multiline = false, value, setValue, selectionRange, ...props }: Props, + { + title, + multiline = false, + value: [value, setValue], + selection: [selection, setSelection], + isFocused, + ...props + }: Props, ref: RefObject ) => { const { colors, mode } = useTheme() @@ -69,9 +76,11 @@ const ComponentInput = forwardRef( minHeight: Platform.OS === 'ios' && multiline ? StyleConstants.Font.LineHeight.M * 5 : undefined }} - onChangeText={setValue} - onSelectionChange={({ nativeEvent: { selection } }) => (selectionRange = selection)} value={value} + onChangeText={setValue} + onFocus={() => (isFocused.current = true)} + onBlur={() => (isFocused.current = false)} + onSelectionChange={({ nativeEvent }) => setSelection(nativeEvent.selection)} {...(multiline && { multiline, numberOfLines: Platform.OS === 'android' ? 5 : undefined diff --git a/src/screens/Tabs/Me/Profile/Fields.tsx b/src/screens/Tabs/Me/Profile/Fields.tsx index 58eea69b..ed115bc5 100644 --- a/src/screens/Tabs/Me/Profile/Fields.tsx +++ b/src/screens/Tabs/Me/Profile/Fields.tsx @@ -7,10 +7,9 @@ import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { useProfileMutation } from '@utils/queryHooks/profile' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import { isEqual } from 'lodash' import React, { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView } from 'react-native' import FlashMessage from 'react-native-flash-message' const Field: React.FC<{ @@ -25,23 +24,21 @@ const Field: React.FC<{ const [name, setName] = useState(field?.name || '') const [value, setValue] = useState(field?.value || '') allProps[index * 2] = { - ref: useRef(null), - value: name, - setValue: setName, - selectionRange: name ? { start: name.length, end: name.length } : { start: 0, end: 0 }, + value: [name, setName], + selection: useState({ start: name.length }), + isFocused: useRef(false), maxLength: 255 } allProps[index * 2 + 1] = { - ref: useRef(null), - value, - setValue, - selectionRange: value ? { start: value.length, end: value.length } : { start: 0, end: 0 }, + value: [value, setValue], + selection: useState({ start: value.length }), + isFocused: useRef(false), maxLength: 255 } useEffect(() => { setDirty(dirty => - dirty ? dirty : !isEqual(field?.name, name) || !isEqual(field?.value, value) + dirty ? dirty : (field?.name || '') !== name || (field?.value || '') !== value ) }, [name, value]) @@ -130,11 +127,11 @@ const TabMeProfileFields: React.FC< data: Array.from(Array(4).keys()) .filter( index => - allProps[index * 2]?.value.length || allProps[index * 2 + 1]?.value.length + allProps[index * 2]?.value[0].length || allProps[index * 2 + 1]?.value[0].length ) .map(index => ({ - name: allProps[index * 2].value, - value: allProps[index * 2 + 1].value + name: allProps[index * 2].value[0], + value: allProps[index * 2 + 1].value[0] })) }).then(() => { navigation.navigate('Tab-Me-Profile-Root') @@ -147,7 +144,7 @@ const TabMeProfileFields: React.FC< return ( - + {Array.from(Array(4).keys()).map(index => ( ))} - + ) } diff --git a/src/screens/Tabs/Me/Profile/Name.tsx b/src/screens/Tabs/Me/Profile/Name.tsx index 736e7b35..4485e93f 100644 --- a/src/screens/Tabs/Me/Profile/Name.tsx +++ b/src/screens/Tabs/Me/Profile/Name.tsx @@ -8,7 +8,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView, TextInput } from 'react-native' import FlashMessage from 'react-native-flash-message' const TabMeProfileName: React.FC< @@ -26,21 +26,20 @@ const TabMeProfileName: React.FC< const { t, i18n } = useTranslation('screenTabs') const { mutateAsync, status } = useProfileMutation() - const [displayName, setDisplayName] = useState(display_name) - const displayNameProps: NonNullable = { + const [value, setValue] = useState(display_name) + const displayNameProps: NonNullable = { + value: [value, setValue], + selection: useState({ start: value.length }), + isFocused: useRef(false), ref: useRef(null), - value: displayName, - setValue: setDisplayName, - selectionRange: displayName - ? { start: displayName.length, end: displayName.length } - : { start: 0, end: 0 }, maxLength: 30 } + console.log('true value', value) const [dirty, setDirty] = useState(false) useEffect(() => { - setDirty(display_name !== displayName) - }, [displayName]) + setDirty(display_name !== value) + }, [value]) useEffect(() => { navigation.setOptions({ @@ -84,7 +83,7 @@ const TabMeProfileName: React.FC< failed: true }, type: 'display_name', - data: displayName + data: value }).then(() => { navigation.navigate('Tab-Me-Profile-Root') }) @@ -92,11 +91,11 @@ const TabMeProfileName: React.FC< /> ) }) - }, [theme, i18n.language, dirty, status, displayName]) + }, [theme, i18n.language, dirty, status, value]) return ( - + - + ) } diff --git a/src/screens/Tabs/Me/Profile/Note.tsx b/src/screens/Tabs/Me/Profile/Note.tsx index 8a1a75bb..56dba987 100644 --- a/src/screens/Tabs/Me/Profile/Note.tsx +++ b/src/screens/Tabs/Me/Profile/Note.tsx @@ -8,7 +8,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView, TextInput } from 'react-native' import FlashMessage from 'react-native-flash-message' const TabMeProfileNote: React.FC< @@ -27,11 +27,11 @@ const TabMeProfileNote: React.FC< const { mutateAsync, status } = useProfileMutation() const [notes, setNotes] = useState(note) - const notesProps: NonNullable = { + const notesProps: NonNullable = { + value: [notes, setNotes], + selection: useState({ start: notes.length }), + isFocused: useRef(false), ref: useRef(null), - value: notes, - setValue: setNotes, - selectionRange: notes ? { start: notes.length, end: notes.length } : { start: 0, end: 0 }, maxLength: 500 } @@ -94,9 +94,9 @@ const TabMeProfileNote: React.FC< return ( - + - + ) } From 2df23a8a2e1319fe38cf70e89c266e0a626348bd Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sun, 18 Sep 2022 23:54:50 +0200 Subject: [PATCH 04/20] POC compose using the new emoji selector --- src/components/Emojis/List.tsx | 3 +- .../Emojis/helpers/EmojisContext.tsx | 1 + src/screens/Compose.tsx | 171 +++++++----------- src/screens/Compose/Root.tsx | 58 +----- src/screens/Compose/Root/Actions.tsx | 105 +++++------ src/screens/Compose/Root/Footer.tsx | 16 +- src/screens/Compose/updateText.ts | 5 +- src/screens/Compose/utils/initialState.ts | 5 +- src/screens/Compose/utils/reducer.ts | 2 - src/screens/Compose/utils/types.d.ts | 17 +- 10 files changed, 138 insertions(+), 245 deletions(-) diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index 9f5aeb14..0360734c 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -44,7 +44,7 @@ const EmojisList = () => { const contentFront = value.slice(0, selection.start) const contentRear = value.slice(selection.end) - const spaceFront = /\s/g.test(contentFront.slice(-1)) ? '' : ' ' + const spaceFront = value.length === 0 || /\s/g.test(contentFront.slice(-1)) ? '' : ' ' const spaceRear = /\s/g.test(contentRear[0]) ? '' : ' ' setValue( @@ -52,7 +52,6 @@ const EmojisList = () => { ) const addedLength = spaceFront.length + shortcode.length + spaceRear.length - setSelection({ start: selection.start + addedLength }) ref?.current?.setNativeProps({ selection: { start: selection.start + addedLength } diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index f821ce03..52935d9a 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -7,6 +7,7 @@ type inputProps = { isFocused: MutableRefObject ref?: RefObject // For controlling focus maxLength?: number + addFunc?: (add: string) => void // For none default state update } export type EmojisState = { diff --git a/src/screens/Compose.tsx b/src/screens/Compose.tsx index 3a7aa3ab..4a130def 100644 --- a/src/screens/Compose.tsx +++ b/src/screens/Compose.tsx @@ -1,4 +1,6 @@ import analytics from '@components/analytics' +import { ComponentEmojis } from '@components/Emojis' +import { EmojisState } from '@components/Emojis/helpers/EmojisContext' import { HeaderLeft, HeaderRight } from '@components/Header' import { createNativeStackNavigator } from '@react-navigation/native-stack' import haptics from '@root/components/haptics' @@ -6,10 +8,7 @@ import { useAppDispatch } from '@root/store' import formatText from '@screens/Compose/formatText' import ComposeRoot from '@screens/Compose/Root' import { RootStackScreenProps } from '@utils/navigation/navigators' -import { - QueryKeyTimeline, - useTimelineMutation -} from '@utils/queryHooks/timeline' +import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline' import { updateStoreReview } from '@utils/slices/contextsSlice' import { getInstanceAccount, @@ -20,22 +19,9 @@ import { import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { filter } from 'lodash' -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - useState -} from 'react' +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - Alert, - Keyboard, - KeyboardAvoidingView, - Platform, - StyleSheet -} from 'react-native' -import { SafeAreaView } from 'react-native-safe-area-context' +import { Alert, Keyboard, Platform } from 'react-native' import { useQueryClient } from 'react-query' import { useSelector } from 'react-redux' import * as Sentry from 'sentry-expo' @@ -60,12 +46,8 @@ const ScreenCompose: React.FC> = ({ const [hasKeyboard, setHasKeyboard] = useState(false) useEffect(() => { - const keyboardShown = Keyboard.addListener('keyboardWillShow', () => - setHasKeyboard(true) - ) - const keyboardHidden = Keyboard.addListener('keyboardWillHide', () => - setHasKeyboard(false) - ) + const keyboardShown = Keyboard.addListener('keyboardWillShow', () => setHasKeyboard(true)) + const keyboardHidden = Keyboard.addListener('keyboardWillHide', () => setHasKeyboard(false)) return () => { keyboardShown.remove() @@ -89,32 +71,23 @@ const ScreenCompose: React.FC> = ({ attachments: { ...composeInitialState.attachments, sensitive: - localAccount?.preferences && - localAccount?.preferences['posting:default:sensitive'] + localAccount?.preferences && localAccount?.preferences['posting:default:sensitive'] ? localAccount?.preferences['posting:default:sensitive'] : false }, visibility: - localAccount?.preferences && - localAccount.preferences['posting:default:visibility'] + localAccount?.preferences && localAccount.preferences['posting:default:visibility'] ? localAccount.preferences['posting:default:visibility'] : 'public' } } }, []) - const [composeState, composeDispatch] = useReducer( - composeReducer, - initialReducerState - ) + const [composeState, composeDispatch] = useReducer(composeReducer, initialReducerState) - const maxTootChars = useSelector( - getInstanceConfigurationStatusMaxChars, - () => true - ) + const maxTootChars = useSelector(getInstanceConfigurationStatusMaxChars, () => true) const totalTextCount = - (composeState.spoiler.active ? composeState.spoiler.count : 0) + - composeState.text.count + (composeState.spoiler.active ? composeState.spoiler.count : 0) + composeState.text.count // If compose state is dirty, then disallow add back drafts useEffect(() => { @@ -173,8 +146,7 @@ const ScreenCompose: React.FC> = ({ }) break case 'reply': - const actualStatus = - params.incomingStatus.reblog || params.incomingStatus + const actualStatus = params.incomingStatus.reblog || params.incomingStatus if (actualStatus.spoiler_text) { formatText({ textInput: 'spoiler', @@ -278,16 +250,10 @@ const ScreenCompose: React.FC> = ({ if (totalTextCount > maxTootChars) { return true } - if ( - composeState.attachments.uploads.filter(upload => upload.uploading) - .length > 0 - ) { + if (composeState.attachments.uploads.filter(upload => upload.uploading).length > 0) { return true } - if ( - composeState.attachments.uploads.length === 0 && - composeState.text.raw.length === 0 - ) { + if (composeState.attachments.uploads.length === 0 && composeState.text.raw.length === 0) { return true } return false @@ -309,18 +275,12 @@ const ScreenCompose: React.FC> = ({ composePost(params, composeState) .then(res => { haptics('Success') - if ( - Platform.OS === 'ios' && - Platform.constants.osVersion === '13.3' - ) { + if (Platform.OS === 'ios' && Platform.constants.osVersion === '13.3') { // https://github.com/tooot-app/app/issues/59 } else { dispatch(updateStoreReview(1)) } - const queryKey: QueryKeyTimeline = [ - 'Timeline', - { page: 'Following' } - ] + const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }] queryClient.invalidateQueries(queryKey) switch (params?.type) { @@ -392,54 +352,61 @@ const ScreenCompose: React.FC> = ({ }` }, [totalTextCount, maxTootChars, composeState.dirty]) + const inputProps: EmojisState['inputProps'] = [ + { + value: [ + composeState.text.raw, + content => formatText({ textInput: 'text', composeDispatch, content }) + ], + selection: [ + composeState.text.selection, + selection => composeDispatch({ type: 'text', payload: { selection } }) + ], + isFocused: useRef(composeState.textInputFocus.current === 'text'), + maxLength: maxTootChars + } + ] + return ( - - - - - maxTootChars - ? StyleConstants.Font.Weight.Bold - : StyleConstants.Font.Weight.Normal, - fontSize: StyleConstants.Font.Size.M - }, - headerTintColor: - totalTextCount > maxTootChars ? colors.red : colors.secondary, - headerLeft, - headerRight - }} - /> - - - - - - + + + maxTootChars + ? StyleConstants.Font.Weight.Bold + : StyleConstants.Font.Weight.Normal, + fontSize: StyleConstants.Font.Size.M + }, + headerTintColor: totalTextCount > maxTootChars ? colors.red : colors.secondary, + headerLeft, + headerRight + }} + /> + + + + + ) } -const styles = StyleSheet.create({ - base: { flex: 1 } -}) - export default ScreenCompose diff --git a/src/screens/Compose/Root.tsx b/src/screens/Compose/Root.tsx index 9aeb08f9..22e21bc4 100644 --- a/src/screens/Compose/Root.tsx +++ b/src/screens/Compose/Root.tsx @@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { useContext, useEffect, useMemo, useRef } from 'react' -import { AccessibilityInfo, findNodeHandle, FlatList, StyleSheet, View } from 'react-native' +import { AccessibilityInfo, findNodeHandle, FlatList, View } from 'react-native' import { Circle } from 'react-native-animated-spinkit' import ComposeActions from './Root/Actions' import ComposePosting from './Posting' @@ -14,9 +14,7 @@ import ComposeRootHeader from './Root/Header' import ComposeRootSuggestion from './Root/Suggestion' import ComposeContext from './utils/createContext' import ComposeDrafts from './Root/Drafts' -import FastImage from 'react-native-fast-image' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' -import { ComposeState } from './utils/types' import { useSelector } from 'react-redux' import { getInstanceConfigurationStatusCharsURL, @@ -24,30 +22,6 @@ import { } from '@utils/slices/instancesSlice' import { useTranslation } from 'react-i18next' -const prefetchEmojis = ( - sortedEmojis: NonNullable, - 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 let instanceConfigurationStatusCharsURL = 23 const ComposeRoot = React.memo( @@ -62,7 +36,6 @@ const ComposeRoot = React.memo( const accessibleRefDrafts = useRef(null) const accessibleRefAttachments = useRef(null) - const accessibleRefEmojis = useRef(null) useEffect(() => { const tagDrafts = findNodeHandle(accessibleRefDrafts.current) @@ -110,18 +83,13 @@ const ComposeRoot = React.memo( ) }) } - composeDispatch({ - type: 'emoji', - payload: { ...composeState.emoji, emojis: sortedEmojis } - }) - prefetchEmojis(sortedEmojis, reduceMotionEnabled) } }, [emojisData, reduceMotionEnabled]) const listEmpty = useMemo(() => { if (isFetching) { return ( - + ) @@ -129,17 +97,12 @@ const ComposeRoot = React.memo( }, [isFetching]) const Footer = useMemo( - () => ( - - ), - [accessibleRefAttachments.current, accessibleRefEmojis.current] + () => , + [accessibleRefAttachments.current] ) return ( - + ( true ) -const styles = StyleSheet.create({ - base: { - flex: 1 - }, - contentView: { flex: 1 }, - loading: { - flex: 1, - alignItems: 'center' - } -}) - export default ComposeRoot diff --git a/src/screens/Compose/Root/Actions.tsx b/src/screens/Compose/Root/Actions.tsx index 347afbac..8ac5b960 100644 --- a/src/screens/Compose/Root/Actions.tsx +++ b/src/screens/Compose/Root/Actions.tsx @@ -1,12 +1,13 @@ import analytics from '@components/analytics' +import EmojisContext from '@components/Emojis/helpers/EmojisContext' import Icon from '@components/Icon' import { useActionSheet } from '@expo/react-native-action-sheet' import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice' import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useCallback, useContext, useMemo } from 'react' +import React, { useContext, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Pressable, StyleSheet, View } from 'react-native' +import { Keyboard, Pressable, StyleSheet, View } from 'react-native' import { useSelector } from 'react-redux' import ComposeContext from '../utils/createContext' import chooseAndUploadAttachment from './Footer/addAttachment' @@ -30,22 +31,19 @@ const ComposeActions: React.FC = () => { return colors.secondary } }, [composeState.poll.active, composeState.attachments.uploads]) - const attachmentOnPress = useCallback(async () => { + const attachmentOnPress = () => { if (composeState.poll.active) return - if ( - composeState.attachments.uploads.length < - instanceConfigurationStatusMaxAttachments - ) { + if (composeState.attachments.uploads.length < instanceConfigurationStatusMaxAttachments) { analytics('compose_actions_attachment_press', { count: composeState.attachments.uploads.length }) - return await chooseAndUploadAttachment({ + return chooseAndUploadAttachment({ composeDispatch, showActionSheetWithOptions }) } - }, [composeState.poll.active, composeState.attachments.uploads]) + } const pollColor = useMemo(() => { if (composeState.attachments.uploads.length) return colors.disabled @@ -56,7 +54,7 @@ const ComposeActions: React.FC = () => { return colors.secondary } }, [composeState.poll.active, composeState.attachments.uploads]) - const pollOnPress = useCallback(() => { + const pollOnPress = () => { if (!composeState.attachments.uploads.length) { analytics('compose_actions_poll_press', { current: composeState.poll.active @@ -70,7 +68,7 @@ const ComposeActions: React.FC = () => { if (composeState.poll.active) { composeState.textInputFocus.refs.text.current?.focus() } - }, [composeState.poll.active, composeState.attachments.uploads]) + } const visibilityIcon = useMemo(() => { switch (composeState.visibility) { @@ -84,7 +82,7 @@ const ComposeActions: React.FC = () => { return 'Mail' } }, [composeState.visibility]) - const visibilityOnPress = useCallback(() => { + const visibilityOnPress = () => { if (!composeState.visibilityLock) { showActionSheetWithOptions( { @@ -133,9 +131,9 @@ const ComposeActions: React.FC = () => { } ) } - }, [composeState.visibility]) + } - const spoilerOnPress = useCallback(() => { + const spoilerOnPress = () => { analytics('compose_actions_spoiler_press', { current: composeState.spoiler.active }) @@ -147,29 +145,45 @@ const ComposeActions: React.FC = () => { type: 'spoiler', payload: { active: !composeState.spoiler.active } }) - }, [composeState.spoiler.active, composeState.textInputFocus]) + } + const { emojisState, emojisDispatch } = useContext(EmojisContext) const emojiColor = useMemo(() => { - if (!composeState.emoji.emojis) return colors.disabled + if (!emojisState.emojis.length) return colors.disabled - if (composeState.emoji.active) { + if (emojisState.targetIndex !== -1) { return colors.primaryDefault } else { return colors.secondary } - }, [composeState.emoji.active, composeState.emoji.emojis]) - const emojiOnPress = useCallback(() => { - analytics('compose_actions_emojis_press', { - current: composeState.emoji.active - }) - if (composeState.emoji.emojis) { - layoutAnimation() - composeDispatch({ - type: 'emoji', - payload: { ...composeState.emoji, active: !composeState.emoji.active } - }) + }, [emojisState.emojis.length, emojisState.targetIndex]) + // useEffect(() => { + // const showSubscription = Keyboard.addListener('keyboardWillShow', () => { + // composeDispatch({ type: 'emoji/shown', payload: false }) + // }) + + // return () => { + // showSubscription.remove() + // } + // }, []) + const emojiOnPress = () => { + if (emojisState.targetIndex === -1) { + Keyboard.dismiss() } - }, [composeState.emoji.active, composeState.emoji.emojis]) + const focusedPropsIndex = emojisState.inputProps?.findIndex(props => props.isFocused.current) + if (focusedPropsIndex === -1) return + emojisDispatch({ type: 'target', payload: focusedPropsIndex }) + // Keyboard.dismiss() + // analytics('compose_actions_emojis_press', { + // current: composeState.emoji.active + // }) + // if (composeState.emoji.emojis) { + // composeDispatch({ + // type: 'emoji', + // payload: { ...composeState.emoji, active: !composeState.emoji.active } + // }) + // } + } return ( { > { /> { } /> { } /> @@ -255,8 +256,8 @@ const ComposeActions: React.FC = () => { accessibilityLabel={t('content.root.actions.emoji.accessibilityLabel')} accessibilityHint={t('content.root.actions.emoji.accessibilityHint')} accessibilityState={{ - disabled: composeState.emoji.emojis ? false : true, - expanded: composeState.emoji.active + disabled: emojisState.emojis.length ? false : true, + expanded: emojisState.targetIndex !== -1 }} style={styles.button} onPress={emojiOnPress} diff --git a/src/screens/Compose/Root/Footer.tsx b/src/screens/Compose/Root/Footer.tsx index 55ccbb6b..37871324 100644 --- a/src/screens/Compose/Root/Footer.tsx +++ b/src/screens/Compose/Root/Footer.tsx @@ -1,31 +1,21 @@ import ComposeAttachments from '@screens/Compose/Root/Footer/Attachments' -import ComposeEmojis from '@screens/Compose/Root/Footer/Emojis' import ComposePoll from '@screens/Compose/Root/Footer/Poll' import ComposeReply from '@screens/Compose/Root/Footer/Reply' import ComposeContext from '@screens/Compose/utils/createContext' import React, { RefObject, useContext } from 'react' -import { SectionList, View } from 'react-native' +import { View } from 'react-native' export interface Props { accessibleRefAttachments: RefObject - accessibleRefEmojis: RefObject } -const ComposeRootFooter: React.FC = ({ - accessibleRefAttachments, - accessibleRefEmojis -}) => { +const ComposeRootFooter: React.FC = ({ accessibleRefAttachments }) => { const { composeState } = useContext(ComposeContext) return ( <> - {composeState.emoji.active ? ( - - ) : null} {composeState.attachments.uploads.length ? ( - + ) : null} {composeState.poll.active ? : null} {composeState.replyToStatus ? : null} diff --git a/src/screens/Compose/updateText.ts b/src/screens/Compose/updateText.ts index 4d09ad4b..ec6af88c 100644 --- a/src/screens/Compose/updateText.ts +++ b/src/screens/Compose/updateText.ts @@ -26,9 +26,8 @@ const updateText = ({ const whiteSpaceFront = /\s/g.test(contentFront.slice(-1)) const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) - const newTextWithSpace = `${ - whiteSpaceFront || type === 'suggestion' ? '' : ' ' - }${newText}${whiteSpaceRear ? '' : ' '}` + const newTextWithSpace = `${whiteSpaceFront || type === 'suggestion' ? '' : ' ' + }${newText}${whiteSpaceRear ? '' : ' '}` formatText({ textInput, diff --git a/src/screens/Compose/utils/initialState.ts b/src/screens/Compose/utils/initialState.ts index 85306b3b..28fbd460 100644 --- a/src/screens/Compose/utils/initialState.ts +++ b/src/screens/Compose/utils/initialState.ts @@ -9,16 +9,15 @@ const composeInitialState: Omit = { count: 0, raw: '', formatted: undefined, - selection: { start: 0, end: 0 } + selection: { start: 0 } }, text: { count: 0, raw: '', formatted: undefined, - selection: { start: 0, end: 0 } + selection: { start: 0 } }, tag: undefined, - emoji: { active: false, emojis: undefined }, poll: { active: false, total: 2, diff --git a/src/screens/Compose/utils/reducer.ts b/src/screens/Compose/utils/reducer.ts index 0480393b..921a9f0d 100644 --- a/src/screens/Compose/utils/reducer.ts +++ b/src/screens/Compose/utils/reducer.ts @@ -35,8 +35,6 @@ const composeReducer = ( return { ...state, text: { ...state.text, ...action.payload } } case 'tag': return { ...state, tag: action.payload } - case 'emoji': - return { ...state, emoji: action.payload } case 'poll': return { ...state, poll: { ...state.poll, ...action.payload } } case 'attachments/sensitive': diff --git a/src/screens/Compose/utils/types.d.ts b/src/screens/Compose/utils/types.d.ts index 7f4102d8..40c31c3a 100644 --- a/src/screens/Compose/utils/types.d.ts +++ b/src/screens/Compose/utils/types.d.ts @@ -26,13 +26,13 @@ export type ComposeState = { count: number raw: string formatted: ReactNode - selection: { start: number; end: number } + selection: { start: number; end?: number } } text: { count: number raw: string formatted: ReactNode - selection: { start: number; end: number } + selection: { start: number; end?: number } } tag?: { type: 'url' | 'accounts' | 'hashtags' @@ -40,15 +40,6 @@ export type ComposeState = { offset: number length: number } - emoji: { - active: boolean - emojis: - | { - title: string - data: Pick[][] - }[] - | undefined - } poll: { active: boolean total: number @@ -96,10 +87,6 @@ export type ComposeAction = type: 'tag' payload: ComposeState['tag'] } - | { - type: 'emoji' - payload: ComposeState['emoji'] - } | { type: 'poll' payload: Partial From b44370d3ec86e7f295d1c0e47cc8d61ff2c17c8c Mon Sep 17 00:00:00 2001 From: xmflsct Date: Mon, 19 Sep 2022 22:01:13 +0200 Subject: [PATCH 05/20] Spoiler now supports emoji as well --- src/screens/Compose.tsx | 16 +++++++-- .../Compose/Root/Header/SpoilerInput.tsx | 18 +++++----- src/screens/Compose/Root/Header/TextInput.tsx | 36 +++++++------------ src/screens/Compose/utils/initialState.ts | 3 +- src/screens/Compose/utils/types.d.ts | 1 + 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/screens/Compose.tsx b/src/screens/Compose.tsx index 4a130def..3a3e8901 100644 --- a/src/screens/Compose.tsx +++ b/src/screens/Compose.tsx @@ -362,8 +362,20 @@ const ScreenCompose: React.FC> = ({ composeState.text.selection, selection => composeDispatch({ type: 'text', payload: { selection } }) ], - isFocused: useRef(composeState.textInputFocus.current === 'text'), - maxLength: maxTootChars + isFocused: composeState.textInputFocus.isFocused.text, + maxLength: maxTootChars - (composeState.spoiler.active ? composeState.spoiler.count : 0) + }, + { + value: [ + composeState.spoiler.raw, + content => formatText({ textInput: 'spoiler', composeDispatch, content }) + ], + selection: [ + composeState.spoiler.selection, + selection => composeDispatch({ type: 'spoiler', payload: { selection } }) + ], + isFocused: composeState.textInputFocus.isFocused.spoiler, + maxLength: maxTootChars - composeState.text.count } ] diff --git a/src/screens/Compose/Root/Header/SpoilerInput.tsx b/src/screens/Compose/Root/Header/SpoilerInput.tsx index e5794fda..b1483bbf 100644 --- a/src/screens/Compose/Root/Header/SpoilerInput.tsx +++ b/src/screens/Compose/Root/Header/SpoilerInput.tsx @@ -16,14 +16,8 @@ const ComposeSpoilerInput: React.FC = () => { const { colors, mode } = useTheme() const adaptiveFontsize = useSelector(getSettingsFontsize) - const adaptedFontsize = adaptiveScale( - StyleConstants.Font.Size.M, - adaptiveFontsize - ) - const adaptedLineheight = adaptiveScale( - StyleConstants.Font.LineHeight.M, - adaptiveFontsize - ) + const adaptedFontsize = adaptiveScale(StyleConstants.Font.Size.M, adaptiveFontsize) + const adaptedLineheight = adaptiveScale(StyleConstants.Font.LineHeight.M, adaptiveFontsize) return ( { }) }} scrollEnabled={false} - onFocus={() => + onFocus={() => { composeDispatch({ type: 'textInputFocus', payload: { current: 'spoiler' } }) - } + composeState.textInputFocus.isFocused.spoiler.current = true + }} + onBlur={() => { + composeState.textInputFocus.isFocused.spoiler.current = false + }} > {composeState.spoiler.formatted} diff --git a/src/screens/Compose/Root/Header/TextInput.tsx b/src/screens/Compose/Root/Header/TextInput.tsx index f107cebf..ab1f9f8a 100644 --- a/src/screens/Compose/Root/Header/TextInput.tsx +++ b/src/screens/Compose/Root/Header/TextInput.tsx @@ -18,20 +18,11 @@ const ComposeTextInput: React.FC = () => { const { t } = useTranslation('screenCompose') const { colors, mode } = useTheme() - const maxAttachments = useSelector( - getInstanceConfigurationStatusMaxAttachments, - () => true - ) + const maxAttachments = useSelector(getInstanceConfigurationStatusMaxAttachments, () => true) const adaptiveFontsize = useSelector(getSettingsFontsize) - const adaptedFontsize = adaptiveScale( - StyleConstants.Font.Size.M, - adaptiveFontsize - ) - const adaptedLineheight = adaptiveScale( - StyleConstants.Font.LineHeight.M, - adaptiveFontsize - ) + const adaptedFontsize = adaptiveScale(StyleConstants.Font.Size.M, adaptiveFontsize) + const adaptedLineheight = adaptiveScale(StyleConstants.Font.LineHeight.M, adaptiveFontsize) return ( { content }) } - onFocus={() => + onFocus={() => { composeDispatch({ type: 'textInputFocus', payload: { current: 'text' } }) - } + composeState.textInputFocus.isFocused.text.current = true + }} + onBlur={() => { + composeState.textInputFocus.isFocused.text.current = false + }} onSelectionChange={({ nativeEvent: { selection: { start, end } @@ -79,20 +74,13 @@ const ComposeTextInput: React.FC = () => { scrollEnabled={false} disableCopyPaste={false} onPaste={(error: string | null | undefined, files: PastedFile[]) => { - if ( - composeState.attachments.uploads.length + files.length > - maxAttachments - ) { + if (composeState.attachments.uploads.length + files.length > maxAttachments) { Alert.alert( - t( - 'content.root.header.textInput.keyboardImage.exceedMaximum.title' - ), + t('content.root.header.textInput.keyboardImage.exceedMaximum.title'), undefined, [ { - text: t( - 'content.root.header.textInput.keyboardImage.exceedMaximum.OK' - ), + text: t('content.root.header.textInput.keyboardImage.exceedMaximum.OK'), style: 'default' } ] diff --git a/src/screens/Compose/utils/initialState.ts b/src/screens/Compose/utils/initialState.ts index 28fbd460..532a0189 100644 --- a/src/screens/Compose/utils/initialState.ts +++ b/src/screens/Compose/utils/initialState.ts @@ -39,7 +39,8 @@ const composeInitialState: Omit = { replyToStatus: undefined, textInputFocus: { current: 'text', - refs: { text: createRef() } + refs: { text: createRef() }, + isFocused: { text: createRef(), spoiler: createRef() } } } diff --git a/src/screens/Compose/utils/types.d.ts b/src/screens/Compose/utils/types.d.ts index 40c31c3a..e910b70c 100644 --- a/src/screens/Compose/utils/types.d.ts +++ b/src/screens/Compose/utils/types.d.ts @@ -59,6 +59,7 @@ export type ComposeState = { textInputFocus: { current: 'text' | 'spoiler' refs: { text: RefObject } + isFocused: { text: MutableRefObject, spoiler: MutableRefObject } } } From fb3cfa0db1aa7f04ba175af6d78f12f231bc2891 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Mon, 19 Sep 2022 22:22:52 +0200 Subject: [PATCH 06/20] Emoji working for compose --- src/components/Emojis.tsx | 10 +- src/components/Emojis/List.tsx | 2 +- src/screens/Compose/Root/Actions.tsx | 31 ++-- src/screens/Compose/Root/Footer/Emojis.tsx | 161 --------------------- 4 files changed, 16 insertions(+), 188 deletions(-) delete mode 100644 src/screens/Compose/Root/Footer/Emojis.tsx diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index a6393082..d6e7c3ba 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -7,7 +7,7 @@ import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native' +import { Keyboard, KeyboardAvoidingView, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' @@ -74,14 +74,14 @@ const ComponentEmojis: React.FC = ({ if (data && data.length) { let sortedEmojis: EmojisState['emojis'] = [] forEach(groupBy(sortBy(data, ['category', 'shortcode']), 'category'), (value, key) => - sortedEmojis.push({ title: key, data: chunk(value, 5) }) + sortedEmojis.push({ title: key, data: chunk(value, 4) }) ) if (frequentEmojis.length) { sortedEmojis.unshift({ title: t('componentEmojis:frequentUsed'), data: chunk( frequentEmojis.map(e => e.emoji), - 5 + 4 ), type: 'frequent' }) @@ -125,7 +125,9 @@ const ComponentEmojis: React.FC = ({ { } = emojisState.inputProps[emojisState.targetIndex] const contentFront = value.slice(0, selection.start) - const contentRear = value.slice(selection.end) + const contentRear = value.slice(selection.end || selection.start) const spaceFront = value.length === 0 || /\s/g.test(contentFront.slice(-1)) ? '' : ' ' const spaceRear = /\s/g.test(contentRear[0]) ? '' : ' ' diff --git a/src/screens/Compose/Root/Actions.tsx b/src/screens/Compose/Root/Actions.tsx index 8ac5b960..5d3af943 100644 --- a/src/screens/Compose/Root/Actions.tsx +++ b/src/screens/Compose/Root/Actions.tsx @@ -157,32 +157,19 @@ const ComposeActions: React.FC = () => { return colors.secondary } }, [emojisState.emojis.length, emojisState.targetIndex]) - // useEffect(() => { - // const showSubscription = Keyboard.addListener('keyboardWillShow', () => { - // composeDispatch({ type: 'emoji/shown', payload: false }) - // }) - - // return () => { - // showSubscription.remove() - // } - // }, []) const emojiOnPress = () => { + analytics('compose_actions_emojis_press', { + current: emojisState.targetIndex !== -1 + }) if (emojisState.targetIndex === -1) { Keyboard.dismiss() + const focusedPropsIndex = emojisState.inputProps?.findIndex(props => props.isFocused.current) + if (focusedPropsIndex === -1) return + emojisDispatch({ type: 'target', payload: focusedPropsIndex }) + } else { + emojisDispatch({ type: 'target', payload: -1 }) + return } - const focusedPropsIndex = emojisState.inputProps?.findIndex(props => props.isFocused.current) - if (focusedPropsIndex === -1) return - emojisDispatch({ type: 'target', payload: focusedPropsIndex }) - // Keyboard.dismiss() - // analytics('compose_actions_emojis_press', { - // current: composeState.emoji.active - // }) - // if (composeState.emoji.emojis) { - // composeDispatch({ - // type: 'emoji', - // payload: { ...composeState.emoji, active: !composeState.emoji.active } - // }) - // } } return ( diff --git a/src/screens/Compose/Root/Footer/Emojis.tsx b/src/screens/Compose/Root/Footer/Emojis.tsx deleted file mode 100644 index eb6ee6b2..00000000 --- a/src/screens/Compose/Root/Footer/Emojis.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import haptics from '@components/haptics' -import CustomText from '@components/Text' -import { useAppDispatch } from '@root/store' -import { useAccessibility } from '@utils/accessibility/AccessibilityManager' -import { countInstanceEmoji } from '@utils/slices/instancesSlice' -import { getSettingsStaticEmoji } from '@utils/slices/settingsSlice' -import { StyleConstants } from '@utils/styles/constants' -import { useTheme } from '@utils/styles/ThemeManager' -import React, { RefObject, useCallback, useContext, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { - AccessibilityInfo, - findNodeHandle, - Image, - Pressable, - SectionList, - View -} from 'react-native' -import FastImage from 'react-native-fast-image' -import { useSelector } from 'react-redux' -import validUrl from 'valid-url' -import updateText from '../../updateText' -import ComposeContext from '../../utils/createContext' - -export interface Props { - accessibleRefEmojis: RefObject -} - -const ComposeEmojis: React.FC = ({ accessibleRefEmojis }) => { - const { composeState, composeDispatch } = useContext(ComposeContext) - const { reduceMotionEnabled } = useAccessibility() - const { colors } = useTheme() - const { t } = useTranslation() - const dispatch = useAppDispatch() - - const staticEmoji = useSelector(getSettingsStaticEmoji) - - useEffect(() => { - const tagEmojis = findNodeHandle(accessibleRefEmojis.current) - if (composeState.emoji.active) { - tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) - } - }, [composeState.emoji.active]) - - const listItem = useCallback( - ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { - return ( - - {item.map(emoji => { - const uri = reduceMotionEnabled ? emoji.static_url : emoji.url - if (validUrl.isHttpsUri(uri)) { - return ( - { - haptics('Light') - updateText({ - composeState, - composeDispatch, - newText: `:${emoji.shortcode}:`, - type: 'emoji' - }) - dispatch(countInstanceEmoji(emoji)) - }} - > - {staticEmoji ? ( - - ) : ( - - )} - - ) - } else { - return null - } - })} - - ) - }, - [composeState] - ) - - return ( - - item[0].shortcode} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - renderItem={listItem} - windowSize={2} - /> - - ) -} - -export default React.memo(ComposeEmojis, () => true) From 7ec7f85893933bb8b824cc08f210eb3c75b51c88 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Tue, 20 Sep 2022 22:23:01 +0200 Subject: [PATCH 07/20] Removed autolinker --- package.json | 2 + src/Screens.tsx | 1 - src/components/Emojis.tsx | 23 +- src/components/Emojis/Button.tsx | 9 +- src/components/Emojis/List.tsx | 21 +- .../Emojis/helpers/EmojisContext.tsx | 17 +- .../autolinker/anchor-tag-builder.d.ts | 120 --- src/modules/autolinker/anchor-tag-builder.js | 176 ---- src/modules/autolinker/autolinker.d.ts | 699 -------------- src/modules/autolinker/autolinker.js | 907 ------------------ src/modules/autolinker/html-tag.d.ts | 238 ----- src/modules/autolinker/html-tag.js | 300 ------ .../autolinker/htmlParser/parse-html.d.ts | 58 -- .../autolinker/htmlParser/parse-html.js | 629 ------------ src/modules/autolinker/htmlParser/state.d.ts | 27 - src/modules/autolinker/htmlParser/state.js | 3 - src/modules/autolinker/index.d.ts | 7 - src/modules/autolinker/index.js | 13 - src/modules/autolinker/match/email-match.d.ts | 51 - src/modules/autolinker/match/email-match.js | 66 -- .../autolinker/match/hashtag-match.d.ts | 68 -- src/modules/autolinker/match/hashtag-match.js | 95 -- src/modules/autolinker/match/index.d.ts | 6 - src/modules/autolinker/match/index.js | 8 - src/modules/autolinker/match/match.d.ts | 165 ---- src/modules/autolinker/match/match.js | 155 --- .../autolinker/match/mention-match.d.ts | 75 -- src/modules/autolinker/match/mention-match.js | 109 --- src/modules/autolinker/match/phone-match.d.ts | 79 -- src/modules/autolinker/match/phone-match.js | 96 -- src/modules/autolinker/match/url-match.d.ts | 190 ---- src/modules/autolinker/match/url-match.js | 260 ----- .../autolinker/matcher/email-matcher.d.ts | 26 - .../autolinker/matcher/email-matcher.js | 311 ------ .../autolinker/matcher/hashtag-matcher.d.ts | 50 - .../autolinker/matcher/hashtag-matcher.js | 83 -- src/modules/autolinker/matcher/index.d.ts | 6 - src/modules/autolinker/matcher/index.js | 8 - src/modules/autolinker/matcher/matcher.d.ts | 40 - src/modules/autolinker/matcher/matcher.js | 31 - .../autolinker/matcher/mention-matcher.d.ts | 53 - .../autolinker/matcher/mention-matcher.js | 96 -- .../autolinker/matcher/phone-matcher.d.ts | 37 - .../autolinker/matcher/phone-matcher.js | 74 -- src/modules/autolinker/matcher/tld-regex.d.ts | 1 - src/modules/autolinker/matcher/tld-regex.js | 5 - .../matcher/url-match-validator.d.ts | 127 --- .../autolinker/matcher/url-match-validator.js | 165 ---- .../autolinker/matcher/url-matcher.d.ts | 136 --- src/modules/autolinker/matcher/url-matcher.js | 312 ------ src/modules/autolinker/regex-lib.d.ts | 147 --- src/modules/autolinker/regex-lib.js | 169 ---- .../autolinker/truncate/truncate-end.d.ts | 9 - .../autolinker/truncate/truncate-end.js | 14 - .../autolinker/truncate/truncate-middle.d.ts | 12 - .../autolinker/truncate/truncate-middle.js | 35 - .../autolinker/truncate/truncate-smart.d.ts | 13 - .../autolinker/truncate/truncate-smart.js | 164 ---- src/modules/autolinker/utils.d.ts | 72 -- src/modules/autolinker/utils.js | 124 --- src/screens/Compose.tsx | 6 +- src/screens/Compose/DraftsList/Root.tsx | 45 +- src/screens/Compose/Root.tsx | 33 +- src/screens/Compose/Root/Actions.tsx | 7 +- src/screens/Compose/Root/Suggestion.tsx | 92 +- src/screens/Compose/formatText.tsx | 170 ++-- src/screens/Compose/updateText.ts | 48 - src/screens/Compose/utils/types.d.ts | 12 +- src/screens/Tabs/Me/Profile/Name.tsx | 1 - yarn.lock | 17 + 70 files changed, 253 insertions(+), 7171 deletions(-) delete mode 100644 src/modules/autolinker/anchor-tag-builder.d.ts delete mode 100644 src/modules/autolinker/anchor-tag-builder.js delete mode 100644 src/modules/autolinker/autolinker.d.ts delete mode 100644 src/modules/autolinker/autolinker.js delete mode 100644 src/modules/autolinker/html-tag.d.ts delete mode 100644 src/modules/autolinker/html-tag.js delete mode 100644 src/modules/autolinker/htmlParser/parse-html.d.ts delete mode 100644 src/modules/autolinker/htmlParser/parse-html.js delete mode 100644 src/modules/autolinker/htmlParser/state.d.ts delete mode 100644 src/modules/autolinker/htmlParser/state.js delete mode 100644 src/modules/autolinker/index.d.ts delete mode 100644 src/modules/autolinker/index.js delete mode 100644 src/modules/autolinker/match/email-match.d.ts delete mode 100644 src/modules/autolinker/match/email-match.js delete mode 100644 src/modules/autolinker/match/hashtag-match.d.ts delete mode 100644 src/modules/autolinker/match/hashtag-match.js delete mode 100644 src/modules/autolinker/match/index.d.ts delete mode 100644 src/modules/autolinker/match/index.js delete mode 100644 src/modules/autolinker/match/match.d.ts delete mode 100644 src/modules/autolinker/match/match.js delete mode 100644 src/modules/autolinker/match/mention-match.d.ts delete mode 100644 src/modules/autolinker/match/mention-match.js delete mode 100644 src/modules/autolinker/match/phone-match.d.ts delete mode 100644 src/modules/autolinker/match/phone-match.js delete mode 100644 src/modules/autolinker/match/url-match.d.ts delete mode 100644 src/modules/autolinker/match/url-match.js delete mode 100644 src/modules/autolinker/matcher/email-matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/email-matcher.js delete mode 100644 src/modules/autolinker/matcher/hashtag-matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/hashtag-matcher.js delete mode 100644 src/modules/autolinker/matcher/index.d.ts delete mode 100644 src/modules/autolinker/matcher/index.js delete mode 100644 src/modules/autolinker/matcher/matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/matcher.js delete mode 100644 src/modules/autolinker/matcher/mention-matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/mention-matcher.js delete mode 100644 src/modules/autolinker/matcher/phone-matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/phone-matcher.js delete mode 100644 src/modules/autolinker/matcher/tld-regex.d.ts delete mode 100644 src/modules/autolinker/matcher/tld-regex.js delete mode 100644 src/modules/autolinker/matcher/url-match-validator.d.ts delete mode 100644 src/modules/autolinker/matcher/url-match-validator.js delete mode 100644 src/modules/autolinker/matcher/url-matcher.d.ts delete mode 100644 src/modules/autolinker/matcher/url-matcher.js delete mode 100644 src/modules/autolinker/regex-lib.d.ts delete mode 100644 src/modules/autolinker/regex-lib.js delete mode 100644 src/modules/autolinker/truncate/truncate-end.d.ts delete mode 100644 src/modules/autolinker/truncate/truncate-end.js delete mode 100644 src/modules/autolinker/truncate/truncate-middle.d.ts delete mode 100644 src/modules/autolinker/truncate/truncate-middle.js delete mode 100644 src/modules/autolinker/truncate/truncate-smart.d.ts delete mode 100644 src/modules/autolinker/truncate/truncate-smart.js delete mode 100644 src/modules/autolinker/utils.d.ts delete mode 100644 src/modules/autolinker/utils.js delete mode 100644 src/screens/Compose/updateText.ts diff --git a/package.json b/package.json index 4281b154..9c1c5b9e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "expo-web-browser": "^11.0.0", "i18next": "^21.9.1", "li": "^1.3.0", + "linkify-it": "^4.0.1", "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -107,6 +108,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@expo/config": "^7.0.1", + "@types/linkify-it": "^3.0.2", "@types/lodash": "^4.14.184", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", diff --git a/src/Screens.tsx b/src/Screens.tsx index 07a072be..4bd00d8c 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -251,7 +251,6 @@ const Screens: React.FC = ({ localCorrupt }) => { if (!text && !media.length) { return } else { - console.log('share', text, media) if (instances.length > 1) { navigationRef.navigate('Screen-AccountSelection', { share: { text, media } diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index d6e7c3ba..8db4570d 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -5,13 +5,20 @@ import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useEmojisQuery } from '@utils/queryHooks/emojis' import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' -import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' +import React, { + createRef, + PropsWithChildren, + RefObject, + useEffect, + useReducer, + useState +} from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, KeyboardAvoidingView, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' -import EmojisContext, { emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext' +import EmojisContext, { Emojis, emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext' const prefetchEmojis = ( sortedEmojis: { @@ -48,6 +55,8 @@ export type Props = { customBehavior?: 'height' | 'padding' | 'position' } +export const emojis: Emojis = createRef() + const ComponentEmojis: React.FC = ({ children, inputProps, @@ -58,11 +67,7 @@ const ComponentEmojis: React.FC = ({ }) => { const { reduceMotionEnabled } = useAccessibility() - const [emojisState, emojisDispatch] = useReducer(emojisReducer, { - emojis: [], - inputProps, - targetIndex: -1 - }) + const [emojisState, emojisDispatch] = useReducer(emojisReducer, { inputProps, targetIndex: -1 }) useEffect(() => { emojisDispatch({ type: 'input', payload: inputProps }) }, [inputProps]) @@ -72,7 +77,7 @@ const ComponentEmojis: React.FC = ({ const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) useEffect(() => { if (data && data.length) { - let sortedEmojis: EmojisState['emojis'] = [] + let sortedEmojis: NonNullable = [] forEach(groupBy(sortBy(data, ['category', 'shortcode']), 'category'), (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 4) }) ) @@ -86,7 +91,7 @@ const ComponentEmojis: React.FC = ({ type: 'frequent' }) } - emojisDispatch({ type: 'load', payload: sortedEmojis }) + emojis.current = sortedEmojis prefetchEmojis(sortedEmojis, reduceMotionEnabled) } }, [data, reduceMotionEnabled]) diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx index 3b7be527..c7783541 100644 --- a/src/components/Emojis/Button.tsx +++ b/src/components/Emojis/Button.tsx @@ -1,3 +1,4 @@ +import { emojis } from '@components/Emojis' import Icon from '@components/Icon' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' @@ -16,7 +17,7 @@ const EmojisButton: React.FC = () => { return ( { if (emojisState.targetIndex === -1) { Keyboard.dismiss() @@ -38,12 +39,10 @@ const EmojisButton: React.FC = () => { }} > diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index 0d6c4f07..ecf3424d 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -1,3 +1,4 @@ +import { emojis } from '@components/Emojis' import Icon from '@components/Icon' import CustomText from '@components/Text' import { useAppDispatch } from '@root/store' @@ -175,17 +176,19 @@ const EmojisList = () => { ? [ { title: 'Search result', - data: chunk( - emojisState.emojis - .filter(e => e.type !== 'frequent') - .flatMap(e => - e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) - ), - 2 - ) + data: emojis.current + ? chunk( + emojis.current + .filter(e => e.type !== 'frequent') + .flatMap(e => + e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) + ), + 2 + ) + : [] } ] - : emojisState.emojis + : emojis.current || [] } keyExtractor={item => item[0]?.shortcode} renderSectionHeader={({ section: { title } }) => ( diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index 52935d9a..54d137f8 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -10,18 +10,21 @@ type inputProps = { addFunc?: (add: string) => void // For none default state update } +export type Emojis = MutableRefObject< + | { + title: string + data: Pick[][] + type?: 'frequent' + }[] + | null +> + export type EmojisState = { - emojis: { - title: string - data: Pick[][] - type?: 'frequent' - }[] inputProps: inputProps[] targetIndex: number } export type EmojisAction = - | { type: 'load'; payload: NonNullable } | { type: 'input'; payload: EmojisState['inputProps'] } | { type: 'target'; payload: EmojisState['targetIndex'] } @@ -33,8 +36,6 @@ const EmojisContext = createContext({} as ContextType) export const emojisReducer = (state: EmojisState, action: EmojisAction) => { switch (action.type) { - case 'load': - return { ...state, emojis: action.payload } case 'input': return { ...state, inputProps: action.payload } case 'target': diff --git a/src/modules/autolinker/anchor-tag-builder.d.ts b/src/modules/autolinker/anchor-tag-builder.d.ts deleted file mode 100644 index 09a5965d..00000000 --- a/src/modules/autolinker/anchor-tag-builder.d.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Match } from "./match/match"; -import { HtmlTag } from "./html-tag"; -import { TruncateConfigObj } from "./autolinker"; -/** - * @protected - * @class Autolinker.AnchorTagBuilder - * @extends Object - * - * Builds anchor (<a>) tags for the Autolinker utility when a match is - * found. - * - * Normally this class is instantiated, configured, and used internally by an - * {@link Autolinker} instance, but may actually be used indirectly in a - * {@link Autolinker#replaceFn replaceFn} to create {@link Autolinker.HtmlTag HtmlTag} - * instances which may be modified before returning from the - * {@link Autolinker#replaceFn replaceFn}. For example: - * - * var html = Autolinker.link( "Test google.com", { - * replaceFn : function( match ) { - * var tag = match.buildTag(); // returns an {@link Autolinker.HtmlTag} instance - * tag.setAttr( 'rel', 'nofollow' ); - * - * return tag; - * } - * } ); - * - * // generated html: - * // Test google.com - */ -export declare class AnchorTagBuilder { - /** - * @cfg {Boolean} newWindow - * @inheritdoc Autolinker#newWindow - */ - private readonly newWindow; - /** - * @cfg {Object} truncate - * @inheritdoc Autolinker#truncate - */ - private readonly truncate; - /** - * @cfg {String} className - * @inheritdoc Autolinker#className - */ - private readonly className; - /** - * @method constructor - * @param {Object} [cfg] The configuration options for the AnchorTagBuilder instance, specified in an Object (map). - */ - constructor(cfg?: AnchorTagBuilderCfg); - /** - * Generates the actual anchor (<a>) tag to use in place of the - * matched text, via its `match` object. - * - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {Autolinker.HtmlTag} The HtmlTag instance for the anchor tag. - */ - build(match: Match): HtmlTag; - /** - * Creates the Object (map) of the HTML attributes for the anchor (<a>) - * tag being generated. - * - * @protected - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {Object} A key/value Object (map) of the anchor tag's attributes. - */ - protected createAttrs(match: Match): { - [attrName: string]: string; - }; - /** - * Creates the CSS class that will be used for a given anchor tag, based on - * the `matchType` and the {@link #className} config. - * - * Example returns: - * - * - "" // no {@link #className} - * - "myLink myLink-url" // url match - * - "myLink myLink-email" // email match - * - "myLink myLink-phone" // phone match - * - "myLink myLink-hashtag" // hashtag match - * - "myLink myLink-mention myLink-twitter" // mention match with Twitter service - * - * @protected - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {String} The CSS class string for the link. Example return: - * "myLink myLink-url". If no {@link #className} was configured, returns - * an empty string. - */ - protected createCssClass(match: Match): string; - /** - * Processes the `anchorText` by truncating the text according to the - * {@link #truncate} config. - * - * @private - * @param {String} anchorText The anchor tag's text (i.e. what will be - * displayed). - * @return {String} The processed `anchorText`. - */ - private processAnchorText; - /** - * Performs the truncation of the `anchorText` based on the {@link #truncate} - * option. If the `anchorText` is longer than the length specified by the - * {@link #truncate} option, the truncation is performed based on the - * `location` property. See {@link #truncate} for details. - * - * @private - * @param {String} anchorText The anchor tag's text (i.e. what will be - * displayed). - * @return {String} The truncated anchor text. - */ - private doTruncate; -} -export interface AnchorTagBuilderCfg { - newWindow?: boolean; - truncate?: TruncateConfigObj; - className?: string; -} diff --git a/src/modules/autolinker/anchor-tag-builder.js b/src/modules/autolinker/anchor-tag-builder.js deleted file mode 100644 index b44caaab..00000000 --- a/src/modules/autolinker/anchor-tag-builder.js +++ /dev/null @@ -1,176 +0,0 @@ -import { HtmlTag } from "./html-tag"; -import { truncateSmart } from "./truncate/truncate-smart"; -import { truncateMiddle } from "./truncate/truncate-middle"; -import { truncateEnd } from "./truncate/truncate-end"; -/** - * @protected - * @class Autolinker.AnchorTagBuilder - * @extends Object - * - * Builds anchor (<a>) tags for the Autolinker utility when a match is - * found. - * - * Normally this class is instantiated, configured, and used internally by an - * {@link Autolinker} instance, but may actually be used indirectly in a - * {@link Autolinker#replaceFn replaceFn} to create {@link Autolinker.HtmlTag HtmlTag} - * instances which may be modified before returning from the - * {@link Autolinker#replaceFn replaceFn}. For example: - * - * var html = Autolinker.link( "Test google.com", { - * replaceFn : function( match ) { - * var tag = match.buildTag(); // returns an {@link Autolinker.HtmlTag} instance - * tag.setAttr( 'rel', 'nofollow' ); - * - * return tag; - * } - * } ); - * - * // generated html: - * // Test google.com - */ -var AnchorTagBuilder = /** @class */ (function () { - /** - * @method constructor - * @param {Object} [cfg] The configuration options for the AnchorTagBuilder instance, specified in an Object (map). - */ - function AnchorTagBuilder(cfg) { - if (cfg === void 0) { cfg = {}; } - /** - * @cfg {Boolean} newWindow - * @inheritdoc Autolinker#newWindow - */ - this.newWindow = false; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Object} truncate - * @inheritdoc Autolinker#truncate - */ - this.truncate = {}; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {String} className - * @inheritdoc Autolinker#className - */ - this.className = ''; // default value just to get the above doc comment in the ES5 output and documentation generator - this.newWindow = cfg.newWindow || false; - this.truncate = cfg.truncate || {}; - this.className = cfg.className || ''; - } - /** - * Generates the actual anchor (<a>) tag to use in place of the - * matched text, via its `match` object. - * - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {Autolinker.HtmlTag} The HtmlTag instance for the anchor tag. - */ - AnchorTagBuilder.prototype.build = function (match) { - return new HtmlTag({ - tagName: 'a', - attrs: this.createAttrs(match), - innerHtml: this.processAnchorText(match.getAnchorText()) - }); - }; - /** - * Creates the Object (map) of the HTML attributes for the anchor (<a>) - * tag being generated. - * - * @protected - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {Object} A key/value Object (map) of the anchor tag's attributes. - */ - AnchorTagBuilder.prototype.createAttrs = function (match) { - var attrs = { - 'href': match.getAnchorHref() // we'll always have the `href` attribute - }; - var cssClass = this.createCssClass(match); - if (cssClass) { - attrs['class'] = cssClass; - } - if (this.newWindow) { - attrs['target'] = "_blank"; - attrs['rel'] = "noopener noreferrer"; // Issue #149. See https://mathiasbynens.github.io/rel-noopener/ - } - if (this.truncate) { - if (this.truncate.length && this.truncate.length < match.getAnchorText().length) { - attrs['title'] = match.getAnchorHref(); - } - } - return attrs; - }; - /** - * Creates the CSS class that will be used for a given anchor tag, based on - * the `matchType` and the {@link #className} config. - * - * Example returns: - * - * - "" // no {@link #className} - * - "myLink myLink-url" // url match - * - "myLink myLink-email" // email match - * - "myLink myLink-phone" // phone match - * - "myLink myLink-hashtag" // hashtag match - * - "myLink myLink-mention myLink-twitter" // mention match with Twitter service - * - * @protected - * @param {Autolinker.match.Match} match The Match instance to generate an - * anchor tag from. - * @return {String} The CSS class string for the link. Example return: - * "myLink myLink-url". If no {@link #className} was configured, returns - * an empty string. - */ - AnchorTagBuilder.prototype.createCssClass = function (match) { - var className = this.className; - if (!className) { - return ""; - } - else { - var returnClasses = [className], cssClassSuffixes = match.getCssClassSuffixes(); - for (var i = 0, len = cssClassSuffixes.length; i < len; i++) { - returnClasses.push(className + '-' + cssClassSuffixes[i]); - } - return returnClasses.join(' '); - } - }; - /** - * Processes the `anchorText` by truncating the text according to the - * {@link #truncate} config. - * - * @private - * @param {String} anchorText The anchor tag's text (i.e. what will be - * displayed). - * @return {String} The processed `anchorText`. - */ - AnchorTagBuilder.prototype.processAnchorText = function (anchorText) { - anchorText = this.doTruncate(anchorText); - return anchorText; - }; - /** - * Performs the truncation of the `anchorText` based on the {@link #truncate} - * option. If the `anchorText` is longer than the length specified by the - * {@link #truncate} option, the truncation is performed based on the - * `location` property. See {@link #truncate} for details. - * - * @private - * @param {String} anchorText The anchor tag's text (i.e. what will be - * displayed). - * @return {String} The truncated anchor text. - */ - AnchorTagBuilder.prototype.doTruncate = function (anchorText) { - var truncate = this.truncate; - if (!truncate || !truncate.length) - return anchorText; - var truncateLength = truncate.length, truncateLocation = truncate.location; - if (truncateLocation === 'smart') { - return truncateSmart(anchorText, truncateLength); - } - else if (truncateLocation === 'middle') { - return truncateMiddle(anchorText, truncateLength); - } - else { - return truncateEnd(anchorText, truncateLength); - } - }; - return AnchorTagBuilder; -}()); -export { AnchorTagBuilder }; - -//# sourceMappingURL=anchor-tag-builder.js.map diff --git a/src/modules/autolinker/autolinker.d.ts b/src/modules/autolinker/autolinker.d.ts deleted file mode 100644 index 979a7356..00000000 --- a/src/modules/autolinker/autolinker.d.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { AnchorTagBuilder } from "./anchor-tag-builder"; -import { Match } from "./match/match"; -import { EmailMatch } from "./match/email-match"; -import { HashtagMatch } from "./match/hashtag-match"; -import { MentionMatch } from "./match/mention-match"; -import { PhoneMatch } from "./match/phone-match"; -import { UrlMatch } from "./match/url-match"; -import { Matcher } from "./matcher/matcher"; -import { HtmlTag } from "./html-tag"; -import { EmailMatcher } from "./matcher/email-matcher"; -import { UrlMatcher } from "./matcher/url-matcher"; -import { HashtagMatcher } from "./matcher/hashtag-matcher"; -import { PhoneMatcher } from "./matcher/phone-matcher"; -import { MentionMatcher } from "./matcher/mention-matcher"; -/** - * @class Autolinker - * @extends Object - * - * Utility class used to process a given string of text, and wrap the matches in - * the appropriate anchor (<a>) tags to turn them into links. - * - * Any of the configuration options may be provided in an Object provided - * to the Autolinker constructor, which will configure how the {@link #link link()} - * method will process the links. - * - * For example: - * - * var autolinker = new Autolinker( { - * newWindow : false, - * truncate : 30 - * } ); - * - * var html = autolinker.link( "Joe went to www.yahoo.com" ); - * // produces: 'Joe went to yahoo.com' - * - * - * The {@link #static-link static link()} method may also be used to inline - * options into a single call, which may be more convenient for one-off uses. - * For example: - * - * var html = Autolinker.link( "Joe went to www.yahoo.com", { - * newWindow : false, - * truncate : 30 - * } ); - * // produces: 'Joe went to yahoo.com' - * - * - * ## Custom Replacements of Links - * - * If the configuration options do not provide enough flexibility, a {@link #replaceFn} - * may be provided to fully customize the output of Autolinker. This function is - * called once for each URL/Email/Phone#/Hashtag/Mention (Twitter, Instagram, Soundcloud) - * match that is encountered. - * - * For example: - * - * var input = "..."; // string with URLs, Email Addresses, Phone #s, Hashtags, and Mentions (Twitter, Instagram, Soundcloud) - * - * var linkedText = Autolinker.link( input, { - * replaceFn : function( match ) { - * console.log( "href = ", match.getAnchorHref() ); - * console.log( "text = ", match.getAnchorText() ); - * - * switch( match.getType() ) { - * case 'url' : - * console.log( "url: ", match.getUrl() ); - * - * if( match.getUrl().indexOf( 'mysite.com' ) === -1 ) { - * var tag = match.buildTag(); // returns an `Autolinker.HtmlTag` instance, which provides mutator methods for easy changes - * tag.setAttr( 'rel', 'nofollow' ); - * tag.addClass( 'external-link' ); - * - * return tag; - * - * } else { - * return true; // let Autolinker perform its normal anchor tag replacement - * } - * - * case 'email' : - * var email = match.getEmail(); - * console.log( "email: ", email ); - * - * if( email === "my@own.address" ) { - * return false; // don't auto-link this particular email address; leave as-is - * } else { - * return; // no return value will have Autolinker perform its normal anchor tag replacement (same as returning `true`) - * } - * - * case 'phone' : - * var phoneNumber = match.getPhoneNumber(); - * console.log( phoneNumber ); - * - * return '' + phoneNumber + ''; - * - * case 'hashtag' : - * var hashtag = match.getHashtag(); - * console.log( hashtag ); - * - * return '' + hashtag + ''; - * - * case 'mention' : - * var mention = match.getMention(); - * console.log( mention ); - * - * return '' + mention + ''; - * } - * } - * } ); - * - * - * The function may return the following values: - * - * - `true` (Boolean): Allow Autolinker to replace the match as it normally - * would. - * - `false` (Boolean): Do not replace the current match at all - leave as-is. - * - Any String: If a string is returned from the function, the string will be - * used directly as the replacement HTML for the match. - * - An {@link Autolinker.HtmlTag} instance, which can be used to build/modify - * an HTML tag before writing out its HTML text. - */ -export default class Autolinker { - /** - * @static - * @property {String} version - * - * The Autolinker version number in the form major.minor.patch - * - * Ex: 0.25.1 - */ - static readonly version = "3.14.1"; - /** - * For backwards compatibility with Autolinker 1.x, the AnchorTagBuilder - * class is provided as a static on the Autolinker class. - */ - static readonly AnchorTagBuilder: typeof AnchorTagBuilder; - /** - * For backwards compatibility with Autolinker 1.x, the HtmlTag class is - * provided as a static on the Autolinker class. - */ - static readonly HtmlTag: typeof HtmlTag; - /** - * For backwards compatibility with Autolinker 1.x, the Matcher classes are - * provided as statics on the Autolinker class. - */ - static readonly matcher: { - Email: typeof EmailMatcher; - Hashtag: typeof HashtagMatcher; - Matcher: typeof Matcher; - Mention: typeof MentionMatcher; - Phone: typeof PhoneMatcher; - Url: typeof UrlMatcher; - }; - /** - * For backwards compatibility with Autolinker 1.x, the Match classes are - * provided as statics on the Autolinker class. - */ - static readonly match: { - Email: typeof EmailMatch; - Hashtag: typeof HashtagMatch; - Match: typeof Match; - Mention: typeof MentionMatch; - Phone: typeof PhoneMatch; - Url: typeof UrlMatch; - }; - /** - * Automatically links URLs, Email addresses, Phone Numbers, Twitter handles, - * Hashtags, and Mentions found in the given chunk of HTML. Does not link URLs - * found within HTML tags. - * - * For instance, if given the text: `You should go to http://www.yahoo.com`, - * then the result will be `You should go to <a href="http://www.yahoo.com">http://www.yahoo.com</a>` - * - * Example: - * - * var linkedText = Autolinker.link( "Go to google.com", { newWindow: false } ); - * // Produces: "Go to google.com" - * - * @static - * @param {String} textOrHtml The HTML or text to find matches within (depending - * on if the {@link #urls}, {@link #email}, {@link #phone}, {@link #mention}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @param {Object} [options] Any of the configuration options for the Autolinker - * class, specified in an Object (map). See the class description for an - * example call. - * @return {String} The HTML text, with matches automatically linked. - */ - static link(textOrHtml: string, options?: AutolinkerConfig): string; - /** - * Parses the input `textOrHtml` looking for URLs, email addresses, phone - * numbers, username handles, and hashtags (depending on the configuration - * of the Autolinker instance), and returns an array of {@link Autolinker.match.Match} - * objects describing those matches (without making any replacements). - * - * Note that if parsing multiple pieces of text, it is slightly more efficient - * to create an Autolinker instance, and use the instance-level {@link #parse} - * method. - * - * Example: - * - * var matches = Autolinker.parse( "Hello google.com, I am asdf@asdf.com", { - * urls: true, - * email: true - * } ); - * - * console.log( matches.length ); // 2 - * console.log( matches[ 0 ].getType() ); // 'url' - * console.log( matches[ 0 ].getUrl() ); // 'google.com' - * console.log( matches[ 1 ].getType() ); // 'email' - * console.log( matches[ 1 ].getEmail() ); // 'asdf@asdf.com' - * - * @static - * @param {String} textOrHtml The HTML or text to find matches within - * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @param {Object} [options] Any of the configuration options for the Autolinker - * class, specified in an Object (map). See the class description for an - * example call. - * @return {Autolinker.match.Match[]} The array of Matches found in the - * given input `textOrHtml`. - */ - static parse(textOrHtml: string, options: AutolinkerConfig): Match[]; - /** - * The Autolinker version number exposed on the instance itself. - * - * Ex: 0.25.1 - */ - readonly version = "3.14.1"; - /** - * @cfg {Boolean/Object} [urls] - * - * `true` if URLs should be automatically linked, `false` if they should not - * be. Defaults to `true`. - * - * Examples: - * - * urls: true - * - * // or - * - * urls: { - * schemeMatches : true, - * wwwMatches : true, - * tldMatches : true - * } - * - * As shown above, this option also accepts an Object form with 3 properties - * to allow for more customization of what exactly gets linked. All default - * to `true`: - * - * @cfg {Boolean} [urls.schemeMatches] `true` to match URLs found prefixed - * with a scheme, i.e. `http://google.com`, or `other+scheme://google.com`, - * `false` to prevent these types of matches. - * @cfg {Boolean} [urls.wwwMatches] `true` to match urls found prefixed with - * `'www.'`, i.e. `www.google.com`. `false` to prevent these types of - * matches. Note that if the URL had a prefixed scheme, and - * `schemeMatches` is true, it will still be linked. - * @cfg {Boolean} [urls.tldMatches] `true` to match URLs with known top - * level domains (.com, .net, etc.) that are not prefixed with a scheme or - * `'www.'`. This option attempts to match anything that looks like a URL - * in the given text. Ex: `google.com`, `asdf.org/?page=1`, etc. `false` - * to prevent these types of matches. - */ - private readonly urls; - /** - * @cfg {Boolean} [email=true] - * - * `true` if email addresses should be automatically linked, `false` if they - * should not be. - */ - private readonly email; - /** - * @cfg {Boolean} [phone=true] - * - * `true` if Phone numbers ("(555)555-5555") should be automatically linked, - * `false` if they should not be. - */ - private readonly phone; - /** - * @cfg {Boolean/String} [hashtag=false] - * - * A string for the service name to have hashtags (ex: "#myHashtag") - * auto-linked to. The currently-supported values are: - * - * - 'twitter' - * - 'facebook' - * - 'instagram' - * - * Pass `false` to skip auto-linking of hashtags. - */ - private readonly hashtag; - /** - * @cfg {String/Boolean} [mention=false] - * - * A string for the service name to have mentions (ex: "@myuser") - * auto-linked to. The currently supported values are: - * - * - 'twitter' - * - 'instagram' - * - 'soundcloud' - * - * Defaults to `false` to skip auto-linking of mentions. - */ - private readonly mention; - /** - * @cfg {Boolean} [newWindow=true] - * - * `true` if the links should open in a new window, `false` otherwise. - */ - private readonly newWindow; - /** - * @cfg {Boolean/Object} [stripPrefix=true] - * - * `true` if 'http://' (or 'https://') and/or the 'www.' should be stripped - * from the beginning of URL links' text, `false` otherwise. Defaults to - * `true`. - * - * Examples: - * - * stripPrefix: true - * - * // or - * - * stripPrefix: { - * scheme : true, - * www : true - * } - * - * As shown above, this option also accepts an Object form with 2 properties - * to allow for more customization of what exactly is prevented from being - * displayed. Both default to `true`: - * - * @cfg {Boolean} [stripPrefix.scheme] `true` to prevent the scheme part of - * a URL match from being displayed to the user. Example: - * `'http://google.com'` will be displayed as `'google.com'`. `false` to - * not strip the scheme. NOTE: Only an `'http://'` or `'https://'` scheme - * will be removed, so as not to remove a potentially dangerous scheme - * (such as `'file://'` or `'javascript:'`) - * @cfg {Boolean} [stripPrefix.www] www (Boolean): `true` to prevent the - * `'www.'` part of a URL match from being displayed to the user. Ex: - * `'www.google.com'` will be displayed as `'google.com'`. `false` to not - * strip the `'www'`. - */ - private readonly stripPrefix; - /** - * @cfg {Boolean} [stripTrailingSlash=true] - * - * `true` to remove the trailing slash from URL matches, `false` to keep - * the trailing slash. - * - * Example when `true`: `http://google.com/` will be displayed as - * `http://google.com`. - */ - private readonly stripTrailingSlash; - /** - * @cfg {Boolean} [decodePercentEncoding=true] - * - * `true` to decode percent-encoded characters in URL matches, `false` to keep - * the percent-encoded characters. - * - * Example when `true`: `https://en.wikipedia.org/wiki/San_Jos%C3%A9` will - * be displayed as `https://en.wikipedia.org/wiki/San_José`. - */ - private readonly decodePercentEncoding; - /** - * @cfg {Number/Object} [truncate=0] - * - * ## Number Form - * - * A number for how many characters matched text should be truncated to - * inside the text of a link. If the matched text is over this number of - * characters, it will be truncated to this length by adding a two period - * ellipsis ('..') to the end of the string. - * - * For example: A url like 'http://www.yahoo.com/some/long/path/to/a/file' - * truncated to 25 characters might look something like this: - * 'yahoo.com/some/long/pat..' - * - * Example Usage: - * - * truncate: 25 - * - * - * Defaults to `0` for "no truncation." - * - * - * ## Object Form - * - * An Object may also be provided with two properties: `length` (Number) and - * `location` (String). `location` may be one of the following: 'end' - * (default), 'middle', or 'smart'. - * - * Example Usage: - * - * truncate: { length: 25, location: 'middle' } - * - * @cfg {Number} [truncate.length=0] How many characters to allow before - * truncation will occur. Defaults to `0` for "no truncation." - * @cfg {"end"/"middle"/"smart"} [truncate.location="end"] - * - * - 'end' (default): will truncate up to the number of characters, and then - * add an ellipsis at the end. Ex: 'yahoo.com/some/long/pat..' - * - 'middle': will truncate and add the ellipsis in the middle. Ex: - * 'yahoo.com/s..th/to/a/file' - * - 'smart': for URLs where the algorithm attempts to strip out unnecessary - * parts first (such as the 'www.', then URL scheme, hash, etc.), - * attempting to make the URL human-readable before looking for a good - * point to insert the ellipsis if it is still too long. Ex: - * 'yahoo.com/some..to/a/file'. For more details, see - * {@link Autolinker.truncate.TruncateSmart}. - */ - private readonly truncate; - /** - * @cfg {String} className - * - * A CSS class name to add to the generated links. This class will be added - * to all links, as well as this class plus match suffixes for styling - * url/email/phone/hashtag/mention links differently. - * - * For example, if this config is provided as "myLink", then: - * - * - URL links will have the CSS classes: "myLink myLink-url" - * - Email links will have the CSS classes: "myLink myLink-email", and - * - Phone links will have the CSS classes: "myLink myLink-phone" - * - Hashtag links will have the CSS classes: "myLink myLink-hashtag" - * - Mention links will have the CSS classes: "myLink myLink-mention myLink-[type]" - * where [type] is either "instagram", "twitter" or "soundcloud" - */ - private readonly className; - /** - * @cfg {Function} replaceFn - * - * A function to individually process each match found in the input string. - * - * See the class's description for usage. - * - * The `replaceFn` can be called with a different context object (`this` - * reference) using the {@link #context} cfg. - * - * This function is called with the following parameter: - * - * @cfg {Autolinker.match.Match} replaceFn.match The Match instance which - * can be used to retrieve information about the match that the `replaceFn` - * is currently processing. See {@link Autolinker.match.Match} subclasses - * for details. - */ - private readonly replaceFn; - /** - * @cfg {Object} context - * - * The context object (`this` reference) to call the `replaceFn` with. - * - * Defaults to this Autolinker instance. - */ - private readonly context; - /** - * @cfg {Boolean} [sanitizeHtml=false] - * - * `true` to HTML-encode the start and end brackets of existing HTML tags found - * in the input string. This will escape `<` and `>` characters to `<` and - * `>`, respectively. - * - * Setting this to `true` will prevent XSS (Cross-site Scripting) attacks, - * but will remove the significance of existing HTML tags in the input string. If - * you would like to maintain the significance of existing HTML tags while also - * making the output HTML string safe, leave this option as `false` and use a - * tool like https://github.com/cure53/DOMPurify (or others) on the input string - * before running Autolinker. - */ - private readonly sanitizeHtml; - /** - * @private - * @property {Autolinker.matcher.Matcher[]} matchers - * - * The {@link Autolinker.matcher.Matcher} instances for this Autolinker - * instance. - * - * This is lazily created in {@link #getMatchers}. - */ - private matchers; - /** - * @private - * @property {Autolinker.AnchorTagBuilder} tagBuilder - * - * The AnchorTagBuilder instance used to build match replacement anchor tags. - * Note: this is lazily instantiated in the {@link #getTagBuilder} method. - */ - private tagBuilder; - /** - * @method constructor - * @param {Object} [cfg] The configuration options for the Autolinker instance, - * specified in an Object (map). - */ - constructor(cfg?: AutolinkerConfig); - /** - * Normalizes the {@link #urls} config into an Object with 3 properties: - * `schemeMatches`, `wwwMatches`, and `tldMatches`, all Booleans. - * - * See {@link #urls} config for details. - * - * @private - * @param {Boolean/Object} urls - * @return {Object} - */ - private normalizeUrlsCfg; - /** - * Normalizes the {@link #stripPrefix} config into an Object with 2 - * properties: `scheme`, and `www` - both Booleans. - * - * See {@link #stripPrefix} config for details. - * - * @private - * @param {Boolean/Object} stripPrefix - * @return {Object} - */ - private normalizeStripPrefixCfg; - /** - * Normalizes the {@link #truncate} config into an Object with 2 properties: - * `length` (Number), and `location` (String). - * - * See {@link #truncate} config for details. - * - * @private - * @param {Number/Object} truncate - * @return {Object} - */ - private normalizeTruncateCfg; - /** - * Parses the input `textOrHtml` looking for URLs, email addresses, phone - * numbers, username handles, and hashtags (depending on the configuration - * of the Autolinker instance), and returns an array of {@link Autolinker.match.Match} - * objects describing those matches (without making any replacements). - * - * This method is used by the {@link #link} method, but can also be used to - * simply do parsing of the input in order to discover what kinds of links - * there are and how many. - * - * Example usage: - * - * var autolinker = new Autolinker( { - * urls: true, - * email: true - * } ); - * - * var matches = autolinker.parse( "Hello google.com, I am asdf@asdf.com" ); - * - * console.log( matches.length ); // 2 - * console.log( matches[ 0 ].getType() ); // 'url' - * console.log( matches[ 0 ].getUrl() ); // 'google.com' - * console.log( matches[ 1 ].getType() ); // 'email' - * console.log( matches[ 1 ].getEmail() ); // 'asdf@asdf.com' - * - * @param {String} textOrHtml The HTML or text to find matches within - * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @return {Autolinker.match.Match[]} The array of Matches found in the - * given input `textOrHtml`. - */ - parse(textOrHtml: string): Match[]; - /** - * After we have found all matches, we need to remove matches that overlap - * with a previous match. This can happen for instance with URLs, where the - * url 'google.com/#link' would match '#link' as a hashtag. Because the - * '#link' part is contained in a larger match that comes before the HashTag - * match, we'll remove the HashTag match. - * - * @private - * @param {Autolinker.match.Match[]} matches - * @return {Autolinker.match.Match[]} - */ - private compactMatches; - /** - * Removes matches for matchers that were turned off in the options. For - * example, if {@link #hashtag hashtags} were not to be matched, we'll - * remove them from the `matches` array here. - * - * Note: we *must* use all Matchers on the input string, and then filter - * them out later. For example, if the options were `{ url: false, hashtag: true }`, - * we wouldn't want to match the text '#link' as a HashTag inside of the text - * 'google.com/#link'. The way the algorithm works is that we match the full - * URL first (which prevents the accidental HashTag match), and then we'll - * simply throw away the URL match. - * - * @private - * @param {Autolinker.match.Match[]} matches The array of matches to remove - * the unwanted matches from. Note: this array is mutated for the - * removals. - * @return {Autolinker.match.Match[]} The mutated input `matches` array. - */ - private removeUnwantedMatches; - /** - * Parses the input `text` looking for URLs, email addresses, phone - * numbers, username handles, and hashtags (depending on the configuration - * of the Autolinker instance), and returns an array of {@link Autolinker.match.Match} - * objects describing those matches. - * - * This method processes a **non-HTML string**, and is used to parse and - * match within the text nodes of an HTML string. This method is used - * internally by {@link #parse}. - * - * @private - * @param {String} text The text to find matches within (depending on if the - * {@link #urls}, {@link #email}, {@link #phone}, - * {@link #hashtag}, and {@link #mention} options are enabled). This must be a non-HTML string. - * @param {Number} [offset=0] The offset of the text node within the - * original string. This is used when parsing with the {@link #parse} - * method to generate correct offsets within the {@link Autolinker.match.Match} - * instances, but may be omitted if calling this method publicly. - * @return {Autolinker.match.Match[]} The array of Matches found in the - * given input `text`. - */ - private parseText; - /** - * Automatically links URLs, Email addresses, Phone numbers, Hashtags, - * and Mentions (Twitter, Instagram, Soundcloud) found in the given chunk of HTML. Does not link - * URLs found within HTML tags. - * - * For instance, if given the text: `You should go to http://www.yahoo.com`, - * then the result will be `You should go to - * <a href="http://www.yahoo.com">http://www.yahoo.com</a>` - * - * This method finds the text around any HTML elements in the input - * `textOrHtml`, which will be the text that is processed. Any original HTML - * elements will be left as-is, as well as the text that is already wrapped - * in anchor (<a>) tags. - * - * @param {String} textOrHtml The HTML or text to autolink matches within - * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, {@link #hashtag}, and {@link #mention} options are enabled). - * @return {String} The HTML, with matches automatically linked. - */ - link(textOrHtml: string): string; - /** - * Creates the return string value for a given match in the input string. - * - * This method handles the {@link #replaceFn}, if one was provided. - * - * @private - * @param {Autolinker.match.Match} match The Match object that represents - * the match. - * @return {String} The string that the `match` should be replaced with. - * This is usually the anchor tag string, but may be the `matchStr` itself - * if the match is not to be replaced. - */ - private createMatchReturnVal; - /** - * Lazily instantiates and returns the {@link Autolinker.matcher.Matcher} - * instances for this Autolinker instance. - * - * @private - * @return {Autolinker.matcher.Matcher[]} - */ - private getMatchers; - /** - * Returns the {@link #tagBuilder} instance for this Autolinker instance, - * lazily instantiating it if it does not yet exist. - * - * @private - * @return {Autolinker.AnchorTagBuilder} - */ - private getTagBuilder; -} -export interface AutolinkerConfig { - urls?: UrlsConfig; - email?: boolean; - phone?: boolean; - hashtag?: HashtagConfig; - mention?: MentionConfig; - newWindow?: boolean; - stripPrefix?: StripPrefixConfig; - stripTrailingSlash?: boolean; - truncate?: TruncateConfig; - className?: string; - replaceFn?: ReplaceFn | null; - context?: any; - sanitizeHtml?: boolean; - decodePercentEncoding?: boolean; -} -export declare type UrlsConfig = boolean | UrlsConfigObj; -export interface UrlsConfigObj { - schemeMatches?: boolean; - wwwMatches?: boolean; - tldMatches?: boolean; -} -export declare type UrlMatchTypeOptions = 'scheme' | 'www' | 'tld'; -export declare type StripPrefixConfig = boolean | StripPrefixConfigObj; -export interface StripPrefixConfigObj { - scheme?: boolean; - www?: boolean; -} -export declare type TruncateConfig = number | TruncateConfigObj; -export interface TruncateConfigObj { - length?: number; - location?: "end" | "middle" | "smart"; -} -export declare type HashtagConfig = false | HashtagServices; -export declare type HashtagServices = 'twitter' | 'facebook' | 'instagram'; -export declare type MentionConfig = false | MentionServices; -export declare type MentionServices = 'mastodon' | 'twitter' | 'instagram' | 'soundcloud'; -export declare type ReplaceFn = (match: Match) => ReplaceFnReturn; -export declare type ReplaceFnReturn = boolean | string | HtmlTag | null | undefined | void; diff --git a/src/modules/autolinker/autolinker.js b/src/modules/autolinker/autolinker.js deleted file mode 100644 index 86ceb9f3..00000000 --- a/src/modules/autolinker/autolinker.js +++ /dev/null @@ -1,907 +0,0 @@ -import { defaults, remove, splitAndCapture } from "./utils"; -import { AnchorTagBuilder } from "./anchor-tag-builder"; -import { Match } from "./match/match"; -import { EmailMatch } from "./match/email-match"; -import { HashtagMatch } from "./match/hashtag-match"; -import { MentionMatch } from "./match/mention-match"; -import { PhoneMatch } from "./match/phone-match"; -import { UrlMatch } from "./match/url-match"; -import { Matcher } from "./matcher/matcher"; -import { HtmlTag } from "./html-tag"; -import { EmailMatcher } from "./matcher/email-matcher"; -import { UrlMatcher } from "./matcher/url-matcher"; -import { HashtagMatcher } from "./matcher/hashtag-matcher"; -import { PhoneMatcher } from "./matcher/phone-matcher"; -import { MentionMatcher } from "./matcher/mention-matcher"; -import { parseHtml } from './htmlParser/parse-html'; -/** - * @class Autolinker - * @extends Object - * - * Utility class used to process a given string of text, and wrap the matches in - * the appropriate anchor (<a>) tags to turn them into links. - * - * Any of the configuration options may be provided in an Object provided - * to the Autolinker constructor, which will configure how the {@link #link link()} - * method will process the links. - * - * For example: - * - * var autolinker = new Autolinker( { - * newWindow : false, - * truncate : 30 - * } ); - * - * var html = autolinker.link( "Joe went to www.yahoo.com" ); - * // produces: 'Joe went to yahoo.com' - * - * - * The {@link #static-link static link()} method may also be used to inline - * options into a single call, which may be more convenient for one-off uses. - * For example: - * - * var html = Autolinker.link( "Joe went to www.yahoo.com", { - * newWindow : false, - * truncate : 30 - * } ); - * // produces: 'Joe went to yahoo.com' - * - * - * ## Custom Replacements of Links - * - * If the configuration options do not provide enough flexibility, a {@link #replaceFn} - * may be provided to fully customize the output of Autolinker. This function is - * called once for each URL/Email/Phone#/Hashtag/Mention (Twitter, Instagram, Soundcloud) - * match that is encountered. - * - * For example: - * - * var input = "..."; // string with URLs, Email Addresses, Phone #s, Hashtags, and Mentions (Twitter, Instagram, Soundcloud) - * - * var linkedText = Autolinker.link( input, { - * replaceFn : function( match ) { - * console.log( "href = ", match.getAnchorHref() ); - * console.log( "text = ", match.getAnchorText() ); - * - * switch( match.getType() ) { - * case 'url' : - * console.log( "url: ", match.getUrl() ); - * - * if( match.getUrl().indexOf( 'mysite.com' ) === -1 ) { - * var tag = match.buildTag(); // returns an `Autolinker.HtmlTag` instance, which provides mutator methods for easy changes - * tag.setAttr( 'rel', 'nofollow' ); - * tag.addClass( 'external-link' ); - * - * return tag; - * - * } else { - * return true; // let Autolinker perform its normal anchor tag replacement - * } - * - * case 'email' : - * var email = match.getEmail(); - * console.log( "email: ", email ); - * - * if( email === "my@own.address" ) { - * return false; // don't auto-link this particular email address; leave as-is - * } else { - * return; // no return value will have Autolinker perform its normal anchor tag replacement (same as returning `true`) - * } - * - * case 'phone' : - * var phoneNumber = match.getPhoneNumber(); - * console.log( phoneNumber ); - * - * return '' + phoneNumber + ''; - * - * case 'hashtag' : - * var hashtag = match.getHashtag(); - * console.log( hashtag ); - * - * return '' + hashtag + ''; - * - * case 'mention' : - * var mention = match.getMention(); - * console.log( mention ); - * - * return '' + mention + ''; - * } - * } - * } ); - * - * - * The function may return the following values: - * - * - `true` (Boolean): Allow Autolinker to replace the match as it normally - * would. - * - `false` (Boolean): Do not replace the current match at all - leave as-is. - * - Any String: If a string is returned from the function, the string will be - * used directly as the replacement HTML for the match. - * - An {@link Autolinker.HtmlTag} instance, which can be used to build/modify - * an HTML tag before writing out its HTML text. - */ -var Autolinker = /** @class */ (function () { - /** - * @method constructor - * @param {Object} [cfg] The configuration options for the Autolinker instance, - * specified in an Object (map). - */ - function Autolinker(cfg) { - if (cfg === void 0) { cfg = {}; } - /** - * The Autolinker version number exposed on the instance itself. - * - * Ex: 0.25.1 - */ - this.version = Autolinker.version; - /** - * @cfg {Boolean/Object} [urls] - * - * `true` if URLs should be automatically linked, `false` if they should not - * be. Defaults to `true`. - * - * Examples: - * - * urls: true - * - * // or - * - * urls: { - * schemeMatches : true, - * wwwMatches : true, - * tldMatches : true - * } - * - * As shown above, this option also accepts an Object form with 3 properties - * to allow for more customization of what exactly gets linked. All default - * to `true`: - * - * @cfg {Boolean} [urls.schemeMatches] `true` to match URLs found prefixed - * with a scheme, i.e. `http://google.com`, or `other+scheme://google.com`, - * `false` to prevent these types of matches. - * @cfg {Boolean} [urls.wwwMatches] `true` to match urls found prefixed with - * `'www.'`, i.e. `www.google.com`. `false` to prevent these types of - * matches. Note that if the URL had a prefixed scheme, and - * `schemeMatches` is true, it will still be linked. - * @cfg {Boolean} [urls.tldMatches] `true` to match URLs with known top - * level domains (.com, .net, etc.) that are not prefixed with a scheme or - * `'www.'`. This option attempts to match anything that looks like a URL - * in the given text. Ex: `google.com`, `asdf.org/?page=1`, etc. `false` - * to prevent these types of matches. - */ - this.urls = {}; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [email=true] - * - * `true` if email addresses should be automatically linked, `false` if they - * should not be. - */ - this.email = true; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [phone=true] - * - * `true` if Phone numbers ("(555)555-5555") should be automatically linked, - * `false` if they should not be. - */ - this.phone = true; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean/String} [hashtag=false] - * - * A string for the service name to have hashtags (ex: "#myHashtag") - * auto-linked to. The currently-supported values are: - * - * - 'twitter' - * - 'facebook' - * - 'instagram' - * - * Pass `false` to skip auto-linking of hashtags. - */ - this.hashtag = false; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {String/Boolean} [mention=false] - * - * A string for the service name to have mentions (ex: "@myuser") - * auto-linked to. The currently supported values are: - * - * - 'twitter' - * - 'instagram' - * - 'soundcloud' - * - * Defaults to `false` to skip auto-linking of mentions. - */ - this.mention = false; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [newWindow=true] - * - * `true` if the links should open in a new window, `false` otherwise. - */ - this.newWindow = true; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean/Object} [stripPrefix=true] - * - * `true` if 'http://' (or 'https://') and/or the 'www.' should be stripped - * from the beginning of URL links' text, `false` otherwise. Defaults to - * `true`. - * - * Examples: - * - * stripPrefix: true - * - * // or - * - * stripPrefix: { - * scheme : true, - * www : true - * } - * - * As shown above, this option also accepts an Object form with 2 properties - * to allow for more customization of what exactly is prevented from being - * displayed. Both default to `true`: - * - * @cfg {Boolean} [stripPrefix.scheme] `true` to prevent the scheme part of - * a URL match from being displayed to the user. Example: - * `'http://google.com'` will be displayed as `'google.com'`. `false` to - * not strip the scheme. NOTE: Only an `'http://'` or `'https://'` scheme - * will be removed, so as not to remove a potentially dangerous scheme - * (such as `'file://'` or `'javascript:'`) - * @cfg {Boolean} [stripPrefix.www] www (Boolean): `true` to prevent the - * `'www.'` part of a URL match from being displayed to the user. Ex: - * `'www.google.com'` will be displayed as `'google.com'`. `false` to not - * strip the `'www'`. - */ - this.stripPrefix = { scheme: true, www: true }; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [stripTrailingSlash=true] - * - * `true` to remove the trailing slash from URL matches, `false` to keep - * the trailing slash. - * - * Example when `true`: `http://google.com/` will be displayed as - * `http://google.com`. - */ - this.stripTrailingSlash = true; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [decodePercentEncoding=true] - * - * `true` to decode percent-encoded characters in URL matches, `false` to keep - * the percent-encoded characters. - * - * Example when `true`: `https://en.wikipedia.org/wiki/San_Jos%C3%A9` will - * be displayed as `https://en.wikipedia.org/wiki/San_José`. - */ - this.decodePercentEncoding = true; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Number/Object} [truncate=0] - * - * ## Number Form - * - * A number for how many characters matched text should be truncated to - * inside the text of a link. If the matched text is over this number of - * characters, it will be truncated to this length by adding a two period - * ellipsis ('..') to the end of the string. - * - * For example: A url like 'http://www.yahoo.com/some/long/path/to/a/file' - * truncated to 25 characters might look something like this: - * 'yahoo.com/some/long/pat..' - * - * Example Usage: - * - * truncate: 25 - * - * - * Defaults to `0` for "no truncation." - * - * - * ## Object Form - * - * An Object may also be provided with two properties: `length` (Number) and - * `location` (String). `location` may be one of the following: 'end' - * (default), 'middle', or 'smart'. - * - * Example Usage: - * - * truncate: { length: 25, location: 'middle' } - * - * @cfg {Number} [truncate.length=0] How many characters to allow before - * truncation will occur. Defaults to `0` for "no truncation." - * @cfg {"end"/"middle"/"smart"} [truncate.location="end"] - * - * - 'end' (default): will truncate up to the number of characters, and then - * add an ellipsis at the end. Ex: 'yahoo.com/some/long/pat..' - * - 'middle': will truncate and add the ellipsis in the middle. Ex: - * 'yahoo.com/s..th/to/a/file' - * - 'smart': for URLs where the algorithm attempts to strip out unnecessary - * parts first (such as the 'www.', then URL scheme, hash, etc.), - * attempting to make the URL human-readable before looking for a good - * point to insert the ellipsis if it is still too long. Ex: - * 'yahoo.com/some..to/a/file'. For more details, see - * {@link Autolinker.truncate.TruncateSmart}. - */ - this.truncate = { length: 0, location: 'end' }; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {String} className - * - * A CSS class name to add to the generated links. This class will be added - * to all links, as well as this class plus match suffixes for styling - * url/email/phone/hashtag/mention links differently. - * - * For example, if this config is provided as "myLink", then: - * - * - URL links will have the CSS classes: "myLink myLink-url" - * - Email links will have the CSS classes: "myLink myLink-email", and - * - Phone links will have the CSS classes: "myLink myLink-phone" - * - Hashtag links will have the CSS classes: "myLink myLink-hashtag" - * - Mention links will have the CSS classes: "myLink myLink-mention myLink-[type]" - * where [type] is either "instagram", "twitter" or "soundcloud" - */ - this.className = ''; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Function} replaceFn - * - * A function to individually process each match found in the input string. - * - * See the class's description for usage. - * - * The `replaceFn` can be called with a different context object (`this` - * reference) using the {@link #context} cfg. - * - * This function is called with the following parameter: - * - * @cfg {Autolinker.match.Match} replaceFn.match The Match instance which - * can be used to retrieve information about the match that the `replaceFn` - * is currently processing. See {@link Autolinker.match.Match} subclasses - * for details. - */ - this.replaceFn = null; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Object} context - * - * The context object (`this` reference) to call the `replaceFn` with. - * - * Defaults to this Autolinker instance. - */ - this.context = undefined; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @cfg {Boolean} [sanitizeHtml=false] - * - * `true` to HTML-encode the start and end brackets of existing HTML tags found - * in the input string. This will escape `<` and `>` characters to `<` and - * `>`, respectively. - * - * Setting this to `true` will prevent XSS (Cross-site Scripting) attacks, - * but will remove the significance of existing HTML tags in the input string. If - * you would like to maintain the significance of existing HTML tags while also - * making the output HTML string safe, leave this option as `false` and use a - * tool like https://github.com/cure53/DOMPurify (or others) on the input string - * before running Autolinker. - */ - this.sanitizeHtml = false; // default value just to get the above doc comment in the ES5 output and documentation generator - /** - * @private - * @property {Autolinker.matcher.Matcher[]} matchers - * - * The {@link Autolinker.matcher.Matcher} instances for this Autolinker - * instance. - * - * This is lazily created in {@link #getMatchers}. - */ - this.matchers = null; - /** - * @private - * @property {Autolinker.AnchorTagBuilder} tagBuilder - * - * The AnchorTagBuilder instance used to build match replacement anchor tags. - * Note: this is lazily instantiated in the {@link #getTagBuilder} method. - */ - this.tagBuilder = null; - // Note: when `this.something` is used in the rhs of these assignments, - // it refers to the default values set above the constructor - this.urls = this.normalizeUrlsCfg(cfg.urls); - this.email = typeof cfg.email === 'boolean' ? cfg.email : this.email; - this.phone = typeof cfg.phone === 'boolean' ? cfg.phone : this.phone; - this.hashtag = cfg.hashtag || this.hashtag; - this.mention = cfg.mention || this.mention; - this.newWindow = typeof cfg.newWindow === 'boolean' ? cfg.newWindow : this.newWindow; - this.stripPrefix = this.normalizeStripPrefixCfg(cfg.stripPrefix); - this.stripTrailingSlash = typeof cfg.stripTrailingSlash === 'boolean' ? cfg.stripTrailingSlash : this.stripTrailingSlash; - this.decodePercentEncoding = typeof cfg.decodePercentEncoding === 'boolean' ? cfg.decodePercentEncoding : this.decodePercentEncoding; - this.sanitizeHtml = cfg.sanitizeHtml || false; - // Validate the value of the `mention` cfg - var mention = this.mention; - if (mention !== false && mention !== 'mastodon' && mention !== 'twitter' && mention !== 'instagram' && mention !== 'soundcloud') { - throw new Error("invalid `mention` cfg - see docs"); - } - // Validate the value of the `hashtag` cfg - var hashtag = this.hashtag; - if (hashtag !== false && hashtag !== 'twitter' && hashtag !== 'facebook' && hashtag !== 'instagram') { - throw new Error("invalid `hashtag` cfg - see docs"); - } - this.truncate = this.normalizeTruncateCfg(cfg.truncate); - this.className = cfg.className || this.className; - this.replaceFn = cfg.replaceFn || this.replaceFn; - this.context = cfg.context || this; - } - /** - * Automatically links URLs, Email addresses, Phone Numbers, Twitter handles, - * Hashtags, and Mentions found in the given chunk of HTML. Does not link URLs - * found within HTML tags. - * - * For instance, if given the text: `You should go to http://www.yahoo.com`, - * then the result will be `You should go to <a href="http://www.yahoo.com">http://www.yahoo.com</a>` - * - * Example: - * - * var linkedText = Autolinker.link( "Go to google.com", { newWindow: false } ); - * // Produces: "Go to google.com" - * - * @static - * @param {String} textOrHtml The HTML or text to find matches within (depending - * on if the {@link #urls}, {@link #email}, {@link #phone}, {@link #mention}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @param {Object} [options] Any of the configuration options for the Autolinker - * class, specified in an Object (map). See the class description for an - * example call. - * @return {String} The HTML text, with matches automatically linked. - */ - Autolinker.link = function (textOrHtml, options) { - var autolinker = new Autolinker(options); - return autolinker.link(textOrHtml); - }; - /** - * Parses the input `textOrHtml` looking for URLs, email addresses, phone - * numbers, username handles, and hashtags (depending on the configuration - * of the Autolinker instance), and returns an array of {@link Autolinker.match.Match} - * objects describing those matches (without making any replacements). - * - * Note that if parsing multiple pieces of text, it is slightly more efficient - * to create an Autolinker instance, and use the instance-level {@link #parse} - * method. - * - * Example: - * - * var matches = Autolinker.parse( "Hello google.com, I am asdf@asdf.com", { - * urls: true, - * email: true - * } ); - * - * console.log( matches.length ); // 2 - * console.log( matches[ 0 ].getType() ); // 'url' - * console.log( matches[ 0 ].getUrl() ); // 'google.com' - * console.log( matches[ 1 ].getType() ); // 'email' - * console.log( matches[ 1 ].getEmail() ); // 'asdf@asdf.com' - * - * @static - * @param {String} textOrHtml The HTML or text to find matches within - * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @param {Object} [options] Any of the configuration options for the Autolinker - * class, specified in an Object (map). See the class description for an - * example call. - * @return {Autolinker.match.Match[]} The array of Matches found in the - * given input `textOrHtml`. - */ - Autolinker.parse = function (textOrHtml, options) { - var autolinker = new Autolinker(options); - return autolinker.parse(textOrHtml); - }; - /** - * Normalizes the {@link #urls} config into an Object with 3 properties: - * `schemeMatches`, `wwwMatches`, and `tldMatches`, all Booleans. - * - * See {@link #urls} config for details. - * - * @private - * @param {Boolean/Object} urls - * @return {Object} - */ - Autolinker.prototype.normalizeUrlsCfg = function (urls) { - if (urls == null) - urls = true; // default to `true` - if (typeof urls === 'boolean') { - return { schemeMatches: urls, wwwMatches: urls, tldMatches: urls }; - } - else { // object form - return { - schemeMatches: typeof urls.schemeMatches === 'boolean' ? urls.schemeMatches : true, - wwwMatches: typeof urls.wwwMatches === 'boolean' ? urls.wwwMatches : true, - tldMatches: typeof urls.tldMatches === 'boolean' ? urls.tldMatches : true - }; - } - }; - /** - * Normalizes the {@link #stripPrefix} config into an Object with 2 - * properties: `scheme`, and `www` - both Booleans. - * - * See {@link #stripPrefix} config for details. - * - * @private - * @param {Boolean/Object} stripPrefix - * @return {Object} - */ - Autolinker.prototype.normalizeStripPrefixCfg = function (stripPrefix) { - if (stripPrefix == null) - stripPrefix = true; // default to `true` - if (typeof stripPrefix === 'boolean') { - return { scheme: stripPrefix, www: stripPrefix }; - } - else { // object form - return { - scheme: typeof stripPrefix.scheme === 'boolean' ? stripPrefix.scheme : true, - www: typeof stripPrefix.www === 'boolean' ? stripPrefix.www : true - }; - } - }; - /** - * Normalizes the {@link #truncate} config into an Object with 2 properties: - * `length` (Number), and `location` (String). - * - * See {@link #truncate} config for details. - * - * @private - * @param {Number/Object} truncate - * @return {Object} - */ - Autolinker.prototype.normalizeTruncateCfg = function (truncate) { - if (typeof truncate === 'number') { - return { length: truncate, location: 'end' }; - } - else { // object, or undefined/null - return defaults(truncate || {}, { - length: Number.POSITIVE_INFINITY, - location: 'end' - }); - } - }; - /** - * Parses the input `textOrHtml` looking for URLs, email addresses, phone - * numbers, username handles, and hashtags (depending on the configuration - * of the Autolinker instance), and returns an array of {@link Autolinker.match.Match} - * objects describing those matches (without making any replacements). - * - * This method is used by the {@link #link} method, but can also be used to - * simply do parsing of the input in order to discover what kinds of links - * there are and how many. - * - * Example usage: - * - * var autolinker = new Autolinker( { - * urls: true, - * email: true - * } ); - * - * var matches = autolinker.parse( "Hello google.com, I am asdf@asdf.com" ); - * - * console.log( matches.length ); // 2 - * console.log( matches[ 0 ].getType() ); // 'url' - * console.log( matches[ 0 ].getUrl() ); // 'google.com' - * console.log( matches[ 1 ].getType() ); // 'email' - * console.log( matches[ 1 ].getEmail() ); // 'asdf@asdf.com' - * - * @param {String} textOrHtml The HTML or text to find matches within - * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, - * {@link #hashtag}, and {@link #mention} options are enabled). - * @return {Autolinker.match.Match[]} The array of Matches found in the - * given input `textOrHtml`. - */ - Autolinker.prototype.parse = function (textOrHtml) { - var _this = this; - var skipTagNames = ['a', 'style', 'script'], skipTagsStackCount = 0, // used to only Autolink text outside of anchor/script/style tags. We don't want to autolink something that is already linked inside of an tag, for instance - matches = []; - // Find all matches within the `textOrHtml` (but not matches that are - // already nested within ,