Compare commits
26 Commits
develop
...
improve.ta
Author | SHA1 | Date |
---|---|---|
Danish | 2c73429ff8 | |
Danish | f1658c146a | |
Danish | 359171ed03 | |
Danish | 0f51a3c5df | |
Danish | 8390a4c582 | |
Danish | 83f0edefcc | |
Danish Ahmed Mirza | d9fa16977e | |
Danish Ahmed Mirza | 7c43029bf0 | |
Danish Ahmed Mirza | 37ac131618 | |
Danish Ahmed Mirza | 086b98f8fb | |
Danish Ahmed Mirza | 7d64b262cf | |
Danish Ahmed Mirza | 10f074eb02 | |
Danish Ahmed Mirza | 64afe08fe6 | |
Danish Ahmed Mirza | e014777c9e | |
Danish Ahmed Mirza | 4c130c0b0b | |
Danish Ahmed Mirza | d4bc6a078f | |
Danish Ahmed Mirza | 28f869a80c | |
Danish Ahmed Mirza | b05d876946 | |
Danish Ahmed Mirza | de9036edeb | |
Danish Ahmed Mirza | 036066cd9a | |
Danish Ahmed Mirza | f9164cf0f1 | |
Danish Ahmed Mirza | e44c3d1ffd | |
Danish Ahmed Mirza | 99db6d824d | |
Danish Ahmed Mirza | 8ea5755345 | |
Danish Ahmed Mirza | 349c56ba45 | |
Danish Ahmed Mirza | 597a6836e6 |
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -2813,3 +2813,5 @@ export const emojis = [
|
|||
'flag_tc',
|
||||
'flag_mf'
|
||||
];
|
||||
|
||||
export const DEFAULT_EMOJIS = ['clap', '+1', 'heart_eyes', 'grinning', 'thinking_face', 'smiley'];
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 }
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue