Compare commits

...

26 Commits

Author SHA1 Message Date
Danish 2c73429ff8 Refactoring 2022-08-26 19:51:09 +05:30
Danish f1658c146a Use react-native-tab-view in ReactionsList 2022-08-26 19:51:09 +05:30
Danish 359171ed03 Migrate to react-native-tab-view 2022-08-26 19:51:09 +05:30
Danish 0f51a3c5df Refactoring and style fixes 2022-08-26 19:51:09 +05:30
Danish 8390a4c582 Add detox tests 2022-08-26 19:51:09 +05:30
Danish 83f0edefcc Tweaks 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza d9fa16977e Add prop enableContentPanningGesture to action sheet 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 7c43029bf0 Fix blank keyboard on switching back from emoji keyboard 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 37ac131618 Tweaks and optimization 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 086b98f8fb Fix FlatList not working properly in ActionSheet 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 7d64b262cf Fix hardware backpress behavior and remove redundant code 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 10f074eb02 Remove wrong use of memo and some requested changes 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 64afe08fe6 Update tabBar icons 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza e014777c9e useMemo performance optamizations and other requested changes 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 4c130c0b0b Fix searchbar height in reaction picker 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza d4bc6a078f Fix emoji search return type 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 28f869a80c Fix reaction picker not opening 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza b05d876946 New reaction picker as bottom action sheet 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza de9036edeb Close EmojiSearchbar on message input focus 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 036066cd9a Remove EmojiPicker footer from Reaction Picker 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza f9164cf0f1 Add ListEmptyComponent 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza e44c3d1ffd Remove redundant code 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 99db6d824d Fix keyboard dismiss on emoji press 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 8ea5755345 Add searchbar for Emojis 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 349c56ba45 Add backspace button and same number of columns as category tabs 2022-08-26 19:51:09 +05:30
Danish Ahmed Mirza 597a6836e6 Migrate EmojiPicker to hooks 2022-08-26 19:51:09 +05:30
26 changed files with 997 additions and 492 deletions

View File

