forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
39e5c2c07a
commit
bcb78c499c
224
app/components/autocomplete/autocomplete.tsx
Normal file
224
app/components/autocomplete/autocomplete.tsx
Normal 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;
|
||||
@@ -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;
|
||||
43
app/components/autocomplete/emoji_suggestion/index.ts
Normal file
43
app/components/autocomplete/emoji_suggestion/index.ts
Normal 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));
|
||||
22
app/components/autocomplete/index.ts
Normal file
22
app/components/autocomplete/index.ts
Normal 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));
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user