From bcb78c499c22c7d9806b6ead80a14e8df3533936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Tue, 22 Feb 2022 11:30:22 +0100 Subject: [PATCH] [Gekidou] [MM-39682] Add autocomplete and emoji suggestion (#5931) * Add autocomplete and emoji suggestion * Address feedback and minor fixes * Remove unneeded constants and fix max height to be aware of the header. * Address feedback * Refactor PostDraft and correctly position Autocomplete * Set min char for emoji search at 2 * Replace searchCustomEmojis from setTimeout to debounce * Remove comments * simplify prop * Change naming of certain variables * remove unnecesary fragment * Improve Autocomplete height calculation * Address feedback Co-authored-by: Elias Nahum --- app/components/autocomplete/autocomplete.tsx | 224 ++++++++++++++++++ .../emoji_suggestion/emoji_suggestion.tsx | 220 +++++++++++++++++ .../autocomplete/emoji_suggestion/index.ts | 43 ++++ app/components/autocomplete/index.ts | 22 ++ .../emoji_picker/filtered/index.tsx | 43 +--- .../cursor_position_handler/index.tsx | 37 --- .../draft_handler/draft_handler.tsx | 22 +- .../post_draft/draft_handler/index.ts | 20 +- .../post_draft/draft_input/index.tsx | 27 +-- app/components/post_draft/index.ts | 19 +- app/components/post_draft/post_draft.tsx | 64 ++++- .../post_draft/send_handler/send_handler.tsx | 18 +- app/utils/emoji/helpers.ts | 47 +++- 13 files changed, 663 insertions(+), 143 deletions(-) create mode 100644 app/components/autocomplete/autocomplete.tsx create mode 100644 app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx create mode 100644 app/components/autocomplete/emoji_suggestion/index.ts create mode 100644 app/components/autocomplete/index.ts delete mode 100644 app/components/post_draft/cursor_position_handler/index.tsx diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx new file mode 100644 index 000000000..0bc196f31 --- /dev/null +++ b/app/components/autocomplete/autocomplete.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo, useState} from 'react'; +import {Keyboard, KeyboardEvent, Platform, useWindowDimensions, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import useHeaderHeight from '@hooks/header'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import EmojiSuggestion from './emoji_suggestion/'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + base: { + left: 8, + position: 'absolute', + right: 8, + }, + borders: { + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + overflow: 'hidden', + borderRadius: 4, + }, + hidden: { + display: 'none', + }, + searchContainer: { + ...Platform.select({ + android: { + top: 42, + }, + ios: { + top: 55, + }, + }), + }, + shadow: { + shadowColor: '#000', + shadowOpacity: 0.12, + shadowRadius: 8, + shadowOffset: { + width: 0, + height: 8, + }, + }, + }; +}); + +type Props = { + cursorPosition: number; + postInputTop: number; + rootId: string; + channelId: string; + isSearch?: boolean; + value: string; + enableDateSuggestion?: boolean; + isAppsEnabled: boolean; + offsetY?: number; + nestedScrollEnabled?: boolean; + updateValue: (v: string) => void; + hasFilesAttached: boolean; +} + +const OFFSET_IPAD = 60; +const AUTOCOMPLETE_MARGIN = 20; + +const Autocomplete = ({ + cursorPosition, + postInputTop, + rootId, + + //channelId, + isSearch = false, + value, + + //enableDateSuggestion = false, + isAppsEnabled, + offsetY = 60, + nestedScrollEnabled = false, + updateValue, + hasFilesAttached, +}: Props) => { + const theme = useTheme(); + const isTablet = useIsTablet(); + const style = getStyleFromTheme(theme); + const {height: deviceHeight} = useWindowDimensions(); + const {defaultHeight: headerHeight} = useHeaderHeight(false, true, false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + // const [showingAtMention, setShowingAtMention] = useState(false); + // const [showingChannelMention, setShowingChannelMention] = useState(false); + const [showingEmoji, setShowingEmoji] = useState(false); + + // const [showingCommand, setShowingCommand] = useState(false); + // const [showingAppCommand, setShowingAppCommand] = useState(false); + // const [showingDate, setShowingDate] = useState(false); + + const hasElements = showingEmoji; // || showingAtMention || showingChannelMention || showingCommand || showingAppCommand || showingDate; + const appsTakeOver = false; // showingAppCommand; + + const maxListHeight = useMemo(() => { + const postInputHeight = deviceHeight - postInputTop; + let offset = 0; + if (Platform.OS === 'ios' && isTablet) { + offset = OFFSET_IPAD; + } + + if (keyboardHeight) { + return (deviceHeight - (postInputHeight + headerHeight + AUTOCOMPLETE_MARGIN + offset)); + } + return (deviceHeight - (postInputHeight + headerHeight + AUTOCOMPLETE_MARGIN + offset)) / 2; + }, [postInputTop, deviceHeight, headerHeight, isTablet]); // We don't depend on keyboardHeight to avoid visual artifacts due to postInputTop and keyboardHeight not being updated in the same render. + + const wrapperStyles = useMemo(() => { + const s = []; + if (Platform.OS === 'ios') { + s.push(style.shadow); + } + + if (isSearch) { + s.push(style.base, style.searchContainer, {height: maxListHeight}); + } + + if (!hasElements) { + s.push(style.hidden); + } + return s; + }, [style, isSearch && maxListHeight, hasElements]); + + const containerStyles = useMemo(() => { + const s = [style.borders]; + if (!isSearch) { + s.push(style.base, {bottom: offsetY}); + } + if (!hasElements) { + s.push(style.hidden); + } + return s; + }, [!isSearch && offsetY, hasElements]); + + useEffect(() => { + const keyboardEvent = (event: KeyboardEvent) => { + setKeyboardHeight(event.endCoordinates.height); + }; + const shown = Keyboard.addListener('keyboardDidShow', keyboardEvent); + const hidden = Keyboard.addListener('keyboardDidHide', keyboardEvent); + + return () => { + shown.remove(); + hidden.remove(); + }; + }, []); + + return ( + + + {/* {isAppsEnabled && ( + + )} */} + {(!appsTakeOver || !isAppsEnabled) && (<> + {/* + */} + {!isSearch && + + } + {/* + {(isSearch && enableDateSuggestion) && + + } */} + )} + + + ); +}; + +export default Autocomplete; diff --git a/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx b/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx new file mode 100644 index 000000000..f0eaf8653 --- /dev/null +++ b/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx @@ -0,0 +1,220 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Fuse from 'fuse.js'; +import {debounce} from 'lodash'; +import React, {useCallback, useEffect, useMemo} from 'react'; +import {FlatList, Platform, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {searchCustomEmojis} from '@actions/remote/custom_emoji'; +import {handleReactionToLatestPost} from '@actions/remote/reactions'; +import Emoji from '@components/emoji'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {getEmojiByName, getEmojis, searchEmojis} from '@utils/emoji/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; + +const EMOJI_REGEX = /(^|\s|^\+|^-)(:([^:\s]*))$/i; +const EMOJI_REGEX_WITHOUT_PREFIX = /\B(:([^:\s]*))$/i; +const REACTION_REGEX = /^(\+|-):([^:\s]+)$/; +const FUSE_OPTIONS = { + findAllMatches: true, + ignoreLocation: true, + includeMatches: true, + shouldSort: false, + includeScore: true, +}; + +const EMOJI_SIZE = 24; +const MIN_SEARCH_LENGTH = 2; +const SEARCH_DELAY = 500; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + emoji: { + marginRight: 5, + }, + emojiName: { + fontSize: 15, + color: theme.centerChannelColor, + }, + emojiText: { + color: '#000', + fontWeight: 'bold', + }, + listView: { + paddingTop: 16, + backgroundColor: theme.centerChannelBg, + borderRadius: 4, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + overflow: 'hidden', + paddingBottom: 8, + paddingHorizontal: 16, + height: 40, + }, + }; +}); + +const keyExtractor = (item: string) => item; + +type Props = { + cursorPosition: number; + customEmojis: CustomEmojiModel[]; + maxListHeight: number; + updateValue: (v: string) => void; + onShowingChange: (c: boolean) => void; + rootId: string; + value: string; + nestedScrollEnabled: boolean; + skinTone: string; + hasFilesAttached: boolean; +} +const EmojiSuggestion = ({ + cursorPosition, + customEmojis = [], + maxListHeight, + updateValue, + onShowingChange, + rootId, + value, + nestedScrollEnabled, + skinTone, + hasFilesAttached, +}: Props) => { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + const flatListStyle = useMemo(() => + [style.listView, {maxHeight: maxListHeight}] + , [style, maxListHeight]); + const containerStyle = useMemo(() => + ({paddingBottom: insets.bottom + 12}) + , [insets.bottom]); + + const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]); + + const searchTerm = useMemo(() => { + const match = value.substring(0, cursorPosition).match(EMOJI_REGEX); + return match?.[3] || ''; + }, [value, cursorPosition]); + + const fuse = useMemo(() => { + return new Fuse(emojis, FUSE_OPTIONS); + }, [emojis]); + + const data = useMemo(() => { + if (searchTerm.length < MIN_SEARCH_LENGTH) { + return []; + } + + return searchEmojis(fuse, searchTerm); + }, [fuse, searchTerm]); + + const showingElements = Boolean(data.length); + + const completeSuggestion = useCallback((emoji: string) => { + if (!hasFilesAttached) { + const match = value.match(REACTION_REGEX); + if (match) { + handleReactionToLatestPost(serverUrl, emoji, match[1] === '+', rootId); + updateValue(''); + return; + } + } + + // We are going to set a double : on iOS to prevent the auto correct from taking over and replacing it + // with the wrong value, this is a hack but I could not found another way to solve it + let completedDraft: string; + let prefix = ':'; + if (Platform.OS === 'ios') { + prefix = '::'; + } + + const emojiPart = value.substring(0, cursorPosition); + const emojiData = getEmojiByName(emoji, customEmojis); + if (emojiData?.image && emojiData.category !== 'custom') { + const codeArray: string[] = emojiData.image.split('-'); + const code = codeArray.reduce((acc, c) => { + return acc + String.fromCodePoint(parseInt(c, 16)); + }, ''); + completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, `${code} `); + } else { + completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, `${prefix}${emoji}: `); + } + + if (value.length > cursorPosition) { + completedDraft += value.substring(cursorPosition); + } + + updateValue(completedDraft); + + if (Platform.OS === 'ios' && (!emojiData?.filename || emojiData.category !== 'custom')) { + // This is the second part of the hack were we replace the double : with just one + // after the auto correct vanished + setTimeout(() => { + updateValue(completedDraft.replace(`::${emoji}: `, `:${emoji}: `)); + }); + } + }, [value, updateValue, rootId, cursorPosition, hasFilesAttached]); + + const renderItem = useCallback(({item}: {item: string}) => { + const completeItemSuggestion = () => completeSuggestion(item); + return ( + + + + + + {`:${item}:`} + + + ); + }, [completeSuggestion, theme.buttonBg, style]); + + useEffect(() => { + onShowingChange(showingElements); + }, [showingElements]); + + useEffect(() => { + const search = debounce(() => searchCustomEmojis(serverUrl, searchTerm), SEARCH_DELAY); + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + search(); + } + + return () => { + search.cancel(); + }; + }, [searchTerm]); + + return ( + + ); +}; + +export default EmojiSuggestion; diff --git a/app/components/autocomplete/emoji_suggestion/index.ts b/app/components/autocomplete/emoji_suggestion/index.ts new file mode 100644 index 000000000..a800d4e4e --- /dev/null +++ b/app/components/autocomplete/emoji_suggestion/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import EmojiSuggestion from './emoji_suggestion'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {SYSTEM, CUSTOM_EMOJI, PREFERENCE}} = MM_TABLES; +const emptyEmojiList: CustomEmojiModel[] = []; +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const isCustomEmojisEnabled = database.get(SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.EnableCustomEmoji === 'true')), + ); + return { + customEmojis: isCustomEmojisEnabled.pipe( + switchMap((enabled) => (enabled ? + database.get(CUSTOM_EMOJI).query().observe() : + of$(emptyEmojiList)), + ), + ), + skinTone: database.get(PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_EMOJI), + Q.where('name', Preferences.EMOJI_SKINTONE), + ).observe().pipe( + switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), + ), + }; +}); + +export default withDatabase(enhanced(EmojiSuggestion)); diff --git a/app/components/autocomplete/index.ts b/app/components/autocomplete/index.ts new file mode 100644 index 000000000..ab5224b7a --- /dev/null +++ b/app/components/autocomplete/index.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import Autocomplete from './autocomplete'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + isAppsEnabled: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')), + ), +})); + +export default withDatabase(enhanced(Autocomplete)); diff --git a/app/components/emoji_picker/filtered/index.tsx b/app/components/emoji_picker/filtered/index.tsx index a3e5c6bcd..138ffd0a3 100644 --- a/app/components/emoji_picker/filtered/index.tsx +++ b/app/components/emoji_picker/filtered/index.tsx @@ -5,8 +5,7 @@ import Fuse from 'fuse.js'; import React, {useCallback, useMemo} from 'react'; import {FlatList} from 'react-native'; -import {Emojis, EmojiIndicesByAlias} from '@utils/emoji'; -import {compareEmojis, getSkin} from '@utils/emoji/helpers'; +import {getEmojis, searchEmojis} from '@utils/emoji/helpers'; import EmojiItem from './emoji_item'; import NoResults from './no_results'; @@ -21,21 +20,7 @@ type Props = { }; const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props) => { - const emojis = useMemo(() => { - const emoticons = new Set(); - for (const [key, index] of EmojiIndicesByAlias.entries()) { - const skin = getSkin(Emojis[index]); - if (!skin || skin === skinTone) { - emoticons.add(key); - } - } - - for (const custom of customEmojis) { - emoticons.add(custom.name); - } - - return Array.from(emoticons); - }, [skinTone, customEmojis]); + const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]); const fuse = useMemo(() => { const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true}; @@ -43,33 +28,11 @@ const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props }, [emojis]); const data = useMemo(() => { - const searchTermLowerCase = searchTerm.toLowerCase(); - if (!searchTerm) { return []; } - const sorter = (a: string, b: string) => { - return compareEmojis(a, b, searchTermLowerCase); - }; - - const fuzz = fuse.search(searchTermLowerCase); - - if (fuzz) { - const results = fuzz.reduce((values, r) => { - const score = r?.score === undefined ? 1 : r.score; - const v = r?.matches?.[0]?.value; - if (score < 0.2 && v) { - values.push(v); - } - - return values; - }, [] as string[]); - - return results.sort(sorter); - } - - return []; + return searchEmojis(fuse, searchTerm); }, [fuse, searchTerm]); const keyExtractor = useCallback((item) => item, []); diff --git a/app/components/post_draft/cursor_position_handler/index.tsx b/app/components/post_draft/cursor_position_handler/index.tsx deleted file mode 100644 index 08c09a957..000000000 --- a/app/components/post_draft/cursor_position_handler/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useState} from 'react'; - -import DraftInput from '../draft_input'; - -type Props = { - testID?: string; - channelId: string; - rootId: string; - - // Send Handler - sendMessage: () => void; - maxMessageLength: number; - canSend: boolean; - - // Draft Handler - value: string; - uploadFileError: React.ReactNode; - files: FileInfo[]; - clearDraft: () => void; - updateValue: (value: string) => void; - addFiles: (files: FileInfo[]) => void; -} - -export default function CursorPositionHandler(props: Props) { - const [pos, setCursorPosition] = useState(0); - - return ( - - ); -} diff --git a/app/components/post_draft/draft_handler/draft_handler.tsx b/app/components/post_draft/draft_handler/draft_handler.tsx index c3a1e4b0a..1a024e06f 100644 --- a/app/components/post_draft/draft_handler/draft_handler.tsx +++ b/app/components/post_draft/draft_handler/draft_handler.tsx @@ -14,12 +14,16 @@ import SendHandler from '../send_handler'; type Props = { testID?: string; channelId: string; + cursorPosition: number; rootId?: string; files?: FileInfo[]; - message?: string; maxFileSize: number; maxFileCount: number; canUploadFiles: boolean; + updateCursorPosition: (cursorPosition: number) => void; + updatePostInputTop: (top: number) => void; + updateValue: (value: string) => void; + value: string; } const emptyFileList: FileInfo[] = []; @@ -33,18 +37,21 @@ export default function DraftHandler(props: Props) { const { testID, channelId, + cursorPosition, rootId = '', files, - message, maxFileSize, maxFileCount, canUploadFiles, + updateCursorPosition, + updatePostInputTop, + updateValue, + value, } = props; const serverUrl = useServerUrl(); const intl = useIntl(); - const [currentValue, setCurrentValue] = useState(message || ''); const [uploadError, setUploadError] = useState(null); const uploadErrorTimeout = useRef(); @@ -52,7 +59,7 @@ export default function DraftHandler(props: Props) { const clearDraft = useCallback(() => { removeDraft(serverUrl, channelId, rootId); - setCurrentValue(''); + updateValue(''); }, [serverUrl, channelId, rootId]); const newUploadError = useCallback((error: React.ReactNode) => { @@ -128,12 +135,15 @@ export default function DraftHandler(props: Props) { rootId={rootId} // From draft handler - value={currentValue} + cursorPosition={cursorPosition} + value={value} files={files || emptyFileList} clearDraft={clearDraft} - updateValue={setCurrentValue} addFiles={addFiles} uploadFileError={uploadError} + updateCursorPosition={updateCursorPosition} + updatePostInputTop={updatePostInputTop} + updateValue={updateValue} /> ); } diff --git a/app/components/post_draft/draft_handler/index.ts b/app/components/post_draft/draft_handler/index.ts index 3de779cf2..c2738f9ea 100644 --- a/app/components/post_draft/draft_handler/index.ts +++ b/app/components/post_draft/draft_handler/index.ts @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; @@ -14,24 +13,11 @@ import {isMinimumServerVersion} from '@utils/helpers'; import DraftHandler from './draft_handler'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type DraftModel from '@typings/database/models/servers/draft'; import type SystemModel from '@typings/database/models/servers/system'; -const {SERVER: {SYSTEM, DRAFT}} = MM_TABLES; - -type OwnProps = { - channelId: string; - rootId?: string; -} -const enhanced = withObservables([], ({database, channelId, rootId = ''}: WithDatabaseArgs & OwnProps) => { - const draft = database.get(DRAFT).query( - Q.where('channel_id', channelId), - Q.where('root_id', rootId), - ).observeWithColumns(['message', 'files']).pipe(switchMap((v) => of$(v[0]))); - - const files = draft.pipe(switchMap((d) => of$(d?.files))); - const message = draft.pipe(switchMap((d) => of$(d?.message))); +const {SERVER: {SYSTEM}} = MM_TABLES; +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( switchMap(({value}) => of$(value as ClientConfig)), ); @@ -57,8 +43,6 @@ const enhanced = withObservables([], ({database, channelId, rootId = ''}: WithDa ); return { - files, - message, maxFileSize, maxFileCount, canUploadFiles, diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx index bdc221557..3fa4a5255 100644 --- a/app/components/post_draft/draft_input/index.tsx +++ b/app/components/post_draft/draft_input/index.tsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Platform, ScrollView, View} from 'react-native'; +import React, {useCallback} from 'react'; +import {LayoutChangeEvent, Platform, ScrollView, View} from 'react-native'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; import {useTheme} from '@context/theme'; @@ -34,6 +34,7 @@ type Props = { uploadFileError: React.ReactNode; updateValue: (value: string) => void; addFiles: (files: FileInfo[]) => void; + updatePostInputTop: (top: number) => void; } const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right']; @@ -87,14 +88,13 @@ export default function DraftInput({ addFiles, updateCursorPosition, cursorPosition, + updatePostInputTop, }: Props) { const theme = useTheme(); - // const [top, setTop] = useState(0); - - // const handleLayout = useCallback((e: LayoutChangeEvent) => { - // setTop(e.nativeEvent.layout.y); - // }, []); + const handleLayout = useCallback((e: LayoutChangeEvent) => { + updatePostInputTop(e.nativeEvent.layout.y); + }, []); // Render const postInputTestID = `${testID}.post.input`; @@ -108,22 +108,13 @@ export default function DraftInput({ channelId={channelId} rootId={rootId} /> - {/* {Platform.OS === 'android' && - - } */} + { - const database = ownProps.database; + const {database, rootId = ''} = ownProps; + const draft = database.get(DRAFT).query( + Q.where('channel_id', ownProps.channelId), + Q.where('root_id', rootId), + ).observeWithColumns(['message', 'files']).pipe(switchMap((v) => of$(v[0]))); + + const files = draft.pipe(switchMap((d) => of$(d?.files))); + const message = draft.pipe(switchMap((d) => of$(d?.message))); + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( switchMap(({value}) => database.get(USER).findAndObserve(value)), ); @@ -72,6 +83,8 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => channelIsArchived, channelIsReadOnly, deactivatedChannel, + files, + message, }; }); diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx index 4b927b1af..8b4412810 100644 --- a/app/components/post_draft/post_draft.tsx +++ b/app/components/post_draft/post_draft.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef} from 'react'; -import {DeviceEventEmitter, Platform} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter, Platform, View} from 'react-native'; import {KeyboardTrackingView, KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; +import Autocomplete from '@components/autocomplete'; import {PostDraft as PostDraftConstants, View as ViewConstants} from '@constants'; import {useIsTablet} from '@hooks/device'; @@ -20,6 +21,9 @@ type Props = { channelIsArchived?: boolean; channelIsReadOnly: boolean; deactivatedChannel: boolean; + files?: FileInfo[]; + isSearch?: boolean; + message?: string; rootId?: string; scrollViewNativeID?: string; } @@ -32,11 +36,17 @@ export default function PostDraft({ channelIsArchived, channelIsReadOnly, deactivatedChannel, - rootId, + files, + isSearch, + message = '', + rootId = '', scrollViewNativeID, }: Props) { const keyboardTracker = useRef(null); const resetScrollViewAnimationFrame = useRef(); + const [value, setValue] = useState(message); + const [cursorPosition, setCursorPosition] = useState(message.length); + const [postInputTop, setPostInputTop] = useState(0); const isTablet = useIsTablet(); const updateNativeScrollView = useCallback((scrollViewNativeIDToUpdate: string) => { @@ -86,22 +96,52 @@ export default function PostDraft({ + ); + + const autoComplete = ( + ); if (Platform.OS === 'android') { - return draftHandler; + return ( + <> + {autoComplete} + {draftHandler} + + ); } return ( - - {draftHandler} - + <> + + {autoComplete} + + + {draftHandler} + + ); } diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index 27fe7694c..18fa362f9 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -19,7 +19,7 @@ import {isReactionMatch} from '@utils/emoji/helpers'; import {preventDoubleTap} from '@utils/tap'; import {confirmOutOfOfficeDisabled} from '@utils/user'; -import CursorPositionHandler from '../cursor_position_handler'; +import DraftInput from '../draft_input'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; import type GroupModel from '@typings/database/models/servers/group'; @@ -31,6 +31,7 @@ type Props = { // From database currentUserId: string; + cursorPosition: number; enableConfirmNotificationsToChannel?: boolean; isTimezoneEnabled: boolean; maxMessageLength: number; @@ -46,6 +47,8 @@ type Props = { files: FileInfo[]; clearDraft: () => void; updateValue: (message: string) => void; + updateCursorPosition: (cursorPosition: number) => void; + updatePostInputTop: (top: number) => void; addFiles: (file: FileInfo[]) => void; uploadFileError: React.ReactNode; } @@ -59,6 +62,7 @@ export default function SendHandler({ isTimezoneEnabled, maxMessageLength, membersCount = 0, + cursorPosition, rootId, useChannelMentions, userIsOutOfOffice, @@ -70,6 +74,8 @@ export default function SendHandler({ updateValue, addFiles, uploadFileError, + updateCursorPosition, + updatePostInputTop, }: Props) { const intl = useIntl(); const serverUrl = useServerUrl(); @@ -264,23 +270,21 @@ export default function SendHandler({ }, [serverUrl, channelId]); return ( - ); } diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index 746112639..0fb435f9b 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import emojiRegex from 'emoji-regex'; +import Fuse from 'fuse.js'; import SystemModel from '@database/models/server/system'; @@ -192,12 +193,12 @@ export function doesMatchNamedEmoji(emojiName: string) { return false; } -export function getEmojiByName(emojiName: string) { +export function getEmojiByName(emojiName: string, customEmojis: CustomEmojiModel[]) { if (EmojiIndicesByAlias.has(emojiName)) { return Emojis[EmojiIndicesByAlias.get(emojiName)!]; } - return null; + return customEmojis.find((e) => e.name === emojiName); } // Since there is no shared logic between the web and mobile app @@ -311,3 +312,45 @@ export function getSkin(emoji: any) { } return null; } + +export const getEmojis = (skinTone: string, customEmojis: CustomEmojiModel[]) => { + const emoticons = new Set(); + for (const [key, index] of EmojiIndicesByAlias.entries()) { + const skin = getSkin(Emojis[index]); + if (!skin || skin === skinTone) { + emoticons.add(key); + } + } + + for (const custom of customEmojis) { + emoticons.add(custom.name); + } + + return Array.from(emoticons); +}; + +export const searchEmojis = (fuse: Fuse, searchTerm: string) => { + const searchTermLowerCase = searchTerm.toLowerCase(); + + const sorter = (a: string, b: string) => { + return compareEmojis(a, b, searchTermLowerCase); + }; + + const fuzz = fuse.search(searchTermLowerCase); + + if (fuzz) { + const results = fuzz.reduce((values, r) => { + const score = r?.score === undefined ? 1 : r.score; + const v = r?.matches?.[0]?.value; + if (score < 0.2 && v) { + values.push(v); + } + + return values; + }, [] as string[]); + + return results.sort(sorter); + } + + return []; +};