@ -140,7 +140,7 @@ const ActionSheet = React.memo(
style={{ ...styles.container, ...bottomSheet }} style={{ ...styles.container, ...bottomSheet }}
backgroundStyle={{ backgroundColor: colors.focusedBackground }} backgroundStyle={{ backgroundColor: colors.focusedBackground }}
onChange={index => index === -1 && onClose()} onChange={index => index === -1 && onClose()}
// We need this to allow horizontal swipe gestures inside bottom sheet like in reaction picker // We need this to allow horizontal swipe gesture inside the bottom sheet like in reaction picker
enableContentPanningGesture={data?.enableContentPanningGesture ?? true} enableContentPanningGesture={data?.enableContentPanningGesture ?? true}
{...androidTablet} {...androidTablet}
> >

View File

@ -1,75 +1,75 @@
import React from 'react'; import React from 'react';
import { FlatList, Text, TouchableOpacity } from 'react-native'; import { Text, Pressable } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode'; import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import styles from './styles'; import styles, { MIN_EMOJI_SIZE, MAX_EMOJI_SIZE } from './styles';
import CustomEmoji from './CustomEmoji'; import CustomEmoji from './CustomEmoji';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { IEmoji, IEmojiCategory } from '../../definitions/IEmoji'; import { IEmoji, IEmojiCategory } from '../../definitions/IEmoji';
import { useTheme } from '../../theme';
import { isIOS } from '../../lib/methods/helpers';
import { useDimensions } from '../../dimensions';
const EMOJI_SIZE = 50; interface IEmojiProps {
emoji: string | IEmoji;
size: number;
baseUrl: string;
}
const renderEmoji = (emoji: IEmoji, size: number, baseUrl: string) => { const Emoji = ({ emoji, size, baseUrl }: IEmojiProps): React.ReactElement => {
if (emoji && emoji.isCustom) { if (typeof emoji === 'string')
return ( return (
<CustomEmoji <Text style={[styles.categoryEmoji, { height: size, width: size, fontSize: size - 14 }]}>
style={[styles.customCategoryEmoji, { height: size - 16, width: size - 16 }]} {shortnameToUnicode(`:${emoji}:`)}
emoji={emoji} </Text>
baseUrl={baseUrl}
/>
); );
}
return ( return (
<Text style={[styles.categoryEmoji, { height: size, width: size, fontSize: size - 14 }]}> <CustomEmoji style={[styles.customCategoryEmoji, { height: size - 16, width: size - 16 }]} emoji={emoji} baseUrl={baseUrl} />
{shortnameToUnicode(`:${emoji}:`)}
</Text>
); );
}; };
class EmojiCategory extends React.Component<IEmojiCategory> { const EmojiCategory = ({ baseUrl, onEmojiSelected, emojis, tabsCount }: IEmojiCategory): React.ReactElement | null => {
renderItem(emoji: IEmoji) { const { colors } = useTheme();
const { baseUrl, onEmojiSelected } = this.props; const { width } = useDimensions();
return ( const emojiSize = Math.min(Math.max(width / tabsCount, MIN_EMOJI_SIZE), MAX_EMOJI_SIZE);
<TouchableOpacity const numColumns = Math.trunc(width / emojiSize);
activeOpacity={0.7} const marginHorizontal = (width - numColumns * emojiSize) / 2;
// @ts-ignore
key={emoji && emoji.isCustom ? emoji.content : emoji} const renderItem = (emoji: IEmoji | string) => (
onPress={() => onEmojiSelected(emoji)} <Pressable
testID={`reaction-picker-${emoji && emoji.isCustom ? emoji.content : emoji}`} key={typeof emoji === 'string' ? emoji : emoji.content}
> onPress={() => onEmojiSelected(emoji)}
{renderEmoji(emoji, EMOJI_SIZE, baseUrl)} testID={`emoji-${typeof emoji === 'string' ? emoji : emoji.content}`}
</TouchableOpacity> android_ripple={{ color: colors.bannerBackground, borderless: true, radius: emojiSize / 2 }}
); style={({ pressed }: { pressed: boolean }) => ({
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
})}
>
<Emoji emoji={emoji} size={emojiSize} baseUrl={baseUrl} />
</Pressable>
);
if (!width) {
return null;
} }
render() { return (
const { emojis, width } = this.props; <FlatList
// rerender FlatList in case of width changes
if (!width) { key={`emoji-category-${width}`}
return null; keyExtractor={item => (typeof item === 'string' ? item : item.content)}
} data={emojis}
extraData={{ baseUrl, width }}
const numColumns = Math.trunc(width / EMOJI_SIZE); renderItem={({ item }) => renderItem(item)}
const marginHorizontal = (width - numColumns * EMOJI_SIZE) / 2; numColumns={numColumns}
initialNumToRender={45}
return ( removeClippedSubviews
<FlatList contentContainerStyle={{ marginHorizontal }}
contentContainerStyle={{ marginHorizontal }} {...scrollPersistTaps}
// rerender FlatList in case of width changes keyboardDismissMode={'none'}
key={`emoji-category-${width}`} />
// @ts-ignore );
keyExtractor={item => (item && item.isCustom && item.content) || item} };
data={emojis}
extraData={this.props}
renderItem={({ item }) => this.renderItem(item)}
numColumns={numColumns}
initialNumToRender={45}
removeClippedSubviews
{...scrollPersistTaps}
keyboardDismissMode={'none'}
/>
);
}
}
export default EmojiCategory; export default EmojiCategory;

View File

@ -0,0 +1,36 @@
import React from 'react';
import { View, Pressable } from 'react-native';
import { useTheme } from '../../theme';
import { CustomIcon } from '../CustomIcon';
import styles from './styles';
import { IFooterProps } from './interfaces';
const BUTTON_HIT_SLOP = { top: 15, right: 15, bottom: 15, left: 15 };
const Footer = ({ onSearchPressed, onBackspacePressed }: IFooterProps): React.ReactElement => {
const { colors } = useTheme();
return (
<View style={[styles.footerContainer, { backgroundColor: colors.bannerBackground }]}>
<Pressable
onPress={onSearchPressed}
hitSlop={BUTTON_HIT_SLOP}
style={({ pressed }) => [[styles.footerButtonsContainer, { opacity: pressed ? 0.7 : 1 }]]}
testID='emoji-picker-search'
>
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='search' />
</Pressable>
<Pressable
onPress={onBackspacePressed}
hitSlop={BUTTON_HIT_SLOP}
style={({ pressed }) => [{ opacity: pressed ? 0.7 : 1 }]}
testID='emoji-picker-backspace'
>
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='backspace' />
</Pressable>
</View>
);
};
export default Footer;

View File

@ -1,56 +1,38 @@
import React from 'react'; import React from 'react';
import { StyleProp, Text, TextStyle, TouchableOpacity, View } from 'react-native'; import { Pressable, View } from 'react-native';
import styles from './styles'; import styles from './styles';
import { themes } from '../../lib/constants'; import { useTheme } from '../../theme';
import { TSupportedThemes } from '../../theme'; import { ITabBarProps } from './interfaces';
import { isIOS } from '../../lib/methods/helpers';
import { CustomIcon } from '../CustomIcon';
interface ITabBarProps { const TabBar = ({ tabs, activeTab, onPress, showFrequentlyUsed }: ITabBarProps): React.ReactElement => {
goToPage?: (page: number) => void; const { colors } = useTheme();
activeTab?: number; return (
tabs?: string[]; <View style={styles.tabsContainer}>
tabEmojiStyle: StyleProp<TextStyle>; {tabs?.map((tab, i) => {
theme: TSupportedThemes; if (i === 0 && !showFrequentlyUsed) return null;
} return (
<Pressable
export default class TabBar extends React.Component<ITabBarProps> { key={tab.key}
shouldComponentUpdate(nextProps: ITabBarProps) { onPress={() => onPress(tab.key)}
const { activeTab, theme } = this.props; testID={`emoji-picker-tab-${tab.key}`}
if (nextProps.activeTab !== activeTab) { android_ripple={{ color: colors.bannerBackground }}
return true; style={({ pressed }: { pressed: boolean }) => [
} styles.tab,
if (nextProps.theme !== theme) { {
return true; backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
}
return false;
}
render() {
const { tabs, goToPage, tabEmojiStyle, activeTab, theme } = this.props;
return (
<View style={styles.tabsContainer}>
{tabs?.map((tab, i) => (
<TouchableOpacity
activeOpacity={0.7}
key={tab}
onPress={() => {
if (goToPage) {
goToPage(i);
} }
}} ]}
style={styles.tab}
testID={`reaction-picker-${tab}`}
> >
<Text style={[styles.tabEmoji, tabEmojiStyle]}>{tab}</Text> <CustomIcon name={tab.key} size={24} color={activeTab === i ? colors.tintColor : colors.auxiliaryTintColor} />
{activeTab === i ? ( <View style={activeTab === i ? [styles.activeTabLine, { backgroundColor: colors.tintColor }] : styles.tabLine} />
<View style={[styles.activeTabLine, { backgroundColor: themes[theme].tintColor }]} /> </Pressable>
) : ( );
<View style={styles.tabLine} /> })}
)} </View>
</TouchableOpacity> );
))} };
</View>
); export default TabBar;
}
}

View File

@ -1,44 +1,49 @@
const list = ['frequentlyUsed', 'custom', 'people', 'nature', 'food', 'activity', 'travel', 'objects', 'symbols', 'flags']; import { TIconsName } from '../CustomIcon';
const tabs = [ import { IEmojiCategoryName } from '../../definitions';
const tabs: {
key: TIconsName;
title: IEmojiCategoryName;
}[] = [
{ {
tabLabel: '🕒', key: 'clock',
category: list[0] title: 'frequentlyUsed'
}, },
{ {
tabLabel: '🚀', key: 'rocket',
category: list[1] title: 'custom'
}, },
{ {
tabLabel: '😃', key: 'emoji',
category: list[2] title: 'people'
}, },
{ {
tabLabel: '🐶', key: 'leaf',
category: list[3] title: 'nature'
}, },
{ {
tabLabel: '🍔', key: 'burger',
category: list[4] title: 'food'
}, },
{ {
tabLabel: '⚽', key: 'basketball',
category: list[5] title: 'activity'
}, },
{ {
tabLabel: '🚌', key: 'airplane',
category: list[6] title: 'travel'
}, },
{ {
tabLabel: '💡', key: 'lamp-bulb',
category: list[7] title: 'objects'
}, },
{ {
tabLabel: '💛', key: 'percentage',
category: list[8] title: 'symbols'
}, },
{ {
tabLabel: '🏁', key: 'flag',
category: list[9] title: 'flags'
} }
]; ];
export default { list, tabs }; export default tabs;

View File

@ -2813,3 +2813,5 @@ export const emojis = [
'flag_tc', 'flag_tc',
'flag_mf' 'flag_mf'
]; ];
export const DEFAULT_EMOJIS = ['clap', '+1', 'heart_eyes', 'grinning', 'thinking_face', 'smiley'];

View File

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import orderBy from 'lodash/orderBy';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import database from '../../lib/database';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import { IEmoji, TFrequentlyUsedEmojiModel } from '../../definitions';
export const useFrequentlyUsedEmoji = (): {
frequentlyUsed: (string | IEmoji)[];
loaded: boolean;
} => {
const [frequentlyUsed, setFrequentlyUsed] = useState<(string | IEmoji)[]>([]);
const [loaded, setLoaded] = useState(false);
const getFrequentlyUsedEmojis = async () => {
const db = database.active;
const frequentlyUsedRecords = await db.get('frequently_used_emojis').query().fetch();
const frequentlyUsedOrdered = orderBy(frequentlyUsedRecords, ['count'], ['desc']);
const frequentlyUsedEmojis = frequentlyUsedOrdered.map(item => {
if (item.isCustom) {
return { content: item.content, extension: item.extension, isCustom: item.isCustom };
}
return shortnameToUnicode(`${item.content}`);
}) as (string | IEmoji)[];
setFrequentlyUsed(frequentlyUsedEmojis);
setLoaded(true);
};
useEffect(() => {
getFrequentlyUsedEmojis();
}, []);
return { frequentlyUsed, loaded };
};
export const addFrequentlyUsed = async (emoji: IEmoji) => {
const db = database.active;
const freqEmojiCollection = db.get('frequently_used_emojis');
let freqEmojiRecord: TFrequentlyUsedEmojiModel;
try {
freqEmojiRecord = await freqEmojiCollection.find(emoji.content || emoji.name);
} catch (error) {
// Do nothing
}
await db.write(async () => {
if (freqEmojiRecord) {
await freqEmojiRecord.update(f => {
if (f.count) {
f.count += 1;
}
});
} else {
await freqEmojiCollection.create(f => {
f._raw = sanitizedRaw({ id: emoji.content || emoji.name }, freqEmojiCollection.schema);
Object.assign(f, emoji);
f.count = 1;
});
}
});
};

View File

@ -1,206 +1,149 @@
import React, { Component } from 'react'; import React, { useMemo, useState } from 'react';
import { StyleProp, TextStyle, View } from 'react-native'; import { View } from 'react-native';
import ScrollableTabView from 'react-native-scrollable-tab-view'; import { TabView, SceneRendererProps, NavigationState } from 'react-native-tab-view';
import { dequal } from 'dequal'; import { shallowEqual } from 'react-redux';
import { connect } from 'react-redux';
import orderBy from 'lodash/orderBy';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { ImageStyle } from 'react-native-fast-image';
import TabBar from './TabBar'; import TabBar from './TabBar';
import EmojiCategory from './EmojiCategory'; import EmojiCategory from './EmojiCategory';
import Footer from './Footer';
import styles from './styles'; import styles from './styles';
import categories from './categories'; import categories from './categories';
import database from '../../lib/database';
import { emojisByCategory } from './emojis'; import { emojisByCategory } from './emojis';
import protectedFunction from '../../lib/methods/helpers/protectedFunction';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode'; import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import log from '../../lib/methods/helpers/log'; import log from '../../lib/methods/helpers/log';
import { themes } from '../../lib/constants'; import { useTheme } from '../../theme';
import { TSupportedThemes } from '../../theme'; import { IEmoji, ICustomEmojis, IEmojiPickerCategory, IEmojiCategoryName } from '../../definitions';
import { IEmoji, TGetCustomEmoji, IApplicationState, ICustomEmojis, TFrequentlyUsedEmojiModel } from '../../definitions'; import { useAppSelector } from '../../lib/hooks';
import { IEmojiPickerProps, EventTypes } from './interfaces';
import { useFrequentlyUsedEmoji, addFrequentlyUsed } from './frequentlyUsedEmojis';
import { TIconsName } from '../CustomIcon';
interface IEmojiPickerProps { const Category = ({
isMessageContainsOnlyEmoji?: boolean; title,
getCustomEmoji?: TGetCustomEmoji; frequentlyUsed,
baseUrl: string; customEmojis,
customEmojis: ICustomEmojis; handleEmojiSelect,
style?: StyleProp<ImageStyle>; baseUrl,
theme: TSupportedThemes; tabsCount
onEmojiSelected: (emoji: string, shortname?: string) => void; }: IEmojiPickerCategory): React.ReactElement => {
tabEmojiStyle?: StyleProp<TextStyle>; let emojis: (IEmoji | string)[] = [];
} if (title === 'frequentlyUsed') {
emojis = frequentlyUsed;
interface IEmojiPickerState { } else if (title === 'custom') {
frequentlyUsed: (string | { content?: string; extension?: string; isCustom: boolean })[]; emojis = customEmojis;
customEmojis: any; } else {
show: boolean; emojis = emojisByCategory[title];
width: number | null;
}
class EmojiPicker extends Component<IEmojiPickerProps, IEmojiPickerState> {
constructor(props: IEmojiPickerProps) {
super(props);
const customEmojis = Object.keys(props.customEmojis)
.filter(item => item === props.customEmojis[item].name)
.map(item => ({
content: props.customEmojis[item].name,
extension: props.customEmojis[item].extension,
isCustom: true
}));
this.state = {
frequentlyUsed: [],
customEmojis,
show: false,
width: null
};
} }
return (
<EmojiCategory
emojis={emojis}
onEmojiSelected={(emoji: IEmoji | string) => handleEmojiSelect(emoji)}
style={styles.categoryContainer}
baseUrl={baseUrl}
tabsCount={tabsCount}
/>
);
};
async componentDidMount() { const EmojiPicker = ({
await this.updateFrequentlyUsed(); onItemClicked,
this.setState({ show: true }); isEmojiKeyboard = false,
} searching = false,
searchedEmojis = []
}: IEmojiPickerProps): React.ReactElement | null => {
const { colors } = useTheme();
const { frequentlyUsed, loaded } = useFrequentlyUsedEmoji();
const [index, setIndex] = useState(0);
const [routes] = useState(categories);
shouldComponentUpdate(nextProps: IEmojiPickerProps, nextState: IEmojiPickerState) { const baseUrl = useAppSelector(state => state.server?.server);
const { frequentlyUsed, show, width } = this.state; const allCustomEmojis: ICustomEmojis = useAppSelector(state => state.customEmojis, shallowEqual);
const { theme } = this.props; const customEmojis: IEmoji[] = useMemo(
if (nextProps.theme !== theme) { () =>
return true; Object.keys(allCustomEmojis)
} .filter(item => item === allCustomEmojis[item].name)
if (nextState.show !== show) { .map(item => ({
return true; content: allCustomEmojis[item].name,
} name: allCustomEmojis[item].name,
if (nextState.width !== width) { extension: allCustomEmojis[item].extension,
return true; isCustom: true
} })),
if (!dequal(nextState.frequentlyUsed, frequentlyUsed)) { [allCustomEmojis]
return true; );
}
return false;
}
onEmojiSelected = (emoji: IEmoji) => { const handleEmojiSelect = (emoji: IEmoji | string) => {
try { try {
const { onEmojiSelected } = this.props; if (typeof emoji === 'string') {
if (emoji.isCustom) { addFrequentlyUsed({ content: emoji, name: emoji, isCustom: false });
this._addFrequentlyUsed({ const shortname = `:${emoji}:`;
onItemClicked(EventTypes.EMOJI_PRESSED, shortnameToUnicode(shortname), shortname);
} else {
addFrequentlyUsed({
content: emoji.content, content: emoji.content,
name: emoji.name,
extension: emoji.extension, extension: emoji.extension,
isCustom: true isCustom: true
}); });
onEmojiSelected(`:${emoji.content}:`); onItemClicked(EventTypes.EMOJI_PRESSED, `:${emoji.content}:`);
} else {
const content = emoji;
this._addFrequentlyUsed({ content, isCustom: false });
const shortname = `:${emoji}:`;
onEmojiSelected(shortnameToUnicode(shortname), shortname);
} }
} catch (e) { } catch (e) {
log(e); log(e);
} }
}; };
_addFrequentlyUsed = protectedFunction(async (emoji: IEmoji) => { const tabsCount = frequentlyUsed.length === 0 ? categories.length - 1 : categories.length;
const db = database.active;
const freqEmojiCollection = db.get('frequently_used_emojis');
let freqEmojiRecord: TFrequentlyUsedEmojiModel;
try {
freqEmojiRecord = await freqEmojiCollection.find(emoji.content);
} catch (error) {
// Do nothing
}
await db.write(async () => { type Route = {
if (freqEmojiRecord) { key: TIconsName;
await freqEmojiRecord.update(f => { title: IEmojiCategoryName;
if (f.count) {
f.count += 1;
}
});
} else {
await freqEmojiCollection.create(f => {
f._raw = sanitizedRaw({ id: emoji.content }, freqEmojiCollection.schema);
Object.assign(f, emoji);
f.count = 1;
});
}
});
});
updateFrequentlyUsed = async () => {
const db = database.active;
const frequentlyUsedRecords = await db.get('frequently_used_emojis').query().fetch();
const frequentlyUsedOrdered = orderBy(frequentlyUsedRecords, ['count'], ['desc']);
const frequentlyUsed = frequentlyUsedOrdered.map(item => {
if (item.isCustom) {
return { content: item.content, extension: item.extension, isCustom: item.isCustom };
}
return shortnameToUnicode(`${item.content}`);
});
this.setState({ frequentlyUsed });
}; };
type State = NavigationState<Route>;
const renderTabBar = (props: SceneRendererProps & { navigationState: State }) => (
<TabBar tabs={categories} onPress={props.jumpTo} activeTab={index} showFrequentlyUsed={frequentlyUsed.length > 0} />
);
onLayout = ({ if (!loaded) {
nativeEvent: { return null;
layout: { width }
}
}: any) => this.setState({ width });
renderCategory(category: keyof typeof emojisByCategory, i: number, label: string) {
const { frequentlyUsed, customEmojis, width } = this.state;
const { baseUrl } = this.props;
let emojis = [];
if (i === 0) {
emojis = frequentlyUsed;
} else if (i === 1) {
emojis = customEmojis;
} else {
emojis = emojisByCategory[category];
}
return (
<EmojiCategory
emojis={emojis}
onEmojiSelected={(emoji: IEmoji) => this.onEmojiSelected(emoji)}
style={styles.categoryContainer}
width={width}
baseUrl={baseUrl}
tabLabel={label}
/>
);
} }
render() { return (
const { show, frequentlyUsed } = this.state; <View style={styles.emojiPickerContainer}>
const { tabEmojiStyle, theme } = this.props; {searching ? (
<EmojiCategory
if (!show) { emojis={searchedEmojis}
return null; onEmojiSelected={(emoji: IEmoji | string) => handleEmojiSelect(emoji)}
} style={styles.categoryContainer}
return ( baseUrl={baseUrl}
<View onLayout={this.onLayout} style={{ flex: 1 }}> tabsCount={tabsCount}
<ScrollableTabView />
renderTabBar={() => <TabBar tabEmojiStyle={tabEmojiStyle} theme={theme} />} ) : (
contentProps={{ <TabView
keyboardShouldPersistTaps: 'always', lazy
keyboardDismissMode: 'none' navigationState={{ index, routes }}
}} renderScene={({ route }: { route: Route }) => (
style={{ backgroundColor: themes[theme].focusedBackground }} <Category
> key={route.key}
{categories.tabs.map((tab: any, i) => title={route.title}
i === 0 && frequentlyUsed.length === 0 frequentlyUsed={frequentlyUsed}
? null // when no frequentlyUsed don't show the tab customEmojis={customEmojis}
: this.renderCategory(tab.category, i, tab.tabLabel) handleEmojiSelect={handleEmojiSelect}
baseUrl={baseUrl}
tabsCount={tabsCount}
/>
)} )}
</ScrollableTabView> onIndexChange={setIndex}
</View> style={{ backgroundColor: colors.focusedBackground }}
); renderTabBar={renderTabBar}
} />
} )}
{isEmojiKeyboard && (
<Footer
onSearchPressed={() => onItemClicked(EventTypes.SEARCH_PRESSED)}
onBackspacePressed={() => onItemClicked(EventTypes.BACKSPACE_PRESSED)}
/>
)}
</View>
);
};
const mapStateToProps = (state: IApplicationState) => ({ export default EmojiPicker;
customEmojis: state.customEmojis,
baseUrl: state.share.server.server || state.server.server
});
export default connect(mapStateToProps)(EmojiPicker);

View File

@ -0,0 +1,27 @@
import { TIconsName } from '../CustomIcon';
import { IEmoji } from '../../definitions';
export enum EventTypes {
EMOJI_PRESSED = 'emojiPressed',
BACKSPACE_PRESSED = 'backspacePressed',
SEARCH_PRESSED = 'searchPressed'
}
export interface IEmojiPickerProps {
onItemClicked: (event: EventTypes, emoji?: string, shortname?: string) => void;
isEmojiKeyboard?: boolean;
searching?: boolean;
searchedEmojis?: (string | IEmoji)[];
}
export interface IFooterProps {
onBackspacePressed: () => void;
onSearchPressed: () => void;
}
export interface ITabBarProps {
activeTab?: number;
tabs?: { key: TIconsName; title: string }[];
onPress: (ket: string) => void;
showFrequentlyUsed?: boolean;
}

View File

@ -2,20 +2,23 @@ import { StyleSheet } from 'react-native';
import sharedStyles from '../../views/Styles'; import sharedStyles from '../../views/Styles';
export const MAX_EMOJI_SIZE = 50;
export const MIN_EMOJI_SIZE = 42;
export default StyleSheet.create({ export default StyleSheet.create({
container: { container: {
flex: 1 flex: 1
}, },
tabsContainer: { tabsContainer: {
height: 45, height: 45,
flexDirection: 'row', flexDirection: 'row'
paddingTop: 5
}, },
tab: { tab: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingBottom: 10 paddingVertical: 10,
width: 44
}, },
tabEmoji: { tabEmoji: {
fontSize: 20, fontSize: 20,
@ -54,5 +57,19 @@ export default StyleSheet.create({
}, },
customCategoryEmoji: { customCategoryEmoji: {
margin: 8 margin: 8
} },
footerContainer: {
height: 44,
paddingHorizontal: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
footerButtonsContainer: {
height: 44,
width: 44,
justifyContent: 'center',
alignItems: 'center'
},
emojiPickerContainer: { flex: 1 }
}); });

View File

@ -11,6 +11,7 @@ import { useDimensions } from '../../dimensions';
import sharedStyles from '../../views/Styles'; import sharedStyles from '../../views/Styles';
import { TAnyMessageModel, TFrequentlyUsedEmojiModel } from '../../definitions'; import { TAnyMessageModel, TFrequentlyUsedEmojiModel } from '../../definitions';
import Touch from '../Touch'; import Touch from '../Touch';
import { DEFAULT_EMOJIS } from '../EmojiPicker/emojis';
type TItem = TFrequentlyUsedEmojiModel | string; type TItem = TFrequentlyUsedEmojiModel | string;
@ -69,8 +70,6 @@ const keyExtractor = (item: TItem) => {
return (emojiModel.id ? emojiModel.content : item) as string; return (emojiModel.id ? emojiModel.content : item) as string;
}; };
const DEFAULT_EMOJIS = ['clap', '+1', 'heart_eyes', 'grinning', 'thinking_face', 'smiley'];
const HeaderItem = ({ item, onReaction, server, theme }: THeaderItem) => { const HeaderItem = ({ item, onReaction, server, theme }: THeaderItem) => {
const emojiModel = item as TFrequentlyUsedEmojiModel; const emojiModel = item as TFrequentlyUsedEmojiModel;
const emoji = (emojiModel.id ? emojiModel.content : item) as string; const emoji = (emojiModel.id ? emojiModel.content : item) as string;

View File

@ -264,7 +264,8 @@ const MessageActions = React.memo(
// TODO: evaluate unification with IEmoji // TODO: evaluate unification with IEmoji
onReactionPress(shortname as any, message.id); onReactionPress(shortname as any, message.id);
} else { } else {
reactionInit(message); // Wait for the Action Sheet to close before opening reaction picker
setTimeout(() => reactionInit(message), 500);
} }
// close actionSheet when click at header // close actionSheet when click at header
hideActionSheet(); hideActionSheet();

View File

@ -6,21 +6,20 @@ import { Provider } from 'react-redux';
import store from '../../lib/store'; import store from '../../lib/store';
import EmojiPicker from '../EmojiPicker'; import EmojiPicker from '../EmojiPicker';
import styles from './styles'; import styles from './styles';
import { themes } from '../../lib/constants'; import { useTheme } from '../../theme';
import { TSupportedThemes } from '../../theme'; import { EventTypes } from '../EmojiPicker/interfaces';
const EmojiKeyboard = ({ theme }: { theme: TSupportedThemes }) => { const EmojiKeyboard = () => {
const onEmojiSelected = (emoji: string) => { const { colors } = useTheme();
KeyboardRegistry.onItemSelected('EmojiKeyboard', { emoji });
const onItemClicked = (eventType: EventTypes, emoji: string | undefined) => {
KeyboardRegistry.onItemSelected('EmojiKeyboard', { eventType, emoji });
}; };
return ( return (
<Provider store={store}> <Provider store={store}>
<View <View style={[styles.emojiKeyboardContainer, { borderTopColor: colors.borderColor }]} testID='messagebox-keyboard-emoji'>
style={[styles.emojiKeyboardContainer, { borderTopColor: themes[theme].borderColor }]} <EmojiPicker onItemClicked={onItemClicked} isEmojiKeyboard={true} />
testID='messagebox-keyboard-emoji'
>
<EmojiPicker onEmojiSelected={onEmojiSelected} theme={theme} />
</View> </View>
</Provider> </Provider>
); );

View File

@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { View, Text, Pressable, TextInput, FlatList } from 'react-native';
import { FormTextInput } from '../TextInput/FormTextInput';
import { useTheme } from '../../theme';
import I18n from '../../i18n';
import { CustomIcon } from '../CustomIcon';
import { IEmoji } from '../../definitions';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import CustomEmoji from '../EmojiPicker/CustomEmoji';
import styles from './styles';
import { useFrequentlyUsedEmoji, addFrequentlyUsed } from '../EmojiPicker/frequentlyUsedEmojis';
import { DEFAULT_EMOJIS } from '../EmojiPicker/emojis';
const BUTTON_HIT_SLOP = { top: 4, right: 4, bottom: 4, left: 4 };
const EMOJI_SIZE = 30;
interface IEmojiSearchBarProps {
openEmoji: () => void;
onChangeText: (value: string) => void;
emojis: (IEmoji | string)[];
onEmojiSelected: (emoji: IEmoji | string) => void;
baseUrl: string;
}
interface IListItem {
emoji: IEmoji | string;
onEmojiSelected: (emoji: IEmoji | string) => void;
baseUrl: string;
}
const Emoji = ({ emoji, baseUrl }: { emoji: IEmoji | string; baseUrl: string }): React.ReactElement => {
const { colors } = useTheme();
if (typeof emoji === 'string') {
return (
<Text style={[styles.searchedEmoji, { fontSize: EMOJI_SIZE, color: colors.backdropColor }]}>
{shortnameToUnicode(`:${emoji}:`)}
</Text>
);
}
return (
<CustomEmoji
style={[styles.emojiSearchCustomEmoji, { height: EMOJI_SIZE, width: EMOJI_SIZE }]}
emoji={emoji}
baseUrl={baseUrl}
/>
);
};
const ListItem = ({ emoji, onEmojiSelected, baseUrl }: IListItem): React.ReactElement => {
const key = typeof emoji === 'string' ? emoji : emoji?.name || emoji?.content;
const onPress = () => {
onEmojiSelected(emoji);
if (typeof emoji === 'string') {
addFrequentlyUsed({ content: emoji, name: emoji, isCustom: false });
} else {
addFrequentlyUsed({
content: emoji?.content || emoji?.name,
name: emoji?.name,
extension: emoji.extension,
isCustom: true
});
}
};
return (
<View style={[styles.emojiContainer]} key={key} testID={`searched-emoji-${key}`}>
<Pressable onPress={onPress}>
<Emoji emoji={emoji} baseUrl={baseUrl} />
</Pressable>
</View>
);
};
const EmojiSearchBar = React.forwardRef<TextInput, IEmojiSearchBarProps>(
({ openEmoji, onChangeText, emojis, onEmojiSelected, baseUrl }, ref) => {
const { colors } = useTheme();
const [searchText, setSearchText] = useState<string>('');
const { frequentlyUsed } = useFrequentlyUsedEmoji();
const frequentlyUsedWithDefaultEmojis = frequentlyUsed
.filter(emoji => {
if (typeof emoji === 'string') return !DEFAULT_EMOJIS.includes(emoji);
return !DEFAULT_EMOJIS.includes(emoji.name);
})
.concat(DEFAULT_EMOJIS);
const handleTextChange = (text: string) => {
setSearchText(text);
onChangeText(text);
};
return (
<View
style={[styles.emojiSearchViewContainer, { borderTopColor: colors.borderColor, backgroundColor: colors.backgroundColor }]}
>
<FlatList
horizontal
data={searchText ? emojis : frequentlyUsedWithDefaultEmojis}
renderItem={({ item }) => <ListItem emoji={item} onEmojiSelected={onEmojiSelected} baseUrl={baseUrl} />}
showsHorizontalScrollIndicator={false}
ListEmptyComponent={() => (
<View style={styles.listEmptyComponent} testID='no-results-found'>
<Text style={{ color: colors.auxiliaryText }}>{I18n.t('No_results_found')}</Text>
</View>
)}
// @ts-ignore
keyExtractor={item => item?.content || item?.name || item}
contentContainerStyle={styles.emojiListContainer}
keyboardShouldPersistTaps='always'
/>
<View style={styles.emojiSearchbarContainer}>
<Pressable
style={({ pressed }: { pressed: boolean }) => [styles.openEmojiKeyboard, { opacity: pressed ? 0.7 : 1 }]}
onPress={openEmoji}
hitSlop={BUTTON_HIT_SLOP}
testID='openback-emoji-keyboard'
>
<CustomIcon name='chevron-left' size={30} color={colors.collapsibleChevron} />
</Pressable>
<View style={styles.emojiSearchInput}>
<FormTextInput
inputRef={ref}
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit
placeholder={I18n.t('Search_emoji')}
returnKeyType='search'
underlineColorAndroid='transparent'
onChangeText={handleTextChange}
style={[styles.emojiSearchbar, { backgroundColor: colors.passcodeButtonActive }]}
containerStyle={styles.textInputContainer}
value={searchText}
onClearInput={() => handleTextChange('')}
iconRight={'search'}
testID='emoji-searchbar-input'
/>
</View>
</View>
</View>
);
}
);
export default EmojiSearchBar;

View File

@ -4,5 +4,6 @@ export const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
export const MENTIONS_TRACKING_TYPE_ROOMS = '#'; export const MENTIONS_TRACKING_TYPE_ROOMS = '#';
export const MENTIONS_TRACKING_TYPE_CANNED = '!'; export const MENTIONS_TRACKING_TYPE_CANNED = '!';
export const MENTIONS_COUNT_TO_DISPLAY = 4; export const MENTIONS_COUNT_TO_DISPLAY = 4;
export const MAX_EMOJIS_TO_DISPLAY = 20;
export const TIMEOUT_CLOSE_EMOJI = 300; export const TIMEOUT_CLOSE_EMOJI = 300;

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Alert, Keyboard, NativeModules, Text, View } from 'react-native'; import { Alert, Keyboard, NativeModules, Text, View, BackHandler } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard'; import { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard';
import ImagePicker, { Image, ImageOrVideo, Options } from 'react-native-image-crop-picker'; import ImagePicker, { Image, ImageOrVideo, Options } from 'react-native-image-crop-picker';
@ -34,7 +34,8 @@ import {
MENTIONS_TRACKING_TYPE_EMOJIS, MENTIONS_TRACKING_TYPE_EMOJIS,
MENTIONS_TRACKING_TYPE_ROOMS, MENTIONS_TRACKING_TYPE_ROOMS,
MENTIONS_TRACKING_TYPE_USERS, MENTIONS_TRACKING_TYPE_USERS,
TIMEOUT_CLOSE_EMOJI TIMEOUT_CLOSE_EMOJI,
MAX_EMOJIS_TO_DISPLAY
} from './constants'; } from './constants';
import CommandsPreview from './CommandsPreview'; import CommandsPreview from './CommandsPreview';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
@ -59,6 +60,9 @@ import { hasPermission, debounce, isAndroid, isIOS, isTablet, compareServerVersi
import { Services } from '../../lib/services'; import { Services } from '../../lib/services';
import { TSupportedThemes } from '../../theme'; import { TSupportedThemes } from '../../theme';
import { ChatsStackParamList } from '../../stacks/types'; import { ChatsStackParamList } from '../../stacks/types';
import { EventTypes } from '../EmojiPicker/interfaces';
import EmojiSearchbar from './EmojiSearchbar';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
require('./EmojiKeyboard'); require('./EmojiKeyboard');
@ -129,6 +133,8 @@ interface IMessageBoxState {
tshow: boolean; tshow: boolean;
mentionLoading: boolean; mentionLoading: boolean;
permissionToUpload: boolean; permissionToUpload: boolean;
showEmojiSearchbar: boolean;
searchedEmojis: any[];
} }
class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> { class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
@ -160,6 +166,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
private typingTimeout: any; private typingTimeout: any;
private emojiSearchbarRef: any;
static defaultProps = { static defaultProps = {
message: { message: {
id: '' id: ''
@ -183,7 +191,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
command: {}, command: {},
tshow: this.sendThreadToChannel, tshow: this.sendThreadToChannel,
mentionLoading: false, mentionLoading: false,
permissionToUpload: true permissionToUpload: true,
showEmojiSearchbar: false,
searchedEmojis: []
}; };
this.text = ''; this.text = '';
this.selection = { start: 0, end: 0 }; this.selection = { start: 0, end: 0 };
@ -209,6 +219,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
...videoPickerConfig, ...videoPickerConfig,
...libPickerLabels ...libPickerLabels
}; };
BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
} }
get sendThreadToChannel() { get sendThreadToChannel() {
@ -326,7 +338,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
tshow, tshow,
mentionLoading, mentionLoading,
trackingType, trackingType,
permissionToUpload permissionToUpload,
showEmojiSearchbar,
searchedEmojis
} = this.state; } = this.state;
const { const {
@ -394,6 +408,12 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
if (nextProps.goToCannedResponses !== goToCannedResponses) { if (nextProps.goToCannedResponses !== goToCannedResponses) {
return true; return true;
} }
if (nextState.showEmojiSearchbar !== showEmojiSearchbar) {
return true;
}
if (!dequal(nextState.searchedEmojis, searchedEmojis)) {
return true;
}
return false; return false;
} }
@ -438,6 +458,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
if (isTablet) { if (isTablet) {
EventEmiter.removeListener(KEY_COMMAND, this.handleCommands); EventEmiter.removeListener(KEY_COMMAND, this.handleCommands);
} }
BackHandler.removeEventListener('hardwareBackPress', this.handleBackPress);
} }
setOptions = async () => { setOptions = async () => {
@ -577,18 +598,45 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
} }
}; };
onEmojiSelected = (keyboardId: string, params: { emoji: string }) => { onKeyboardItemSelected = (keyboardId: string, params: { eventType: EventTypes; emoji: string }) => {
const { eventType, emoji } = params;
const { text } = this; const { text } = this;
const { emoji } = params;
let newText = ''; let newText = '';
// if messagebox has an active cursor // if messagebox has an active cursor
const { start, end } = this.selection; const { start, end } = this.selection;
const cursor = Math.max(start, end); const cursor = Math.max(start, end);
newText = `${text.substr(0, cursor)}${emoji}${text.substr(cursor)}`; let newCursor;
const newCursor = cursor + emoji.length;
this.setInput(newText, { start: newCursor, end: newCursor }); switch (eventType) {
this.setShowSend(true); case EventTypes.BACKSPACE_PRESSED:
const emojiRegex = /\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/;
let charsToRemove = 1;
const lastEmoji = text.substr(cursor > 0 ? cursor - 2 : text.length - 2, cursor > 0 ? cursor : text.length);
// Check if last character is an emoji
if (emojiRegex.test(lastEmoji)) charsToRemove = 2;
newText =
text.substr(0, (cursor > 0 ? cursor : text.length) - charsToRemove) + text.substr(cursor > 0 ? cursor : text.length);
newCursor = cursor - charsToRemove;
this.setInput(newText, { start: newCursor, end: newCursor });
this.setShowSend(newText !== '');
break;
case EventTypes.EMOJI_PRESSED:
newText = `${text.substr(0, cursor)}${emoji}${text.substr(cursor)}`;
newCursor = cursor + emoji.length;
this.setInput(newText, { start: newCursor, end: newCursor });
this.setShowSend(true);
break;
case EventTypes.SEARCH_PRESSED:
this.setState({ showEmojiKeyboard: false, showEmojiSearchbar: true });
setTimeout(() => {
if (this.emojiSearchbarRef && this.emojiSearchbarRef.focus) {
this.emojiSearchbarRef.focus();
}
}, 400);
break;
default:
// Do nothing
}
}; };
getPermalink = async (message: any) => { getPermalink = async (message: any) => {
@ -621,16 +669,20 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.setState({ mentions: res, mentionLoading: false }); this.setState({ mentions: res, mentionLoading: false });
}, 300); }, 300);
getEmojis = debounce(async (keyword: any) => { getCustomEmojis = async (keyword: any, count: number) => {
const db = database.active;
const customEmojisCollection = db.get('custom_emojis');
const likeString = sanitizeLikeString(keyword); const likeString = sanitizeLikeString(keyword);
const whereClause = []; const whereClause = [];
if (likeString) { if (likeString) {
whereClause.push(Q.where('name', Q.like(`${likeString}%`))); whereClause.push(Q.where('name', Q.like(`${likeString}%`)));
} }
let customEmojis = await customEmojisCollection.query(...whereClause).fetch(); const db = database.active;
customEmojis = customEmojis.slice(0, MENTIONS_COUNT_TO_DISPLAY); const customEmojisCollection = db.get('custom_emojis');
const customEmojis = await (await customEmojisCollection.query(...whereClause).fetch()).slice(0, count);
return customEmojis;
};
getEmojis = debounce(async (keyword: any) => {
const customEmojis = await this.getCustomEmojis(keyword, MENTIONS_COUNT_TO_DISPLAY);
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY); const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY); const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis || [], mentionLoading: false }); this.setState({ mentions: mergedEmojis || [], mentionLoading: false });
@ -881,7 +933,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
openEmoji = () => { openEmoji = () => {
logEvent(events.ROOM_OPEN_EMOJI); logEvent(events.ROOM_OPEN_EMOJI);
this.setState({ showEmojiKeyboard: true }); this.setState({ showEmojiKeyboard: true, showEmojiSearchbar: false });
this.stopTrackingMention();
}; };
recordingCallback = (recording: any) => { recordingCallback = (recording: any) => {
@ -906,6 +959,15 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.setState({ showEmojiKeyboard: false }); this.setState({ showEmojiKeyboard: false });
}; };
closeEmojiKeyboardAndFocus = () => {
this.closeEmoji();
this.focus();
};
closeEmojiSearchbar = () => {
this.setState({ showEmojiSearchbar: false });
};
closeEmojiAndAction = (action?: Function, params?: any) => { closeEmojiAndAction = (action?: Function, params?: any) => {
const { showEmojiKeyboard } = this.state; const { showEmojiKeyboard } = this.state;
@ -926,7 +988,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.clearInput(); this.clearInput();
this.debouncedOnChangeText.stop(); this.debouncedOnChangeText.stop();
this.closeEmoji(); this.closeEmojiKeyboardAndFocus();
this.stopTrackingMention(); this.stopTrackingMention();
this.handleTyping(false); this.handleTyping(false);
if (message.trim() === '' && !showSend) { if (message.trim() === '' && !showSend) {
@ -1083,6 +1145,58 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
); );
}; };
renderEmojiSearchbar = () => {
const { showEmojiSearchbar, searchedEmojis } = this.state;
const { baseUrl } = this.props;
const searchEmojis = debounce(async (keyword: any) => {
const customEmojis = await this.getCustomEmojis(keyword, MAX_EMOJIS_TO_DISPLAY / 2);
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MAX_EMOJIS_TO_DISPLAY / 2);
const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MAX_EMOJIS_TO_DISPLAY);
this.setState({ searchedEmojis: mergedEmojis });
}, 300);
const onChangeText = (value: string) => {
searchEmojis(value);
};
const onEmojiSelected = (emoji: any) => {
let selectedEmoji;
if (emoji.name || emoji.content) {
selectedEmoji = `:${emoji.name || emoji.content}:`;
} else {
selectedEmoji = shortnameToUnicode(`:${emoji}:`);
}
const { text } = this;
let newText = '';
const { start, end } = this.selection;
const cursor = Math.max(start, end);
newText = `${text.substr(0, cursor)}${selectedEmoji}${text.substr(cursor)}`;
const newCursor = cursor + selectedEmoji.length;
this.setInput(newText, { start: newCursor, end: newCursor });
this.setShowSend(true);
};
return showEmojiSearchbar ? (
<EmojiSearchbar
ref={ref => (this.emojiSearchbarRef = ref)}
openEmoji={this.openEmoji}
onChangeText={onChangeText}
emojis={searchedEmojis}
baseUrl={baseUrl}
onEmojiSelected={onEmojiSelected}
/>
) : null;
};
handleBackPress = () => {
const { showEmojiSearchbar } = this.state;
if (showEmojiSearchbar) {
this.setState({ showEmojiSearchbar: false });
return true;
}
return false;
};
renderContent = () => { renderContent = () => {
const { const {
recording, recording,
@ -1153,7 +1267,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
editing={editing} editing={editing}
editCancel={this.editCancel} editCancel={this.editCancel}
openEmoji={this.openEmoji} openEmoji={this.openEmoji}
closeEmoji={this.closeEmoji} closeEmoji={this.closeEmojiKeyboardAndFocus}
/> />
<TextInput <TextInput
ref={component => (this.component = component)} ref={component => (this.component = component)}
@ -1169,6 +1283,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
defaultValue='' defaultValue=''
multiline multiline
testID={`messagebox-input${tmid ? '-thread' : ''}`} testID={`messagebox-input${tmid ? '-thread' : ''}`}
onFocus={this.closeEmojiSearchbar}
{...isAndroidTablet} {...isAndroidTablet}
/> />
<RightButtons <RightButtons
@ -1197,6 +1312,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
{recordAudio} {recordAudio}
</View> </View>
{this.renderSendToChannel()} {this.renderSendToChannel()}
{this.renderEmojiSearchbar()}
</View> </View>
{children} {children}
</> </>
@ -1224,7 +1340,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null} kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null}
kbInitialProps={{ theme }} kbInitialProps={{ theme }}
onKeyboardResigned={this.onKeyboardResigned} onKeyboardResigned={this.onKeyboardResigned}
onItemSelected={this.onEmojiSelected} onItemSelected={this.onKeyboardItemSelected}
trackInteractive trackInteractive
requiresSameParentToManageScrollView requiresSameParentToManageScrollView
addBottomView addBottomView

View File

@ -157,5 +157,34 @@ export default StyleSheet.create({
fontSize: 12, fontSize: 12,
marginLeft: 4, marginLeft: 4,
...sharedStyles.textRegular ...sharedStyles.textRegular
},
searchedEmoji: {
backgroundColor: 'transparent'
},
emojiContainer: { justifyContent: 'center', marginHorizontal: 2 },
emojiListContainer: { height: 50, paddingHorizontal: 5, marginVertical: 5, flexGrow: 1 },
emojiSearchViewContainer: {
borderTopWidth: 1
},
emojiSearchbarContainer: {
flexDirection: 'row',
height: 50,
marginBottom: 15,
justifyContent: 'center',
alignItems: 'center'
},
openEmojiKeyboard: { marginHorizontal: 10, justifyContent: 'center' },
emojiSearchbar: { paddingHorizontal: 20, borderRadius: 2, fontSize: 16 },
textInputContainer: { justifyContent: 'center', marginBottom: 0, marginRight: 15 },
listEmptyComponent: {
width: '100%',
alignItems: 'center',
justifyContent: 'center'
},
emojiSearchCustomEmoji: {
margin: 4
},
emojiSearchInput: {
flex: 1
} }
}); });

