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 }}
backgroundStyle={{ backgroundColor: colors.focusedBackground }}
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}
{...androidTablet}
>

View File

@ -1,75 +1,75 @@
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 styles from './styles';
import styles, { MIN_EMOJI_SIZE, MAX_EMOJI_SIZE } from './styles';
import CustomEmoji from './CustomEmoji';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { IEmoji, IEmojiCategory } from '../../definitions/IEmoji';
import { useTheme } from '../../theme';
import { isIOS } from '../../lib/methods/helpers';
import { useDimensions } from '../../dimensions';
const EMOJI_SIZE = 50;
const renderEmoji = (emoji: IEmoji, size: number, baseUrl: string) => {
if (emoji && emoji.isCustom) {
return (
<CustomEmoji
style={[styles.customCategoryEmoji, { height: size - 16, width: size - 16 }]}
emoji={emoji}
baseUrl={baseUrl}
/>
);
interface IEmojiProps {
emoji: string | IEmoji;
size: number;
baseUrl: string;
}
const Emoji = ({ emoji, size, baseUrl }: IEmojiProps): React.ReactElement => {
if (typeof emoji === 'string')
return (
<Text style={[styles.categoryEmoji, { height: size, width: size, fontSize: size - 14 }]}>
{shortnameToUnicode(`:${emoji}:`)}
</Text>
);
return (
<CustomEmoji style={[styles.customCategoryEmoji, { height: size - 16, width: size - 16 }]} emoji={emoji} baseUrl={baseUrl} />
);
};
class EmojiCategory extends React.Component<IEmojiCategory> {
renderItem(emoji: IEmoji) {
const { baseUrl, onEmojiSelected } = this.props;
return (
<TouchableOpacity
activeOpacity={0.7}
// @ts-ignore
key={emoji && emoji.isCustom ? emoji.content : emoji}
onPress={() => onEmojiSelected(emoji)}
testID={`reaction-picker-${emoji && emoji.isCustom ? emoji.content : emoji}`}
>
{renderEmoji(emoji, EMOJI_SIZE, baseUrl)}
</TouchableOpacity>
);
}
const EmojiCategory = ({ baseUrl, onEmojiSelected, emojis, tabsCount }: IEmojiCategory): React.ReactElement | null => {
const { colors } = useTheme();
const { width } = useDimensions();
const emojiSize = Math.min(Math.max(width / tabsCount, MIN_EMOJI_SIZE), MAX_EMOJI_SIZE);
const numColumns = Math.trunc(width / emojiSize);
const marginHorizontal = (width - numColumns * emojiSize) / 2;
render() {
const { emojis, width } = this.props;
const renderItem = (emoji: IEmoji | string) => (
<Pressable
key={typeof emoji === 'string' ? emoji : emoji.content}
onPress={() => onEmojiSelected(emoji)}
testID={`emoji-${typeof emoji === 'string' ? emoji : emoji.content}`}
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;
}
const numColumns = Math.trunc(width / EMOJI_SIZE);
const marginHorizontal = (width - numColumns * EMOJI_SIZE) / 2;
return (
<FlatList
contentContainerStyle={{ marginHorizontal }}
// rerender FlatList in case of width changes
key={`emoji-category-${width}`}
// @ts-ignore
keyExtractor={item => (item && item.isCustom && item.content) || item}
keyExtractor={item => (typeof item === 'string' ? item : item.content)}
data={emojis}
extraData={this.props}
renderItem={({ item }) => this.renderItem(item)}
extraData={{ baseUrl, width }}
renderItem={({ item }) => renderItem(item)}
numColumns={numColumns}
initialNumToRender={45}
removeClippedSubviews
contentContainerStyle={{ marginHorizontal }}
{...scrollPersistTaps}
keyboardDismissMode={'none'}
/>
);
}
}
};
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 { StyleProp, Text, TextStyle, TouchableOpacity, View } from 'react-native';
import { Pressable, View } from 'react-native';
import styles from './styles';
import { themes } from '../../lib/constants';
import { TSupportedThemes } from '../../theme';
interface ITabBarProps {
goToPage?: (page: number) => void;
activeTab?: number;
tabs?: string[];
tabEmojiStyle: StyleProp<TextStyle>;
theme: TSupportedThemes;
}
export default class TabBar extends React.Component<ITabBarProps> {
shouldComponentUpdate(nextProps: ITabBarProps) {
const { activeTab, theme } = this.props;
if (nextProps.activeTab !== activeTab) {
return true;
}
if (nextProps.theme !== theme) {
return true;
}
return false;
}
render() {
const { tabs, goToPage, tabEmojiStyle, activeTab, theme } = this.props;
import { useTheme } from '../../theme';
import { ITabBarProps } from './interfaces';
import { isIOS } from '../../lib/methods/helpers';
import { CustomIcon } from '../CustomIcon';
const TabBar = ({ tabs, activeTab, onPress, showFrequentlyUsed }: ITabBarProps): React.ReactElement => {
const { colors } = useTheme();
return (
<View style={styles.tabsContainer}>
{tabs?.map((tab, i) => (
<TouchableOpacity
activeOpacity={0.7}
key={tab}
onPress={() => {
if (goToPage) {
goToPage(i);
{tabs?.map((tab, i) => {
if (i === 0 && !showFrequentlyUsed) return null;
return (
<Pressable
key={tab.key}
onPress={() => onPress(tab.key)}
testID={`emoji-picker-tab-${tab.key}`}
android_ripple={{ color: colors.bannerBackground }}
style={({ pressed }: { pressed: boolean }) => [
styles.tab,
{
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
}
}}
style={styles.tab}
testID={`reaction-picker-${tab}`}
]}
>
<Text style={[styles.tabEmoji, tabEmojiStyle]}>{tab}</Text>
{activeTab === i ? (
<View style={[styles.activeTabLine, { backgroundColor: themes[theme].tintColor }]} />
) : (
<View style={styles.tabLine} />
)}
</TouchableOpacity>
))}
<CustomIcon name={tab.key} size={24} color={activeTab === i ? colors.tintColor : colors.auxiliaryTintColor} />
<View style={activeTab === i ? [styles.activeTabLine, { backgroundColor: colors.tintColor }] : styles.tabLine} />
</Pressable>
);
})}
</View>
);
}
}
};
export default TabBar;

View File

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

View File

@ -2813,3 +2813,5 @@ export const emojis = [
'flag_tc',
'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 { StyleProp, TextStyle, View } from 'react-native';
import ScrollableTabView from 'react-native-scrollable-tab-view';
import { dequal } from 'dequal';
import { connect } from 'react-redux';
import orderBy from 'lodash/orderBy';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { ImageStyle } from 'react-native-fast-image';
import React, { useMemo, useState } from 'react';
import { View } from 'react-native';
import { TabView, SceneRendererProps, NavigationState } from 'react-native-tab-view';
import { shallowEqual } from 'react-redux';
import TabBar from './TabBar';
import EmojiCategory from './EmojiCategory';
import Footer from './Footer';
import styles from './styles';
import categories from './categories';
import database from '../../lib/database';
import { emojisByCategory } from './emojis';
import protectedFunction from '../../lib/methods/helpers/protectedFunction';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import log from '../../lib/methods/helpers/log';
import { themes } from '../../lib/constants';
import { TSupportedThemes } from '../../theme';
import { IEmoji, TGetCustomEmoji, IApplicationState, ICustomEmojis, TFrequentlyUsedEmojiModel } from '../../definitions';
import { useTheme } from '../../theme';
import { IEmoji, ICustomEmojis, IEmojiPickerCategory, IEmojiCategoryName } from '../../definitions';
import { useAppSelector } from '../../lib/hooks';
import { IEmojiPickerProps, EventTypes } from './interfaces';
import { useFrequentlyUsedEmoji, addFrequentlyUsed } from './frequentlyUsedEmojis';
import { TIconsName } from '../CustomIcon';
interface IEmojiPickerProps {
isMessageContainsOnlyEmoji?: boolean;
getCustomEmoji?: TGetCustomEmoji;
baseUrl: string;
customEmojis: ICustomEmojis;
style?: StyleProp<ImageStyle>;
theme: TSupportedThemes;
onEmojiSelected: (emoji: string, shortname?: string) => void;
tabEmojiStyle?: StyleProp<TextStyle>;
}
interface IEmojiPickerState {
frequentlyUsed: (string | { content?: string; extension?: string; isCustom: boolean })[];
customEmojis: any;
show: boolean;
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: [],
const Category = ({
title,
frequentlyUsed,
customEmojis,
show: false,
width: null
handleEmojiSelect,
baseUrl,
tabsCount
}: IEmojiPickerCategory): React.ReactElement => {
let emojis: (IEmoji | string)[] = [];
if (title === 'frequentlyUsed') {
emojis = frequentlyUsed;
} else if (title === 'custom') {
emojis = customEmojis;
} else {
emojis = emojisByCategory[title];
}
return (
<EmojiCategory
emojis={emojis}
onEmojiSelected={(emoji: IEmoji | string) => handleEmojiSelect(emoji)}
style={styles.categoryContainer}
baseUrl={baseUrl}
tabsCount={tabsCount}
/>
);
};
}
async componentDidMount() {
await this.updateFrequentlyUsed();
this.setState({ show: true });
}
const EmojiPicker = ({
onItemClicked,
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 { frequentlyUsed, show, width } = this.state;
const { theme } = this.props;
if (nextProps.theme !== theme) {
return true;
}
if (nextState.show !== show) {
return true;
}
if (nextState.width !== width) {
return true;
}
if (!dequal(nextState.frequentlyUsed, frequentlyUsed)) {
return true;
}
return false;
}
const baseUrl = useAppSelector(state => state.server?.server);
const allCustomEmojis: ICustomEmojis = useAppSelector(state => state.customEmojis, shallowEqual);
const customEmojis: IEmoji[] = useMemo(
() =>
Object.keys(allCustomEmojis)
.filter(item => item === allCustomEmojis[item].name)
.map(item => ({
content: allCustomEmojis[item].name,
name: allCustomEmojis[item].name,
extension: allCustomEmojis[item].extension,
isCustom: true
})),
[allCustomEmojis]
);
onEmojiSelected = (emoji: IEmoji) => {
const handleEmojiSelect = (emoji: IEmoji | string) => {
try {
const { onEmojiSelected } = this.props;
if (emoji.isCustom) {
this._addFrequentlyUsed({
if (typeof emoji === 'string') {
addFrequentlyUsed({ content: emoji, name: emoji, isCustom: false });
const shortname = `:${emoji}:`;
onItemClicked(EventTypes.EMOJI_PRESSED, shortnameToUnicode(shortname), shortname);
} else {
addFrequentlyUsed({
content: emoji.content,
name: emoji.name,
extension: emoji.extension,
isCustom: true
});
onEmojiSelected(`:${emoji.content}:`);
} else {
const content = emoji;
this._addFrequentlyUsed({ content, isCustom: false });
const shortname = `:${emoji}:`;
onEmojiSelected(shortnameToUnicode(shortname), shortname);
onItemClicked(EventTypes.EMOJI_PRESSED, `:${emoji.content}:`);
}
} catch (e) {
log(e);
}
};
_addFrequentlyUsed = protectedFunction(async (emoji: IEmoji) => {
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
}
const tabsCount = frequentlyUsed.length === 0 ? categories.length - 1 : categories.length;
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 }, 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 Route = {
key: TIconsName;
title: IEmojiCategoryName;
};
onLayout = ({
nativeEvent: {
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}
/>
type State = NavigationState<Route>;
const renderTabBar = (props: SceneRendererProps & { navigationState: State }) => (
<TabBar tabs={categories} onPress={props.jumpTo} activeTab={index} showFrequentlyUsed={frequentlyUsed.length > 0} />
);
}
render() {
const { show, frequentlyUsed } = this.state;
const { tabEmojiStyle, theme } = this.props;
if (!show) {
if (!loaded) {
return null;
}
return (
<View onLayout={this.onLayout} style={{ flex: 1 }}>
<ScrollableTabView
renderTabBar={() => <TabBar tabEmojiStyle={tabEmojiStyle} theme={theme} />}
contentProps={{
keyboardShouldPersistTaps: 'always',
keyboardDismissMode: 'none'
}}
style={{ backgroundColor: themes[theme].focusedBackground }}
>
{categories.tabs.map((tab: any, i) =>
i === 0 && frequentlyUsed.length === 0
? null // when no frequentlyUsed don't show the tab
: this.renderCategory(tab.category, i, tab.tabLabel)
<View style={styles.emojiPickerContainer}>
{searching ? (
<EmojiCategory
emojis={searchedEmojis}
onEmojiSelected={(emoji: IEmoji | string) => handleEmojiSelect(emoji)}
style={styles.categoryContainer}
baseUrl={baseUrl}
tabsCount={tabsCount}
/>
) : (
<TabView
lazy
navigationState={{ index, routes }}
renderScene={({ route }: { route: Route }) => (
<Category
key={route.key}
title={route.title}
frequentlyUsed={frequentlyUsed}
customEmojis={customEmojis}
handleEmojiSelect={handleEmojiSelect}
baseUrl={baseUrl}
tabsCount={tabsCount}
/>
)}
onIndexChange={setIndex}
style={{ backgroundColor: colors.focusedBackground }}
renderTabBar={renderTabBar}
/>
)}
{isEmojiKeyboard && (
<Footer
onSearchPressed={() => onItemClicked(EventTypes.SEARCH_PRESSED)}
onBackspacePressed={() => onItemClicked(EventTypes.BACKSPACE_PRESSED)}
/>
)}
</ScrollableTabView>
</View>
);
}
}
};
const mapStateToProps = (state: IApplicationState) => ({
customEmojis: state.customEmojis,
baseUrl: state.share.server.server || state.server.server
});
export default connect(mapStateToProps)(EmojiPicker);
export default 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';
export const MAX_EMOJI_SIZE = 50;
export const MIN_EMOJI_SIZE = 42;
export default StyleSheet.create({
container: {
flex: 1
},
tabsContainer: {
height: 45,
flexDirection: 'row',
paddingTop: 5
flexDirection: 'row'
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 10
paddingVertical: 10,
width: 44
},
tabEmoji: {
fontSize: 20,
@ -54,5 +57,19 @@ export default StyleSheet.create({
},
customCategoryEmoji: {
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 { TAnyMessageModel, TFrequentlyUsedEmojiModel } from '../../definitions';
import Touch from '../Touch';
import { DEFAULT_EMOJIS } from '../EmojiPicker/emojis';
type TItem = TFrequentlyUsedEmojiModel | string;
@ -69,8 +70,6 @@ const keyExtractor = (item: TItem) => {
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 emojiModel = item as TFrequentlyUsedEmojiModel;
const emoji = (emojiModel.id ? emojiModel.content : item) as string;

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard';
import ImagePicker, { Image, ImageOrVideo, Options } from 'react-native-image-crop-picker';
@ -34,7 +34,8 @@ import {
MENTIONS_TRACKING_TYPE_EMOJIS,
MENTIONS_TRACKING_TYPE_ROOMS,
MENTIONS_TRACKING_TYPE_USERS,
TIMEOUT_CLOSE_EMOJI
TIMEOUT_CLOSE_EMOJI,
MAX_EMOJIS_TO_DISPLAY
} from './constants';
import CommandsPreview from './CommandsPreview';
import { getUserSelector } from '../../selectors/login';
@ -59,6 +60,9 @@ import { hasPermission, debounce, isAndroid, isIOS, isTablet, compareServerVersi
import { Services } from '../../lib/services';
import { TSupportedThemes } from '../../theme';
import { ChatsStackParamList } from '../../stacks/types';
import { EventTypes } from '../EmojiPicker/interfaces';
import EmojiSearchbar from './EmojiSearchbar';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
require('./EmojiKeyboard');
@ -129,6 +133,8 @@ interface IMessageBoxState {
tshow: boolean;
mentionLoading: boolean;
permissionToUpload: boolean;
showEmojiSearchbar: boolean;
searchedEmojis: any[];
}
class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
@ -160,6 +166,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
private typingTimeout: any;
private emojiSearchbarRef: any;
static defaultProps = {
message: {
id: ''
@ -183,7 +191,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
command: {},
tshow: this.sendThreadToChannel,
mentionLoading: false,
permissionToUpload: true
permissionToUpload: true,
showEmojiSearchbar: false,
searchedEmojis: []
};
this.text = '';
this.selection = { start: 0, end: 0 };
@ -209,6 +219,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
...videoPickerConfig,
...libPickerLabels
};
BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
}
get sendThreadToChannel() {
@ -326,7 +338,9 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
tshow,
mentionLoading,
trackingType,
permissionToUpload
permissionToUpload,
showEmojiSearchbar,
searchedEmojis
} = this.state;
const {
@ -394,6 +408,12 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
if (nextProps.goToCannedResponses !== goToCannedResponses) {
return true;
}
if (nextState.showEmojiSearchbar !== showEmojiSearchbar) {
return true;
}
if (!dequal(nextState.searchedEmojis, searchedEmojis)) {
return true;
}
return false;
}
@ -438,6 +458,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
if (isTablet) {
EventEmiter.removeListener(KEY_COMMAND, this.handleCommands);
}
BackHandler.removeEventListener('hardwareBackPress', this.handleBackPress);
}
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 { emoji } = params;
let newText = '';
// if messagebox has an active cursor
const { start, end } = this.selection;
const cursor = Math.max(start, end);
let newCursor;
switch (eventType) {
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)}`;
const newCursor = cursor + emoji.length;
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) => {
@ -621,16 +669,20 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.setState({ mentions: res, mentionLoading: false });
}, 300);
getEmojis = debounce(async (keyword: any) => {
const db = database.active;
const customEmojisCollection = db.get('custom_emojis');
getCustomEmojis = async (keyword: any, count: number) => {
const likeString = sanitizeLikeString(keyword);
const whereClause = [];
if (likeString) {
whereClause.push(Q.where('name', Q.like(`${likeString}%`)));
}
let customEmojis = await customEmojisCollection.query(...whereClause).fetch();
customEmojis = customEmojis.slice(0, MENTIONS_COUNT_TO_DISPLAY);
const db = database.active;
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 mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis || [], mentionLoading: false });
@ -881,7 +933,8 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
openEmoji = () => {
logEvent(events.ROOM_OPEN_EMOJI);
this.setState({ showEmojiKeyboard: true });
this.setState({ showEmojiKeyboard: true, showEmojiSearchbar: false });
this.stopTrackingMention();
};
recordingCallback = (recording: any) => {
@ -906,6 +959,15 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.setState({ showEmojiKeyboard: false });
};
closeEmojiKeyboardAndFocus = () => {
this.closeEmoji();
this.focus();
};
closeEmojiSearchbar = () => {
this.setState({ showEmojiSearchbar: false });
};
closeEmojiAndAction = (action?: Function, params?: any) => {
const { showEmojiKeyboard } = this.state;
@ -926,7 +988,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
this.clearInput();
this.debouncedOnChangeText.stop();
this.closeEmoji();
this.closeEmojiKeyboardAndFocus();
this.stopTrackingMention();
this.handleTyping(false);
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 = () => {
const {
recording,
@ -1153,7 +1267,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
editing={editing}
editCancel={this.editCancel}
openEmoji={this.openEmoji}
closeEmoji={this.closeEmoji}
closeEmoji={this.closeEmojiKeyboardAndFocus}
/>
<TextInput
ref={component => (this.component = component)}
@ -1169,6 +1283,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
defaultValue=''
multiline
testID={`messagebox-input${tmid ? '-thread' : ''}`}
onFocus={this.closeEmojiSearchbar}
{...isAndroidTablet}
/>
<RightButtons
@ -1197,6 +1312,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
{recordAudio}
</View>
{this.renderSendToChannel()}
{this.renderEmojiSearchbar()}
</View>
{children}
</>
@ -1224,7 +1340,7 @@ class MessageBox extends Component<IMessageBoxProps, IMessageBoxState> {
kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null}
kbInitialProps={{ theme }}
onKeyboardResigned={this.onKeyboardResigned}
onItemSelected={this.onEmojiSelected}
onItemSelected={this.onKeyboardItemSelected}
trackInteractive
requiresSameParentToManageScrollView
addBottomView

View File

@ -157,5 +157,34 @@ export default StyleSheet.create({
fontSize: 12,
marginLeft: 4,
...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 ScrollableTabView from 'react-native-scrollable-tab-view';
import { FlatList } from 'react-native-gesture-handler';
import { TabView, SceneRendererProps, NavigationState } from 'react-native-tab-view';
import Emoji from './message/Emoji';
import { useTheme } from '../theme';
@ -30,6 +30,12 @@ const styles = StyleSheet.create({
customEmojiStyle: { width: 25, height: 25 }
});
type Route = {
key: string;
reaction: IReaction;
};
type State = NavigationState<Route>;
interface IReactionsListBase {
baseUrl: string;
getCustomEmoji: TGetCustomEmoji;
@ -41,24 +47,23 @@ interface IReactionsListProps extends IReactionsListBase {
}
interface ITabBarItem extends IReactionsListBase {
tab: IReaction;
index: number;
goToPage?: (index: number) => void;
tab: { key: string; reaction: IReaction };
jumpTo?: (key: string) => void;
}
interface IReactionsTabBar extends IReactionsListBase {
activeTab?: number;
tabs?: IReaction[];
goToPage?: (index: number) => void;
tabs?: { key: string; reaction: IReaction }[];
jumpTo?: (key: string) => void;
width: number;
}
const TabBarItem = ({ tab, index, goToPage, baseUrl, getCustomEmoji }: ITabBarItem) => {
const TabBarItem = ({ tab, jumpTo, baseUrl, getCustomEmoji }: ITabBarItem) => {
const { colors } = useTheme();
return (
<Pressable
key={tab.emoji}
key={tab.key}
onPress={() => {
goToPage?.(index);
jumpTo?.(tab.key);
}}
style={({ pressed }: { pressed: boolean }) => ({
opacity: pressed ? 0.7 : 1
@ -66,19 +71,19 @@ const TabBarItem = ({ tab, index, goToPage, baseUrl, getCustomEmoji }: ITabBarIt
>
<View style={styles.tabBarItem}>
<Emoji
content={tab.emoji}
content={tab.key}
standardEmojiStyle={styles.standardEmojiStyle}
customEmojiStyle={styles.customEmojiStyle}
baseUrl={baseUrl}
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>
</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 { colors } = useTheme();
return (
@ -94,7 +99,7 @@ const ReactionsTabBar = ({ tabs, activeTab, goToPage, baseUrl, getCustomEmoji, w
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>
);
})}
@ -128,16 +133,30 @@ const UsersList = ({ tabLabel }: { tabLabel: IReaction }) => {
};
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
const sortedReactions = reactions?.sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length);
const routes = sortedReactions ? sortedReactions?.map(reaction => ({ key: reaction.emoji, reaction })) : [];
return (
<View style={styles.reactionsListContainer}>
<ScrollableTabView renderTabBar={() => <ReactionsTabBar baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} width={width} />}>
{sortedReactions?.map(reaction => (
<UsersList tabLabel={reaction} key={reaction.emoji} />
))}
</ScrollableTabView>
<TabView
lazy
navigationState={{ index, routes }}
renderScene={({ route }) => <UsersList tabLabel={route.reaction} />}
onIndexChange={setIndex}
renderTabBar={(props: SceneRendererProps & { navigationState: State }) => (
<ReactionsTabBar
tabs={routes}
jumpTo={props.jumpTo}
width={width}
baseUrl={baseUrl}
getCustomEmoji={getCustomEmoji}
activeTab={index}
/>
)}
/>
</View>
);
};

View File

@ -5,7 +5,7 @@ import { ImageStyle } from 'react-native-fast-image';
export interface IEmoji {
content: string;
name: string;
extension: string;
extension?: string;
isCustom: boolean;
count?: number;
}
@ -30,11 +30,31 @@ export interface ICustomEmojiModel {
export interface IEmojiCategory {
baseUrl: string;
emojis: IEmoji[];
onEmojiSelected: (emoji: IEmoji) => void;
width: number | null;
emojis: (IEmoji | string)[];
onEmojiSelected: (emoji: IEmoji | string) => void;
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;

View File

@ -482,6 +482,7 @@
"Search_Messages": "Search Messages",
"Search": "Search",
"Search_by": "Search by",
"Search_emoji": "Search emoji",
"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.",
"Seconds": "{{second}} seconds",

View File

@ -1,85 +1,98 @@
import React from 'react';
import { View } from 'react-native';
import { connect } from 'react-redux';
import Modal from 'react-native-modal';
import { Q } from '@nozbe/watermelondb';
import EmojiPicker from '../../containers/EmojiPicker';
import { isAndroid } from '../../lib/methods/helpers';
import { themes } from '../../lib/constants';
import { TSupportedThemes, withTheme } from '../../theme';
import { useTheme } from '../../theme';
import styles from './styles';
import { IApplicationState } from '../../definitions';
const margin = isAndroid ? 40 : 20;
const maxSize = 400;
import { IEmoji } from '../../definitions';
import { EventTypes } from '../../containers/EmojiPicker/interfaces';
import { FormTextInput } from '../../containers/TextInput/FormTextInput';
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 {
message?: any;
show: boolean;
isMasterDetail: boolean;
reactionClose: () => void;
onEmojiSelected: (shortname: string, id: string) => void;
width: number;
height: number;
theme: TSupportedThemes;
}
class ReactionPicker extends React.Component<IReactionPickerProps> {
shouldComponentUpdate(nextProps: IReactionPickerProps) {
const { show, width, height } = this.props;
return nextProps.show !== show || width !== nextProps.width || height !== nextProps.height;
}
const MAX_EMOJIS_TO_DISPLAY = 20;
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:
// custom emojis: only `emoji` is returned with shortname type (:joy:)
// to set reactions, we need shortname type
const { onEmojiSelected, message } = this.props;
if (message) {
// @ts-ignore
onEmojiSelected(shortname || emoji, message.id);
}
reactionClose();
};
render() {
const { width, height, show, reactionClose, isMasterDetail, theme } = this.props;
let widthStyle = width - margin;
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} />
return (
<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>
</Modal>
) : null;
}
}
<EmojiPicker onItemClicked={handleEmojiSelect} searching={searching} searchedEmojis={searchedEmojis} />
</View>
);
};
const mapStateToProps = (state: IApplicationState) => ({
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(withTheme(ReactionPicker));
export default ReactionPicker;

View File

@ -828,12 +828,32 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
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) => {
this.setState({ selectedMessage: message, reacting: true });
this.setState({ selectedMessage: message }, this.showReactionPicker);
};
onReactionClose = () => {
this.setState({ selectedMessage: undefined, reacting: false });
const { hideActionSheet } = this.props;
this.setState({ selectedMessage: undefined, reacting: false }, hideActionSheet);
};
onMessageLongPress = (message: TAnyMessageModel) => {
@ -1493,8 +1513,8 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
render() {
console.count(`${this.constructor.name}.render calls`);
const { room, selectedMessage, loading, reacting } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height, serverVersion } = this.props;
const { room, loading } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, serverVersion } = this.props;
const { rid, t } = room;
let sysMes;
let bannerClosed;
@ -1526,15 +1546,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
/>
{this.renderFooter()}
{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} />
<JoinCode ref={this.joinCode} onJoin={this.onJoin} rid={rid} t={t} theme={theme} />
</SafeAreaView>

View File

@ -15,9 +15,7 @@ export default StyleSheet.create({
marginVertical: 15
},
reactionPickerContainer: {
borderRadius: 4,
flexDirection: 'column',
overflow: 'hidden'
height: '100%'
},
bannerContainer: {
paddingVertical: 12,
@ -64,5 +62,14 @@ export default StyleSheet.create({
previewMode: {
fontSize: 16,
...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 () => {
if (device.getPlatform() === 'android') {
await expect(element(by.id('messagebox-open-emoji'))).toExist();
}
});
it('should have message input', async () => {
@ -87,7 +85,6 @@ describe('Room screen', () => {
});
it('should show/hide emoji keyboard', async () => {
if (device.getPlatform() === 'android') {
await element(by.id('messagebox-open-emoji')).tap();
await waitFor(element(by.id('messagebox-keyboard-emoji')))
.toExist()
@ -96,14 +93,69 @@ describe('Room screen', () => {
await expect(element(by.id('messagebox-open-emoji'))).toBeNotVisible();
await element(by.id('messagebox-close-emoji')).tap();
await waitFor(element(by.id('messagebox-keyboard-emoji')))
.toBeNotVisible()
.not.toBeVisible()
.withTimeout(10000);
await expect(element(by.id('messagebox-close-emoji'))).toBeNotVisible();
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 () => {
await element(by.id('messagebox-input')).clearText();
await element(by.id('messagebox-input')).typeText(':joy');
await sleep(300);
await waitFor(element(by.id('messagebox-container')))
@ -220,10 +272,8 @@ describe('Room screen', () => {
await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by[textMatcher]('Permalink')).atIndex(0).tap();
// TODO: test clipboard
});
it('should copy message', async () => {
await element(by[textMatcher](`${data.random}message`))
.atIndex(0)
@ -234,13 +284,10 @@ describe('Room screen', () => {
await expect(element(by.id('action-sheet-handle'))).toBeVisible();
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await element(by[textMatcher]('Copy')).atIndex(0).tap();
// TODO: test clipboard
});
it('should star message', async () => {
await starMessage('message');
await sleep(1000); // https://github.com/RocketChat/Rocket.Chat.ReactNative/issues/2324
await element(by[textMatcher](`${data.random}message`))
.atIndex(0)
@ -255,7 +302,6 @@ describe('Room screen', () => {
.withTimeout(6000);
await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8);
});
it('should react to message', async () => {
await waitFor(element(by[textMatcher](`${data.random}message`)))
.toExist()
@ -272,19 +318,41 @@ describe('Room screen', () => {
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')))
.toBeVisible()
await waitFor(element(by.id('emoji-picker-tab-emoji')))
.toExist()
.withTimeout(2000);
await element(by.id('reaction-picker-😃')).tap();
await waitFor(element(by.id('reaction-picker-grinning')))
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 1);
await element(by.id('emoji-picker-tab-emoji')).tap();
await waitFor(element(by.id('emoji-grinning')))
.toExist()
.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:')))
.toExist()
.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 () => {
await element(by[textMatcher](`${data.random}message`))
.atIndex(0)
@ -302,36 +370,37 @@ describe('Room screen', () => {
.toBeVisible()
.withTimeout(60000);
});
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 waitFor(element(by.id('reaction-picker')))
await waitFor(element(by.id('action-sheet')))
.toExist()
.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()
.withTimeout(2000);
await element(by.id('reaction-picker-😃')).tap();
await waitFor(element(by.id('reaction-picker-grimacing')))
await element(by.id('emoji-picker-tab-emoji')).tap();
await waitFor(element(by.id('emoji-wink')))
.toExist()
.withTimeout(2000);
await element(by.id('reaction-picker-grimacing')).tap();
await waitFor(element(by.id('message-reaction-:grimacing:')))
.withTimeout(10000);
await element(by.id('emoji-wink')).tap();
await waitFor(element(by.id('message-reaction-:wink:')))
.toExist()
.withTimeout(60000);
});
it('should remove reaction', async () => {
await element(by.id('message-reaction-:grinning:')).tap();
await waitFor(element(by.id('message-reaction-:grinning:')))
.toBeNotVisible()
await element(by.id('message-reaction-:smile:')).tap();
await waitFor(element(by.id('message-reaction-:smile:')))
.not.toExist()
.withTimeout(60000);
});
it('should ask for review', async () => {
await dismissReviewNag(); // TODO: Create a proper test for this elsewhere.
});
it('should edit message', async () => {
await mockMessage('edit');
await element(by[textMatcher](`${data.random}edit`))
@ -352,7 +421,6 @@ describe('Room screen', () => {
.toExist()
.withTimeout(60000);
});
it('should quote message', async () => {
await mockMessage('quote');
await element(by[textMatcher](`${data.random}quote`))
@ -369,14 +437,11 @@ describe('Room screen', () => {
.toExist()
.withTimeout(2000);
await element(by.id('messagebox-send-message')).tap();
// TODO: test if quote was sent
});
it('should pin message', async () => {
await mockMessage('pin');
await pinMessage('pin');
await waitFor(element(by[textMatcher](`${data.random}pin`)).atIndex(0))
.toExist()
.withTimeout(5000);
@ -396,7 +461,6 @@ describe('Room screen', () => {
.withTimeout(2000);
await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.8);
});
it('should delete message', async () => {
await mockMessage('delete');
await waitFor(element(by[textMatcher](`${data.random}delete`)).atIndex(0)).toBeVisible();
@ -412,7 +476,6 @@ describe('Room screen', () => {
.toExist()
.withTimeout(1000);
await element(by[textMatcher]('Delete')).atIndex(0).tap();
const deleteAlertMessage = 'You will not be able to recover this message!';
await waitFor(element(by[textMatcher](deleteAlertMessage)).atIndex(0))
.toExist()

View File

@ -105,6 +105,7 @@
"react-native-notifications": "^4.2.4",
"react-native-notifier": "1.6.1",
"react-native-orientation-locker": "1.1.8",
"react-native-pager-view": "^5.4.25",
"react-native-picker-select": "^8.0.4",
"react-native-platform-touchable": "1.1.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-slowlog": "^1.0.2",
"react-native-svg": "^12.3.0",
"react-native-tab-view": "^3.1.1",
"react-native-ui-lib": "RocketChat/react-native-ui-lib",
"react-native-vector-icons": "9.1.0",
"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"
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:
version "8.0.4"
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"
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:
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"