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 [];
+};