View File

@ -1,7 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { StyleSheet, Text, Pressable, View, ScrollView } from 'react-native'; import { StyleSheet, Text, Pressable, View, ScrollView } from 'react-native';
import ScrollableTabView from 'react-native-scrollable-tab-view';
import { FlatList } from 'react-native-gesture-handler'; import { FlatList } from 'react-native-gesture-handler';
import { TabView, SceneRendererProps, NavigationState } from 'react-native-tab-view';
import Emoji from './message/Emoji'; import Emoji from './message/Emoji';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
@ -30,6 +30,12 @@ const styles = StyleSheet.create({
customEmojiStyle: { width: 25, height: 25 } customEmojiStyle: { width: 25, height: 25 }
}); });
type Route = {
key: string;
reaction: IReaction;
};
type State = NavigationState<Route>;
interface IReactionsListBase { interface IReactionsListBase {
baseUrl: string; baseUrl: string;
getCustomEmoji: TGetCustomEmoji; getCustomEmoji: TGetCustomEmoji;
@ -41,24 +47,23 @@ interface IReactionsListProps extends IReactionsListBase {
} }
interface ITabBarItem extends IReactionsListBase { interface ITabBarItem extends IReactionsListBase {
tab: IReaction; tab: { key: string; reaction: IReaction };
index: number; jumpTo?: (key: string) => void;
goToPage?: (index: number) => void;
} }
interface IReactionsTabBar extends IReactionsListBase { interface IReactionsTabBar extends IReactionsListBase {
activeTab?: number; activeTab?: number;
tabs?: IReaction[]; tabs?: { key: string; reaction: IReaction }[];
goToPage?: (index: number) => void; jumpTo?: (key: string) => void;
width: number; width: number;
} }
const TabBarItem = ({ tab, index, goToPage, baseUrl, getCustomEmoji }: ITabBarItem) => { const TabBarItem = ({ tab, jumpTo, baseUrl, getCustomEmoji }: ITabBarItem) => {
const { colors } = useTheme(); const { colors } = useTheme();
return ( return (
<Pressable <Pressable
key={tab.emoji} key={tab.key}
onPress={() => { onPress={() => {
goToPage?.(index); jumpTo?.(tab.key);
}} }}
style={({ pressed }: { pressed: boolean }) => ({ style={({ pressed }: { pressed: boolean }) => ({
opacity: pressed ? 0.7 : 1 opacity: pressed ? 0.7 : 1
@ -66,19 +71,19 @@ const TabBarItem = ({ tab, index, goToPage, baseUrl, getCustomEmoji }: ITabBarIt
> >
<View style={styles.tabBarItem}> <View style={styles.tabBarItem}>
<Emoji <Emoji
content={tab.emoji} content={tab.key}
standardEmojiStyle={styles.standardEmojiStyle} standardEmojiStyle={styles.standardEmojiStyle}
customEmojiStyle={styles.customEmojiStyle} customEmojiStyle={styles.customEmojiStyle}
baseUrl={baseUrl} baseUrl={baseUrl}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}
/> />
<Text style={[styles.reactionCount, { color: colors.auxiliaryTintColor }]}>{tab.usernames.length}</Text> <Text style={[styles.reactionCount, { color: colors.auxiliaryTintColor }]}>{tab.reaction.usernames.length}</Text>
</View> </View>
</Pressable> </Pressable>
); );
}; };
const ReactionsTabBar = ({ tabs, activeTab, goToPage, baseUrl, getCustomEmoji, width }: IReactionsTabBar) => { const ReactionsTabBar = ({ tabs, activeTab, jumpTo, baseUrl, getCustomEmoji, width }: IReactionsTabBar) => {
const tabWidth = tabs && Math.max(width / tabs.length, MIN_TAB_WIDTH); const tabWidth = tabs && Math.max(width / tabs.length, MIN_TAB_WIDTH);
const { colors } = useTheme(); const { colors } = useTheme();
return ( return (
@ -94,7 +99,7 @@ const ReactionsTabBar = ({ tabs, activeTab, goToPage, baseUrl, getCustomEmoji, w
borderColor: isActiveTab ? colors.tintActive : colors.separatorColor borderColor: isActiveTab ? colors.tintActive : colors.separatorColor
}} }}
> >
<TabBarItem tab={tab} index={index} goToPage={goToPage} baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} /> <TabBarItem tab={tab} jumpTo={jumpTo} baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} />
</View> </View>
); );
})} })}
@ -128,16 +133,30 @@ const UsersList = ({ tabLabel }: { tabLabel: IReaction }) => {
}; };
const ReactionsList = ({ reactions, baseUrl, getCustomEmoji, width }: IReactionsListProps): React.ReactElement => { const ReactionsList = ({ reactions, baseUrl, getCustomEmoji, width }: IReactionsListProps): React.ReactElement => {
const [index, setIndex] = useState(0);
// sorting reactions in descending order on the basic of number of users reacted // sorting reactions in descending order on the basic of number of users reacted
const sortedReactions = reactions?.sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length); const sortedReactions = reactions?.sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length);
const routes = sortedReactions ? sortedReactions?.map(reaction => ({ key: reaction.emoji, reaction })) : [];
return ( return (
<View style={styles.reactionsListContainer}> <View style={styles.reactionsListContainer}>
<ScrollableTabView renderTabBar={() => <ReactionsTabBar baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} width={width} />}> <TabView
{sortedReactions?.map(reaction => ( lazy
<UsersList tabLabel={reaction} key={reaction.emoji} /> navigationState={{ index, routes }}
))} renderScene={({ route }) => <UsersList tabLabel={route.reaction} />}
</ScrollableTabView> onIndexChange={setIndex}
renderTabBar={(props: SceneRendererProps & { navigationState: State }) => (
<ReactionsTabBar
tabs={routes}
jumpTo={props.jumpTo}
width={width}
baseUrl={baseUrl}
getCustomEmoji={getCustomEmoji}
activeTab={index}
/>
)}
/>
</View> </View>
); );
}; };

