From 56cf890debeb9872f791ccb992a5587f2c05fd9e Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Mar 2023 16:01:43 -0500 Subject: 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 --- src/view/com/composer/text-input/TextInput.tsx | 252 +++++++++++++++++---- src/view/com/composer/text-input/TextInput.web.tsx | 169 ++++++++++---- .../composer/text-input/mobile/Autocomplete.tsx | 75 ++++++ .../com/composer/text-input/web/Autocomplete.tsx | 157 +++++++++++++ 4 files changed, 559 insertions(+), 94 deletions(-) create mode 100644 src/view/com/composer/text-input/mobile/Autocomplete.tsx create mode 100644 src/view/com/composer/text-input/web/Autocomplete.tsx (limited to 'src/view/com/composer/text-input') 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 + text: string placeholder: string - style: StyleProp - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - - ) +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(null) + const textInputSelection = React.useRef({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) => { + // 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 ( + + {v} + + ) + } else { + return ( + + {v.link} + + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + + {textDecorated} + + + + ) + }, +) + +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 + text: string placeholder: string - style: StyleProp - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - - ) +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 ( + + + + ) + }, +) + +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 ( + + {view.suggestions.map(item => ( + onSelect(item.handle)}> + + {item.displayName || item.handle} + +  @{item.handle} + + + + ))} + + ) + }, +) + +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 { + 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 | 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( + (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 ( +
+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ) + }, +) -- cgit 1.4.1