diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-13 16:01:43 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-13 16:01:43 -0500 |
commit | 56cf890debeb9872f791ccb992a5587f2c05fd9e (patch) | |
tree | 929453b41274a712d8b2fce441e98a0cd030d305 /src/view/com/composer/text-input | |
parent | 503e03d91e1de4bfeabec1eb2d97dcdceb13fcc5 (diff) | |
download | voidsky-56cf890debeb9872f791ccb992a5587f2c05fd9e.tar.zst |
Move to expo and react-navigation (#288)
* WIP - adding expo * WIP - adding expo 2 * Fix tsc * Finish adding expo * Disable the 'require cycle' warning * Tweak plist * Modify some dependency versions to make expo happy * Fix icon fill * Get Web compiling for expo * 1.7 * Switch to react-navigation in expo2 (#287) * WIP Switch to react-navigation * WIP Switch to react-navigation 2 * WIP Switch to react-navigation 3 * Convert all screens to react navigation * Update BottomBar for react navigation * Update mobile menu to be react-native drawer * Fixes to drawer and bottombar * Factor out some helpers * Replace the navigation model with react-navigation * Restructure the shell folder and fix the header positioning * Restore the error boundary * Fix tsc * Implement not-found page * Remove react-native-gesture-handler (no longer used) * Handle notifee card presses * Handle all navigations from the state layer * Fix drawer behaviors * Fix two linking issues * Switch to our react-native-progress fork to fix an svg rendering issue * Get Web working with react-navigation * Refactor routes and navigation for a bit more clarity * Remove dead code * Rework Web shell to left/right nav to make this easier * Fix ViewHeader for desktop web * Hide profileheader back btn on desktop web * Move the compose button to the left nav * Implement reply prompt in threads for desktop web * Composer refactors * Factor out all platform-specific text input behaviors from the composer * Small fix * Update the web build to use tiptap for the composer * Tune up the mention autocomplete dropdown * Simplify the default avatar and banner * Fixes to link cards in web composer * Fix dropdowns on web * Tweak load latest on desktop * Add web beta message and feedback link * Fix up links in desktop web
Diffstat (limited to 'src/view/com/composer/text-input')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 252 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 169 | ||||
-rw-r--r-- | src/view/com/composer/text-input/mobile/Autocomplete.tsx | 75 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/Autocomplete.tsx | 157 |
4 files changed, 559 insertions, 94 deletions
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index be6150e11..2a40fb518 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,64 +1,222 @@ import React from 'react' import { NativeSyntheticEvent, - StyleProp, + StyleSheet, TextInputSelectionChangeEventData, - TextStyle, } from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {Autocomplete} from './mobile/Autocomplete' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getImageDim} from 'lib/media/manip' +import {cropAndCompressFlow} from 'lib/media/picker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' -export type TextInputRef = PasteInputRef +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - <PasteInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </PasteInput> - ) +interface Selection { + start: number + end: number } + +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + onPhotoPasted, + onSuggestedLinksChanged, + onError, + }: TextInputProps, + ref, + ) => { + const pal = usePalette('default') + const store = useStores() + const textInput = React.useRef<PasteInputRef>(null) + const textInputSelection = React.useRef<Selection>({start: 0, end: 0}) + const theme = useTheme() + + React.useImperativeHandle(ref, () => ({ + focus: () => textInput.current?.focus(), + blur: () => textInput.current?.blur(), + })) + + React.useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onChangeText = React.useCallback( + (newText: string) => { + onTextChanged(newText) + + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix.value) + } else { + autocompleteView.setActive(false) + } + + const ents = extractEntities(newText)?.filter( + ent => ent.type === 'link', + ) + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) + } + }, + [ + onTextChanged, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + ], + ) + + const onPaste = React.useCallback( + async (err: string | undefined, files: PastedFile[]) => { + if (err) { + return onError(cleanError(err)) + } + const uris = files.map(f => f.uri) + const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) + if (imgUri) { + let imgDim + try { + imgDim = await getImageDim(imgUri) + } catch (e) { + imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + } + const finalImgPath = await cropAndCompressFlow( + store, + imgUri, + imgDim, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(finalImgPath) + } + }, + [store, onError, onPhotoPasted], + ) + + const onSelectionChange = React.useCallback( + (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + }, + [textInputSelection], + ) + + const onSelectAutocompleteItem = React.useCallback( + (item: string) => { + onChangeText( + insertMentionAt(text, textInputSelection.current?.start || 0, item), + ) + autocompleteView.setActive(false) + }, + [onChangeText, text, autocompleteView], + ) + + const textDecorated = React.useMemo(() => { + let i = 0 + return detectLinkables(text).map(v => { + if (typeof v === 'string') { + return ( + <Text key={i++} style={[pal.text, styles.textInputFormatting]}> + {v} + </Text> + ) + } else { + return ( + <Text key={i++} style={[pal.link, styles.textInputFormatting]}> + {v.link} + </Text> + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + <PasteInput + testID="composerTextInput" + ref={textInput} + onChangeText={onChangeText} + onPaste={onPaste} + onSelectionChange={onSelectionChange} + placeholder={placeholder} + keyboardAppearance={theme.colorScheme} + style={[pal.text, styles.textInput, styles.textInputFormatting]}> + {textDecorated} + </PasteInput> + <Autocomplete + view={autocompleteView} + onSelect={onSelectAutocompleteItem} + /> + </> + ) + }, +) + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + padding: 5, + marginLeft: 8, + alignSelf: 'flex-start', + }, + textInputFormatting: { + fontSize: 18, + letterSpacing: 0.2, + fontWeight: '400', + lineHeight: 23.4, // 1.3*16 + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 2b610850c..67ef836a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,58 +1,133 @@ import React from 'react' -import { - NativeSyntheticEvent, - StyleProp, - StyleSheet, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, - TextStyle, -} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {addStyle} from 'lib/styles' - -export type TextInputRef = RNTextInput +import {StyleSheet, View} from 'react-native' +import {useEditor, EditorContent, JSONContent} from '@tiptap/react' +import {Document} from '@tiptap/extension-document' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' +import {Paragraph} from '@tiptap/extension-paragraph' +import {Placeholder} from '@tiptap/extension-placeholder' +import {Text} from '@tiptap/extension-text' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {createSuggestion} from './web/Autocomplete' + +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - <RNTextInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </RNTextInput> - ) +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + // onPhotoPasted, TODO + onSuggestedLinksChanged, + }: // onError, TODO + TextInputProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + Link.configure({ + protocols: ['http', 'https'], + autolink: true, + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + ], + content: text, + autofocus: true, + editable: true, + injectCSS: true, + onUpdate({editor: editorProp}) { + const json = editorProp.getJSON() + const newText = editorJsonToText(json).trim() + onTextChanged(newText) + + const newSuggestedLinks = new Set(editorJsonToLinks(json)) + if (!isEqual(newSuggestedLinks, suggestedLinks)) { + onSuggestedLinksChanged(newSuggestedLinks) + } + }, + }) + + React.useImperativeHandle(ref, () => ({ + focus: () => {}, // TODO + blur: () => {}, // TODO + })) + + return ( + <View style={styles.container}> + <EditorContent editor={editor} /> + </View> + ) + }, +) + +function editorJsonToText(json: JSONContent): string { + let text = '' + if (json.type === 'doc' || json.type === 'paragraph') { + if (json.content?.length) { + for (const node of json.content) { + text += editorJsonToText(node) + } + } + text += '\n' + } else if (json.type === 'text') { + text += json.text || '' + } else if (json.type === 'mention') { + text += json.attrs?.id || '' + } + return text +} + +function editorJsonToLinks(json: JSONContent): string[] { + let links: string[] = [] + if (json.content?.length) { + for (const node of json.content) { + links = links.concat(editorJsonToLinks(node)) + } + } + + const link = json.marks?.find(m => m.type === 'link') + if (link?.attrs?.href) { + links.push(link.attrs.href) + } + + return links } const styles = StyleSheet.create({ - input: { - minHeight: 140, + container: { + flex: 1, + alignSelf: 'flex-start', + padding: 5, + marginLeft: 8, + marginBottom: 10, }, }) diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx new file mode 100644 index 000000000..424a8629f --- /dev/null +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -0,0 +1,75 @@ +import React, {useEffect} from 'react' +import { + Animated, + TouchableOpacity, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export const Autocomplete = observer( + ({ + view, + onSelect, + }: { + view: UserAutocompleteViewModel + onSelect: (item: string) => void + }) => { + const pal = usePalette('default') + const winDim = useWindowDimensions() + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [positionInterp, view.isActive]) + + const topAnimStyle = { + top: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winDim.height, winDim.height / 4], + }), + } + return ( + <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> + {view.suggestions.map(item => ( + <TouchableOpacity + testID="autocompleteButton" + key={item.handle} + style={[pal.border, styles.item]} + onPress={() => onSelect(item.handle)}> + <Text type="md-medium" style={pal.text}> + {item.displayName || item.handle} + <Text type="sm" style={pal.textLight}> + @{item.handle} + </Text> + </Text> + </TouchableOpacity> + ))} + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 16, + paddingHorizontal: 16, + height: 50, + }, +}) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx new file mode 100644 index 000000000..fbe438969 --- /dev/null +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -0,0 +1,157 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' + +interface MentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createSuggestion({ + autocompleteView, +}: { + autocompleteView: UserAutocompleteViewModel +}): Omit<SuggestionOptions, 'editor'> { + return { + async items({query}) { + autocompleteView.setActive(true) + await autocompleteView.setPrefix(query) + return autocompleteView.suggestions.slice(0, 8).map(s => s.handle) + }, + + render: () => { + let component: ReactRenderer<MentionListRef> | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const MentionList = forwardRef<MentionListRef, SuggestionProps>( + (props: SuggestionProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({id: item}) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( + <div className="items"> + {props.items.length ? ( + props.items.map((item, index) => ( + <button + className={`item ${index === selectedIndex ? 'is-selected' : ''}`} + key={index} + onClick={() => selectItem(index)}> + {item} + </button> + )) + ) : ( + <div className="item">No result</div> + )} + </div> + ) + }, +) |