View File

@ -5,7 +5,7 @@ import { ImageStyle } from 'react-native-fast-image';
export interface IEmoji { export interface IEmoji {
content: string; content: string;
name: string; name: string;
extension: string; extension?: string;
isCustom: boolean; isCustom: boolean;
count?: number; count?: number;
} }
@ -30,11 +30,31 @@ export interface ICustomEmojiModel {
export interface IEmojiCategory { export interface IEmojiCategory {
baseUrl: string; baseUrl: string;
emojis: IEmoji[]; emojis: (IEmoji | string)[];
onEmojiSelected: (emoji: IEmoji) => void; onEmojiSelected: (emoji: IEmoji | string) => void;
width: number | null;
style: StyleProp<ImageStyle>; style: StyleProp<ImageStyle>;
tabLabel: string; tabsCount: number;
}
export type IEmojiCategoryName =
| 'frequentlyUsed'
| 'custom'
| 'people'
| 'nature'
| 'food'
| 'activity'
| 'travel'
| 'objects'
| 'symbols'
| 'flags';
export interface IEmojiPickerCategory {
title: IEmojiCategoryName;
frequentlyUsed: (IEmoji | string)[];
customEmojis: IEmoji[];
handleEmojiSelect: (emoji: IEmoji | string) => void;
baseUrl: string;
tabsCount: number;
} }
export type TGetCustomEmoji = (name: string) => any; export type TGetCustomEmoji = (name: string) => any;

View File

@ -482,6 +482,7 @@
"Search_Messages": "Search Messages", "Search_Messages": "Search Messages",
"Search": "Search", "Search": "Search",
"Search_by": "Search by", "Search_by": "Search by",
"Search_emoji": "Search emoji",
"Search_global_users": "Search for global users", "Search_global_users": "Search for global users",
"Search_global_users_description": "If you turn-on, you can search for any user from others companies or servers.", "Search_global_users_description": "If you turn-on, you can search for any user from others companies or servers.",
"Seconds": "{{second}} seconds", "Seconds": "{{second}} seconds",

View File

@ -1,85 +1,98 @@
import React from 'react'; import React from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { connect } from 'react-redux'; import { Q } from '@nozbe/watermelondb';
import Modal from 'react-native-modal';
import EmojiPicker from '../../containers/EmojiPicker'; import EmojiPicker from '../../containers/EmojiPicker';
import { isAndroid } from '../../lib/methods/helpers'; import { useTheme } from '../../theme';
import { themes } from '../../lib/constants';
import { TSupportedThemes, withTheme } from '../../theme';
import styles from './styles'; import styles from './styles';
import { IApplicationState } from '../../definitions'; import { IEmoji } from '../../definitions';
import { EventTypes } from '../../containers/EmojiPicker/interfaces';
const margin = isAndroid ? 40 : 20; import { FormTextInput } from '../../containers/TextInput/FormTextInput';
const maxSize = 400; import I18n from '../../i18n';
import { sanitizeLikeString } from '../../lib/database/utils';
import { emojis } from '../../containers/EmojiPicker/emojis';
import database from '../../lib/database';
import { debounce } from '../../lib/methods/helpers/debounce';
interface IReactionPickerProps { interface IReactionPickerProps {
message?: any; message?: any;
show: boolean; show: boolean;
isMasterDetail: boolean;
reactionClose: () => void; reactionClose: () => void;
onEmojiSelected: (shortname: string, id: string) => void; onEmojiSelected: (shortname: string, id: string) => void;
width: number; width: number;
height: number; height: number;
theme: TSupportedThemes;
} }
class ReactionPicker extends React.Component<IReactionPickerProps> { const MAX_EMOJIS_TO_DISPLAY = 20;
shouldComponentUpdate(nextProps: IReactionPickerProps) {
const { show, width, height } = this.props;
return nextProps.show !== show || width !== nextProps.width || height !== nextProps.height;
}
onEmojiSelected = (emoji: string, shortname?: string) => { const ReactionPicker = ({ onEmojiSelected, message, reactionClose }: IReactionPickerProps): React.ReactElement => {
const { colors } = useTheme();
const [searchText, setSearchText] = React.useState<string>('');
const [searchedEmojis, setSearchedEmojis] = React.useState<(string | IEmoji)[]>([]);
const [searching, setSearching] = React.useState<boolean>(false);
const handleTextChange = (text: string) => {
setSearching(text !== '');
setSearchText(text);
searchEmojis(text);
};
const searchEmojis = debounce(async (keyword: string) => {
const likeString = sanitizeLikeString(keyword);
const whereClause = [];
if (likeString) {
whereClause.push(Q.where('name', Q.like(`${likeString}%`)));
}
const db = database.active;
const customEmojisCollection = await (
await db
.get('custom_emojis')
.query(...whereClause)
.fetch()
).slice(0, MAX_EMOJIS_TO_DISPLAY / 2);
const customEmojis = customEmojisCollection?.map(emoji => ({
isCustom: true,
content: emoji?.name,
name: emoji?.name,
extension: emoji?.extension
})) as IEmoji[];
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MAX_EMOJIS_TO_DISPLAY / 2);
const mergedEmojis = [...customEmojis, ...filteredEmojis];
setSearchedEmojis(mergedEmojis);
}, 300);
const handleEmojiSelect = (_eventType: EventTypes, emoji?: string, shortname?: string) => {
// standard emojis: `emoji` is unicode and `shortname` is :joy: // standard emojis: `emoji` is unicode and `shortname` is :joy:
// custom emojis: only `emoji` is returned with shortname type (:joy:) // custom emojis: only `emoji` is returned with shortname type (:joy:)
// to set reactions, we need shortname type // to set reactions, we need shortname type
const { onEmojiSelected, message } = this.props;
if (message) { if (message) {
// @ts-ignore
onEmojiSelected(shortname || emoji, message.id); onEmojiSelected(shortname || emoji, message.id);
} }
reactionClose();
}; };
render() { return (
const { width, height, show, reactionClose, isMasterDetail, theme } = this.props; <View style={styles.reactionPickerContainer} testID='reaction-picker'>
<View style={styles.searchbarContainer}>
<FormTextInput
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit
placeholder={I18n.t('Search_emoji')}
returnKeyType='search'
underlineColorAndroid='transparent'
onChangeText={handleTextChange}
style={[styles.reactionPickerSearchbar, { backgroundColor: colors.borderColor }]}
value={searchText}
onClearInput={() => handleTextChange('')}
iconRight={'search'}
testID='reaction-picker-searchbar'
/>
</View>
<EmojiPicker onItemClicked={handleEmojiSelect} searching={searching} searchedEmojis={searchedEmojis} />
</View>
);
};
let widthStyle = width - margin; export default ReactionPicker;
let heightStyle = Math.min(width, height) - margin * 2;
if (isMasterDetail) {
widthStyle = maxSize;
heightStyle = maxSize;
}
return show ? (
<Modal
isVisible={show}
style={{ alignItems: 'center' }}
onBackdropPress={reactionClose}
onBackButtonPress={reactionClose}
animationIn='fadeIn'
animationOut='fadeOut'
backdropOpacity={themes[theme].backdropOpacity}
>
<View
style={[
styles.reactionPickerContainer,
{
width: widthStyle,
height: heightStyle
}
]}
testID='reaction-picker'
>
<EmojiPicker theme={theme} onEmojiSelected={this.onEmojiSelected} />
</View>
</Modal>
) : null;
}
}
const mapStateToProps = (state: IApplicationState) => ({
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(withTheme(ReactionPicker));

View File

@ -828,12 +828,32 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
this.setState({ selectedMessage: undefined, replying: false, replyWithMention: false }); this.setState({ selectedMessage: undefined, replying: false, replyWithMention: false });
}; };
showReactionPicker = () => {
const { showActionSheet, width, height } = this.props;
const { reacting, selectedMessage } = this.state;
showActionSheet({
children: (
<ReactionPicker
show={reacting}
message={selectedMessage}
onEmojiSelected={this.onReactionPress}
reactionClose={this.onReactionClose}
width={width}
height={height}
/>
),
snaps: [400, '100%'],
enableContentPanningGesture: false
});
};
onReactionInit = (message: TAnyMessageModel) => { onReactionInit = (message: TAnyMessageModel) => {
this.setState({ selectedMessage: message, reacting: true }); this.setState({ selectedMessage: message }, this.showReactionPicker);
}; };
onReactionClose = () => { onReactionClose = () => {
this.setState({ selectedMessage: undefined, reacting: false }); const { hideActionSheet } = this.props;
this.setState({ selectedMessage: undefined, reacting: false }, hideActionSheet);
}; };
onMessageLongPress = (message: TAnyMessageModel) => { onMessageLongPress = (message: TAnyMessageModel) => {
@ -1493,8 +1513,8 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
render() { render() {
console.count(`${this.constructor.name}.render calls`); console.count(`${this.constructor.name}.render calls`);
const { room, selectedMessage, loading, reacting } = this.state; const { room, loading } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height, serverVersion } = this.props; const { user, baseUrl, theme, navigation, Hide_System_Messages, width, serverVersion } = this.props;
const { rid, t } = room; const { rid, t } = room;
let sysMes; let sysMes;
let bannerClosed; let bannerClosed;
@ -1526,15 +1546,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
/> />
{this.renderFooter()} {this.renderFooter()}
{this.renderActions()} {this.renderActions()}
<ReactionPicker
show={reacting}
message={selectedMessage}
onEmojiSelected={this.onReactionPress}
reactionClose={this.onReactionClose}
width={width}
height={height}
theme={theme}
/>
<UploadProgress rid={rid} user={user} baseUrl={baseUrl} width={width} /> <UploadProgress rid={rid} user={user} baseUrl={baseUrl} width={width} />
<JoinCode ref={this.joinCode} onJoin={this.onJoin} rid={rid} t={t} theme={theme} /> <JoinCode ref={this.joinCode} onJoin={this.onJoin} rid={rid} t={t} theme={theme} />
</SafeAreaView> </SafeAreaView>

View File

@ -15,9 +15,7 @@ export default StyleSheet.create({
marginVertical: 15 marginVertical: 15
}, },
reactionPickerContainer: { reactionPickerContainer: {
borderRadius: 4, height: '100%'
flexDirection: 'column',
overflow: 'hidden'
}, },
bannerContainer: { bannerContainer: {
paddingVertical: 12, paddingVertical: 12,
@ -64,5 +62,14 @@ export default StyleSheet.create({
previewMode: { previewMode: {
fontSize: 16, fontSize: 16,
...sharedStyles.textMedium ...sharedStyles.textMedium
},
searchbarContainer: {
height: 55,
marginBottom: 10,
paddingHorizontal: 15
},
reactionPickerSearchbar: {
paddingHorizontal: 20,
minHeight: 48
} }
}); });

View File

@ -60,9 +60,7 @@ describe('Room screen', () => {
}); });
it('should have open emoji button', async () => { it('should have open emoji button', async () => {
if (device.getPlatform() === 'android') { await expect(element(by.id('messagebox-open-emoji'))).toExist();
await expect(element(by.id('messagebox-open-emoji'))).toExist();
}
}); });
it('should have message input', async () => { it('should have message input', async () => {
@ -87,23 +85,77 @@ describe('Room screen', () => {
}); });
it('should show/hide emoji keyboard', async () => { it('should show/hide emoji keyboard', async () => {
if (device.getPlatform() === 'android') { await element(by.id('messagebox-open-emoji')).tap();
await element(by.id('messagebox-open-emoji')).tap(); await waitFor(element(by.id('messagebox-keyboard-emoji')))
await waitFor(element(by.id('messagebox-keyboard-emoji'))) .toExist()
.toExist() .withTimeout(10000);
.withTimeout(10000); await expect(element(by.id('messagebox-close-emoji'))).toExist();
await expect(element(by.id('messagebox-close-emoji'))).toExist(); await expect(element(by.id('messagebox-open-emoji'))).toBeNotVisible();
await expect(element(by.id('messagebox-open-emoji'))).toBeNotVisible(); await element(by.id('messagebox-close-emoji')).tap();
await element(by.id('messagebox-close-emoji')).tap(); await waitFor(element(by.id('messagebox-keyboard-emoji')))
await waitFor(element(by.id('messagebox-keyboard-emoji'))) .not.toBeVisible()
.toBeNotVisible() .withTimeout(10000);
.withTimeout(10000); await expect(element(by.id('messagebox-close-emoji'))).toBeNotVisible();
await expect(element(by.id('messagebox-close-emoji'))).toBeNotVisible(); await expect(element(by.id('messagebox-open-emoji'))).toExist();
await expect(element(by.id('messagebox-open-emoji'))).toExist(); });
}
it('should clear the emoji', async () => {
await element(by.id('messagebox-open-emoji')).tap();
await waitFor(element(by.id('messagebox-keyboard-emoji')))
.toExist()
.withTimeout(10000);
await expect(element(by.id('emoji-picker-tab-emoji'))).toExist();
await element(by.id('emoji-picker-tab-emoji')).tap();
await expect(element(by.id('emoji-smiley'))).toExist();
await element(by.id('emoji-smiley')).tap();
await waitFor(element(by.id('emoji-picker-backspace')))
.toExist()
.withTimeout(4000);
await expect(element(by.id('messagebox-input'))).toHaveText('😃');
await expect(element(by.id('emoji-picker-backspace'))).toExist();
await element(by.id('emoji-picker-backspace')).tap();
await expect(element(by.id('messagebox-input'))).toHaveText('');
await element(by.id('messagebox-close-emoji')).tap();
await waitFor(element(by.id('messagebox-keyboard-emoji')))
.not.toBeVisible()
.withTimeout(10000);
});
it('should search emojis, go back to EmojiKeyboard and then close the EmojiKeyboard', async () => {
await element(by.id('messagebox-open-emoji')).tap();
await waitFor(element(by.id('emoji-picker-search')))
.toExist()
.withTimeout(4000);
await element(by.id('emoji-picker-search')).tap();
await waitFor(element(by.id('emoji-searchbar-input')))
.toExist()
.withTimeout(2000);
await element(by.id('emoji-searchbar-input')).typeText('smiley');
await waitFor(element(by.id('searched-emoji-smiley')))
.toExist()
.withTimeout(2000);
await element(by.id('searched-emoji-smiley')).tap();
await expect(element(by.id('messagebox-input'))).toHaveText('😃');
await element(by.id('emoji-searchbar-input')).clearText();
await element(by.id('emoji-searchbar-input')).typeText('random-text');
await waitFor(element(by.id('no-results-found')))
.toExist()
.withTimeout(2000);
await element(by.id('emoji-searchbar-input')).clearText();
await expect(element(by.id('openback-emoji-keyboard'))).toExist();
await element(by.id('openback-emoji-keyboard')).tap();
await waitFor(element(by.id('emoji-searchbar-input')))
.not.toBeVisible()
.withTimeout(2000);
await expect(element(by.id('messagebox-close-emoji'))).toExist();
await element(by.id('messagebox-close-emoji')).tap();
await waitFor(element(by.id('messagebox-keyboard-emoji')))
.not.toBeVisible()
.withTimeout(10000);
}); });
it('should show/hide emoji autocomplete', async () => { it('should show/hide emoji autocomplete', async () => {
await element(by.id('messagebox-input')).clearText();
await element(by.id('messagebox-input')).typeText(':joy'); await element(by.id('messagebox-input')).typeText(':joy');
await sleep(300); await sleep(300);
await waitFor(element(by.id('messagebox-container'))) await waitFor(element(by.id('messagebox-container')))
@ -220,10 +272,8 @@ describe('Room screen', () => {
await expect(element(by.id('action-sheet-handle'))).toBeVisible(); await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by[textMatcher]('Permalink')).atIndex(0).tap(); await element(by[textMatcher]('Permalink')).atIndex(0).tap();
// TODO: test clipboard // TODO: test clipboard
}); });
it('should copy message', async () => { it('should copy message', async () => {
await element(by[textMatcher](`${data.random}message`)) await element(by[textMatcher](`${data.random}message`))
.atIndex(0) .atIndex(0)
@ -234,13 +284,10 @@ describe('Room screen', () => {
await expect(element(by.id('action-sheet-handle'))).toBeVisible(); await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by[textMatcher]('Copy')).atIndex(0).tap(); await element(by[textMatcher]('Copy')).atIndex(0).tap();
// TODO: test clipboard // TODO: test clipboard
}); });
it('should star message', async () => { it('should star message', async () => {
await starMessage('message'); await starMessage('message');
await sleep(1000); // https://github.com/RocketChat/Rocket.Chat.ReactNative/issues/2324 await sleep(1000); // https://github.com/RocketChat/Rocket.Chat.ReactNative/issues/2324
await element(by[textMatcher](`${data.random}message`)) await element(by[textMatcher](`${data.random}message`))
.atIndex(0) .atIndex(0)
@ -255,7 +302,6 @@ describe('Room screen', () => {
.withTimeout(6000); .withTimeout(6000);
await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8); await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8);
}); });
it('should react to message', async () => { it('should react to message', async () => {
await waitFor(element(by[textMatcher](`${data.random}message`))) await waitFor(element(by[textMatcher](`${data.random}message`)))
.toExist() .toExist()
@ -272,19 +318,41 @@ describe('Room screen', () => {
await expect(element(by.id('action-sheet-handle'))).toBeVisible(); await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by.id('add-reaction')).tap(); await element(by.id('add-reaction')).tap();
await waitFor(element(by.id('reaction-picker'))) await waitFor(element(by.id('emoji-picker-tab-emoji')))
.toBeVisible() .toExist()
.withTimeout(2000); .withTimeout(2000);
await element(by.id('reaction-picker-😃')).tap(); await element(by.id('action-sheet-handle')).swipe('up', 'fast', 1);
await waitFor(element(by.id('reaction-picker-grinning'))) await element(by.id('emoji-picker-tab-emoji')).tap();
await waitFor(element(by.id('emoji-grinning')))
.toExist() .toExist()
.withTimeout(10000); .withTimeout(10000);
await element(by.id('reaction-picker-grinning')).tap(); await element(by.id('emoji-grinning')).tap();
await waitFor(element(by.id('message-reaction-:grinning:'))) await waitFor(element(by.id('message-reaction-:grinning:')))
.toExist() .toExist()
.withTimeout(60000); .withTimeout(60000);
}); });
it('should search emojis in the reaction picker and react', async () => {
await element(by[textMatcher](`${data.random}message`))
.atIndex(0)
.longPress();
await waitFor(element(by.id('action-sheet')))
.toExist()
.withTimeout(2000);
await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by.id('add-reaction')).tap();
await waitFor(element(by.id('reaction-picker-searchbar')))
.toExist()
.withTimeout(2000);
await element(by.id('reaction-picker-searchbar')).typeText('smile');
await waitFor(element(by.id('emoji-smile')))
.toExist()
.withTimeout(4000);
await element(by.id('emoji-smile')).tap();
await waitFor(element(by.id('message-reaction-:smile:')))
.toExist()
.withTimeout(60000);
});
it('should react to message with frequently used emoji', async () => { it('should react to message with frequently used emoji', async () => {
await element(by[textMatcher](`${data.random}message`)) await element(by[textMatcher](`${data.random}message`))
.atIndex(0) .atIndex(0)
@ -302,36 +370,37 @@ describe('Room screen', () => {
.toBeVisible() .toBeVisible()
.withTimeout(60000); .withTimeout(60000);
}); });
it('should show reaction picker on add reaction button pressed and have frequently used emoji, and dismiss review nag', async () => { it('should show reaction picker on add reaction button pressed and have frequently used emoji, and dismiss review nag', async () => {
await element(by.id('message-add-reaction')).tap(); await element(by.id('message-add-reaction')).tap();
await waitFor(element(by.id('reaction-picker'))) await waitFor(element(by.id('action-sheet')))
.toExist() .toExist()
.withTimeout(2000); .withTimeout(2000);
await waitFor(element(by.id('reaction-picker-grinning'))) await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 1);
await waitFor(element(by.id('emoji-grinning')))
.toExist()
.withTimeout(4000);
await waitFor(element(by.id('emoji-picker-tab-emoji')))
.toExist() .toExist()
.withTimeout(2000); .withTimeout(2000);
await element(by.id('reaction-picker-😃')).tap(); await element(by.id('emoji-picker-tab-emoji')).tap();
await waitFor(element(by.id('reaction-picker-grimacing'))) await waitFor(element(by.id('emoji-wink')))
.toExist() .toExist()
.withTimeout(2000); .withTimeout(10000);
await element(by.id('reaction-picker-grimacing')).tap(); await element(by.id('emoji-wink')).tap();
await waitFor(element(by.id('message-reaction-:grimacing:'))) await waitFor(element(by.id('message-reaction-:wink:')))
.toExist() .toExist()
.withTimeout(60000); .withTimeout(60000);
}); });
it('should remove reaction', async () => { it('should remove reaction', async () => {
await element(by.id('message-reaction-:grinning:')).tap(); await element(by.id('message-reaction-:smile:')).tap();
await waitFor(element(by.id('message-reaction-:grinning:'))) await waitFor(element(by.id('message-reaction-:smile:')))
.toBeNotVisible() .not.toExist()
.withTimeout(60000); .withTimeout(60000);
}); });
it('should ask for review', async () => { it('should ask for review', async () => {
await dismissReviewNag(); // TODO: Create a proper test for this elsewhere. await dismissReviewNag(); // TODO: Create a proper test for this elsewhere.
}); });
it('should edit message', async () => { it('should edit message', async () => {
await mockMessage('edit'); await mockMessage('edit');
await element(by[textMatcher](`${data.random}edit`)) await element(by[textMatcher](`${data.random}edit`))
@ -352,7 +421,6 @@ describe('Room screen', () => {
.toExist() .toExist()
.withTimeout(60000); .withTimeout(60000);
}); });
it('should quote message', async () => { it('should quote message', async () => {
await mockMessage('quote'); await mockMessage('quote');
await element(by[textMatcher](`${data.random}quote`)) await element(by[textMatcher](`${data.random}quote`))
@ -369,14 +437,11 @@ describe('Room screen', () => {
.toExist() .toExist()
.withTimeout(2000); .withTimeout(2000);
await element(by.id('messagebox-send-message')).tap(); await element(by.id('messagebox-send-message')).tap();
// TODO: test if quote was sent // TODO: test if quote was sent
}); });
it('should pin message', async () => { it('should pin message', async () => {
await mockMessage('pin'); await mockMessage('pin');
await pinMessage('pin'); await pinMessage('pin');
await waitFor(element(by[textMatcher](`${data.random}pin`)).atIndex(0)) await waitFor(element(by[textMatcher](`${data.random}pin`)).atIndex(0))
.toExist() .toExist()
.withTimeout(5000); .withTimeout(5000);
@ -396,7 +461,6 @@ describe('Room screen', () => {
.withTimeout(2000); .withTimeout(2000);
await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8); await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8);
}); });
it('should delete message', async () => { it('should delete message', async () => {
await mockMessage('delete'); await mockMessage('delete');
await waitFor(element(by[textMatcher](`${data.random}delete`)).atIndex(0)).toBeVisible(); await waitFor(element(by[textMatcher](`${data.random}delete`)).atIndex(0)).toBeVisible();
@ -412,7 +476,6 @@ describe('Room screen', () => {
.toExist() .toExist()
.withTimeout(1000); .withTimeout(1000);
await element(by[textMatcher]('Delete')).atIndex(0).tap(); await element(by[textMatcher]('Delete')).atIndex(0).tap();
const deleteAlertMessage = 'You will not be able to recover this message!'; const deleteAlertMessage = 'You will not be able to recover this message!';
await waitFor(element(by[textMatcher](deleteAlertMessage)).atIndex(0)) await waitFor(element(by[textMatcher](deleteAlertMessage)).atIndex(0))
.toExist() .toExist()

View File

@ -105,6 +105,7 @@
"react-native-notifications": "^4.2.4", "react-native-notifications": "^4.2.4",
"react-native-notifier": "1.6.1", "react-native-notifier": "1.6.1",
"react-native-orientation-locker": "1.1.8", "react-native-orientation-locker": "1.1.8",
"react-native-pager-view": "^5.4.25",
"react-native-picker-select": "^8.0.4", "react-native-picker-select": "^8.0.4",
"react-native-platform-touchable": "1.1.1", "react-native-platform-touchable": "1.1.1",
"react-native-popover-view": "4.0.1", "react-native-popover-view": "4.0.1",
@ -118,6 +119,7 @@
"react-native-simple-crypto": "RocketChat/react-native-simple-crypto#0.5.1", "react-native-simple-crypto": "RocketChat/react-native-simple-crypto#0.5.1",
"react-native-slowlog": "^1.0.2", "react-native-slowlog": "^1.0.2",
"react-native-svg": "^12.3.0", "react-native-svg": "^12.3.0",
"react-native-tab-view": "^3.1.1",
"react-native-ui-lib": "RocketChat/react-native-ui-lib", "react-native-ui-lib": "RocketChat/react-native-ui-lib",
"react-native-vector-icons": "9.1.0", "react-native-vector-icons": "9.1.0",
"react-native-webview": "10.3.2", "react-native-webview": "10.3.2",

View File

@ -17096,6 +17096,11 @@ react-native-orientation-locker@1.1.8:
resolved "https://registry.yarnpkg.com/react-native-orientation-locker/-/react-native-orientation-locker-1.1.8.tgz#45d1c9e002496b8d286ec8932d6e3e7d341f9c85" resolved "https://registry.yarnpkg.com/react-native-orientation-locker/-/react-native-orientation-locker-1.1.8.tgz#45d1c9e002496b8d286ec8932d6e3e7d341f9c85"
integrity sha512-+Vd7x6O/3zGqYIMXpeDlaw3ma074Dtnocm8ryT9v5SvaiEcWSzII4frPgXaUcc/MiCq4OWZ1JtVoyw75mdomQw== integrity sha512-+Vd7x6O/3zGqYIMXpeDlaw3ma074Dtnocm8ryT9v5SvaiEcWSzII4frPgXaUcc/MiCq4OWZ1JtVoyw75mdomQw==
react-native-pager-view@^5.4.25:
version "5.4.25"
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-5.4.25.tgz#cd639d5387a7f3d5581b55a33c5faa1cbc200f97"
integrity sha512-3drrYwaLat2fYszymZe3nHMPASJ4aJMaxiejfA1V5SK3OygYmdtmV2u5prX7TnjueJzGSyyaCYEr2JlrRt4YPg==
react-native-picker-select@^8.0.4: react-native-picker-select@^8.0.4:
version "8.0.4" version "8.0.4"
resolved "https://registry.yarnpkg.com/react-native-picker-select/-/react-native-picker-select-8.0.4.tgz#3f7f1f42df69b06e7d2c10338288332a6c40fd10" resolved "https://registry.yarnpkg.com/react-native-picker-select/-/react-native-picker-select-8.0.4.tgz#3f7f1f42df69b06e7d2c10338288332a6c40fd10"
@ -17217,6 +17222,11 @@ react-native-swipe-gestures@^1.0.5:
resolved "https://registry.yarnpkg.com/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz#a172cb0f3e7478ccd681fd36b8bfbcdd098bde7c" resolved "https://registry.yarnpkg.com/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz#a172cb0f3e7478ccd681fd36b8bfbcdd098bde7c"
integrity sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw== integrity sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==
react-native-tab-view@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/react-native-tab-view/-/react-native-tab-view-3.1.1.tgz#1f8d7a835ab4f5b1b1407ec8dddc1053b53fa3c6"
integrity sha512-M5pRN6utQfytKWoKlKVzg5NbkYu308qNoW1khGTtEOTs1k14p2dHJ/BWOJoJYHKbPVUyZldbG9MFT7gUl4YHnw==
react-native-text-size@4.0.0-rc.1: react-native-text-size@4.0.0-rc.1:
version "4.0.0-rc.1" version "4.0.0-rc.1"
resolved "https://registry.yarnpkg.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz#1e048d345dd6a5a8e1269e0585c1a5948c478da5" resolved "https://registry.yarnpkg.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz#1e048d345dd6a5a8e1269e0585c1a5948c478da5"