[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 <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-02-22 11:30:22 +01:00
committed by GitHub
parent 39e5c2c07a
commit bcb78c499c
13 changed files with 663 additions and 143 deletions

View File

@@ -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 (
<View
style={wrapperStyles}
>
<View
testID='autocomplete'
style={containerStyles}
>
{/* {isAppsEnabled && (
<AppSlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingAppCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
/>
)} */}
{(!appsTakeOver || !isAppsEnabled) && (<>
{/* <AtMention
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingAtMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
/>
<ChannelMention
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingChannelMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
/> */}
{!isSearch &&
<EmojiSuggestion
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
updateValue={updateValue}
onShowingChange={setShowingEmoji}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
rootId={rootId}
hasFilesAttached={hasFilesAttached}
/>
}
{/* <SlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
/>
{(isSearch && enableDateSuggestion) &&
<DateSuggestion
cursorPosition={cursorPosition}
updateValue={updateValue}
onResultCountChange={setShowingDate}
value={value || ''}
/>
} */}
</>)}
</View>
</View>
);
};
export default Autocomplete;

View File

@@ -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 (
<TouchableWithFeedback
onPress={completeItemSuggestion}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type={'native'}
>
<View style={style.row}>
<View style={style.emoji}>
<Emoji
emojiName={item}
textStyle={style.emojiText}
size={EMOJI_SIZE}
/>
</View>
<Text style={style.emojiName}>{`:${item}:`}</Text>
</View>
</TouchableWithFeedback>
);
}, [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 (
<FlatList
testID='emoji_suggestion.list'
keyboardShouldPersistTaps='always'
style={flatListStyle}
data={data}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
contentContainerStyle={containerStyle}
/>
);
};
export default EmojiSuggestion;

View File

@@ -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<SystemModel>(SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.EnableCustomEmoji === 'true')),
);
return {
customEmojis: isCustomEmojisEnabled.pipe(
switchMap((enabled) => (enabled ?
database.get<CustomEmojiModel>(CUSTOM_EMOJI).query().observe() :
of$(emptyEmojiList)),
),
),
skinTone: database.get<PreferenceModel>(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));

View File

@@ -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<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')),
),
}));
export default withDatabase(enhanced(Autocomplete));

View File

@@ -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<string>();
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, []);

View File

@@ -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 (
<DraftInput
{...props}
cursorPosition={pos}
updateCursorPosition={setCursorPosition}
/>
);
}

View File

@@ -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<React.ReactNode>(null);
const uploadErrorTimeout = useRef<NodeJS.Timeout>();
@@ -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}
/>
);
}

View File

@@ -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<DraftModel>(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<SystemModel>(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,

View File

@@ -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' &&
<Autocomplete
maxHeight={Math.min(top - AUTOCOMPLETE_MARGIN, DEVICE.AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={handleInputQuickAction}
rootId={rootId}
channelId={channelId}
offsetY={0}
/>
} */}
<SafeAreaView
edges={SAFE_AREA_VIEW_EDGES}
// onLayout={handleLayout}
onLayout={handleLayout}
style={style.inputWrapper}
testID={testID}
>
<ScrollView
style={style.inputContainer}
contentContainerStyle={style.inputContentContainer}

View File

@@ -1,6 +1,7 @@
// 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 as from$} from 'rxjs';
@@ -15,18 +16,28 @@ import PostDraft from './post_draft';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, USER, CHANNEL}} = MM_TABLES;
const {SERVER: {DRAFT, SYSTEM, USER, CHANNEL}} = MM_TABLES;
type OwnProps = {
channelId?: string;
channelId: string;
channelIsArchived?: boolean;
rootId?: string;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const {database, rootId = ''} = ownProps;
const draft = database.get<DraftModel>(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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => database.get<UserModel>(USER).findAndObserve(value)),
);
@@ -72,6 +83,8 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
files,
message,
};
});

View File

@@ -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<KeyboardTrackingViewRef>(null);
const resetScrollViewAnimationFrame = useRef<number>();
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({
<DraftHandler
testID={testID}
channelId={channelId}
cursorPosition={cursorPosition}
files={files}
rootId={rootId}
updateCursorPosition={setCursorPosition}
updatePostInputTop={setPostInputTop}
updateValue={setValue}
value={value}
/>
);
const autoComplete = (
<Autocomplete
postInputTop={postInputTop}
updateValue={setValue}
rootId={rootId}
channelId={channelId}
offsetY={0}
cursorPosition={cursorPosition}
value={value}
isSearch={isSearch}
hasFilesAttached={Boolean(files?.length)}
/>
);
if (Platform.OS === 'android') {
return draftHandler;
return (
<>
{autoComplete}
{draftHandler}
</>
);
}
return (
<KeyboardTrackingView
accessoriesContainerID={accessoriesContainerID}
ref={keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
viewInitialOffsetY={isTablet ? ViewConstants.BOTTOM_TAB_HEIGHT : 0}
>
{draftHandler}
</KeyboardTrackingView>
<>
<View nativeID={accessoriesContainerID}>
{autoComplete}
</View>
<KeyboardTrackingView
accessoriesContainerID={accessoriesContainerID}
ref={keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
viewInitialOffsetY={isTablet ? ViewConstants.BOTTOM_TAB_HEIGHT : 0}
>
{draftHandler}
</KeyboardTrackingView>
</>
);
}

View File

@@ -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 (
<CursorPositionHandler
<DraftInput
testID={testID}
channelId={channelId}
rootId={rootId}
// From draft handler
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}
value={value}
files={files}
clearDraft={clearDraft}
updateValue={updateValue}
addFiles={addFiles}
uploadFileError={uploadFileError}
// From send handler
sendMessage={handleSendMessage}
canSend={canSend()}
maxMessageLength={maxMessageLength}
updatePostInputTop={updatePostInputTop}
/>
);
}

View File

@@ -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<string>();
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<string>, 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 [];
};