Improve emoji picker performance introducing flashList (#8650)
This commit is contained in:
parent
915692d828
commit
0c24a646d4
19 changed files with 641 additions and 304 deletions
|
|
@ -212,6 +212,13 @@ dependencies {
|
|||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'com.wix:detox:20.26.2'
|
||||
|
||||
// For animated GIF support
|
||||
implementation 'com.facebook.fresco:animated-gif:3.6.0'
|
||||
|
||||
// For WebP support, including animated WebP
|
||||
implementation 'com.facebook.fresco:animated-webp:3.6.0'
|
||||
implementation 'com.facebook.fresco:webpsupport:3.6.0'
|
||||
|
||||
implementation project(':reactnativenotifications')
|
||||
implementation project(':watermelondb-jsi')
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {logDebug} from '@utils/log';
|
|||
|
||||
import {fetchCustomEmojis, searchCustomEmojis, fetchCustomEmojiInBatchForTest} from './custom_emoji';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
|
||||
jest.mock('@managers/network_manager');
|
||||
jest.mock('@utils/log');
|
||||
jest.mock('@utils/errors');
|
||||
|
|
@ -30,8 +32,9 @@ describe('fetchCustomEmojis', () => {
|
|||
it('should fetch custom emojis successfully', async () => {
|
||||
const mockClient = {
|
||||
getCustomEmojis: jest.fn().mockResolvedValue(mockEmojis),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as unknown as Client);
|
||||
|
||||
const result = await fetchCustomEmojis(serverUrl);
|
||||
|
||||
|
|
@ -43,6 +46,7 @@ describe('fetchCustomEmojis', () => {
|
|||
it('should handle error during fetch custom emojis', async () => {
|
||||
const mockClient = {
|
||||
getCustomEmojis: jest.fn().mockRejectedValue(error),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
(getFullErrorMessage as jest.Mock).mockReturnValue('Full error message');
|
||||
|
|
@ -62,6 +66,7 @@ describe('searchCustomEmojis', () => {
|
|||
const term = 'emoji';
|
||||
const mockClient = {
|
||||
searchCustomEmoji: jest.fn().mockResolvedValue(mockEmojis),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ describe('searchCustomEmojis', () => {
|
|||
const term = 'emoji';
|
||||
const mockClient = {
|
||||
searchCustomEmoji: jest.fn().mockRejectedValue(error),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
(getFullErrorMessage as jest.Mock).mockReturnValue('Full error message');
|
||||
|
|
@ -94,6 +100,7 @@ describe('fetchEmojisByName', () => {
|
|||
it('should fetch emojis by name successfully', async () => {
|
||||
const mockClient = {
|
||||
getCustomEmojiByName: jest.fn().mockResolvedValue(emoji),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
|
||||
|
|
@ -106,6 +113,7 @@ describe('fetchEmojisByName', () => {
|
|||
it('should handle no emojis', async () => {
|
||||
const mockClient = {
|
||||
getCustomEmojiByName: jest.fn().mockRejectedValue('error message'),
|
||||
getCustomEmojiImageUrl: jest.fn(),
|
||||
};
|
||||
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import DatabaseManager from '@database/manager';
|
|||
import {debounce} from '@helpers/api/general';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {queryCustomEmojisByName} from '@queries/servers/custom_emoji';
|
||||
import {prefetchCustomEmojiImages} from '@utils/emoji/prefetch';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export const fetchCustomEmojis = async (serverUrl: string, page = 0, perPage = G
|
|||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const data = await client.getCustomEmojis(page, perPage, sort);
|
||||
prefetchCustomEmojiImages(client, data);
|
||||
await operator.handleCustomEmojis({
|
||||
emojis: data,
|
||||
prepareRecordsOnly: false,
|
||||
|
|
@ -39,6 +41,7 @@ export const searchCustomEmojis = async (serverUrl: string, term: string) => {
|
|||
const exist = await queryCustomEmojisByName(database, names).fetch();
|
||||
const existingNames = new Set(exist.map((e) => e.name));
|
||||
const emojis = data.filter((d) => !existingNames.has(d.name));
|
||||
prefetchCustomEmojiImages(client, emojis);
|
||||
await operator.handleCustomEmojis({
|
||||
emojis,
|
||||
prepareRecordsOnly: false,
|
||||
|
|
@ -71,8 +74,10 @@ export const fetchEmojisByName = async (serverUrl: string) => {
|
|||
return result;
|
||||
}, []);
|
||||
if (emojis.length) {
|
||||
prefetchCustomEmojiImages(client, emojis);
|
||||
await operator.handleCustomEmojis({emojis, prepareRecordsOnly: false});
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
logDebug('error on debouncedFetchEmojiByNames', getFullErrorMessage(error));
|
||||
|
|
|
|||
|
|
@ -2,13 +2,9 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {Image} from 'expo-image';
|
||||
import {Image as ExpoImage} from 'expo-image';
|
||||
import React from 'react';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
} from 'react-native';
|
||||
import {Image, Platform, StyleSheet, Text} from 'react-native';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
|
|
@ -99,41 +95,63 @@ const Emoji = (props: EmojiProps) => {
|
|||
);
|
||||
}
|
||||
|
||||
const key = (`${assetImage}-${height}-${width}`);
|
||||
if (assetImage) {
|
||||
const key = Platform.OS === 'android' ? (`${assetImage}-${height}-${width}`) : null;
|
||||
|
||||
const image = assetImages.get(assetImage);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
key={key}
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
resizeMode={'contain'}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return Platform.select({
|
||||
ios: (
|
||||
<ExpoImage
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
/>
|
||||
),
|
||||
android: (
|
||||
<Image
|
||||
key={key}
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
resizeMode='contain'
|
||||
testID={testID}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Android can't change the size of an image after its first render, so
|
||||
// force a new image to be rendered when the size changes
|
||||
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
|
||||
|
||||
return (
|
||||
<Image
|
||||
key={key}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
return Platform.select({
|
||||
ios: (
|
||||
<ExpoImage
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
cachePolicy='disk'
|
||||
placeholder={require('@assets/images/thumb.png')}
|
||||
placeholderContentFit='contain'
|
||||
/>
|
||||
),
|
||||
android: (
|
||||
<Image
|
||||
source={{uri: imageUrl, cache: 'force-cache'}}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
resizeMode='contain'
|
||||
testID={testID}
|
||||
key={key}
|
||||
defaultSource={require('@assets/images/thumb.png')}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
export const MAX_ALLOWED_REACTIONS = 40;
|
||||
export const SORT_BY_NAME = 'name';
|
||||
export const EMOJIS_PER_PAGE = 200;
|
||||
export const EMOJIS_PER_PAGE = 90;
|
||||
|
||||
// reEmoji matches an emoji (eg. :taco:) at the start of a string.
|
||||
export const reEmoji = /^:([a-z0-9_\-+]+):\B/i;
|
||||
|
|
@ -14,7 +14,33 @@ export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:
|
|||
// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right
|
||||
// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <)
|
||||
export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i;
|
||||
|
||||
export const EMOJI_SIZE = 34;
|
||||
export const EMOJI_ROW_MARGIN = 12;
|
||||
export const EMOJIS_PER_ROW = 7;
|
||||
export const EMOJIS_PER_ROW_TABLET = 9;
|
||||
|
||||
export const EMOJI_CATEGORY_ICONS: Record<string, string> = {
|
||||
recent: 'clock-outline',
|
||||
'smileys-emotion': 'emoticon-happy-outline',
|
||||
'people-body': 'account-outline',
|
||||
'animals-nature': 'leaf-outline',
|
||||
'food-drink': 'food-apple',
|
||||
'travel-places': 'airplane-variant',
|
||||
activities: 'basketball',
|
||||
objects: 'lightbulb-outline',
|
||||
symbols: 'heart-outline',
|
||||
flags: 'flag-outline',
|
||||
custom: 'emoticon-custom-outline',
|
||||
};
|
||||
|
||||
export default {
|
||||
MAX_ALLOWED_REACTIONS,
|
||||
SORT_BY_NAME,
|
||||
|
||||
EMOJI_SIZE,
|
||||
EMOJI_ROW_MARGIN,
|
||||
EMOJIS_PER_ROW,
|
||||
EMOJIS_PER_ROW_TABLET,
|
||||
EMOJI_CATEGORY_ICONS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo, useCallback} from 'react';
|
||||
import {Text, TouchableOpacity, View} from 'react-native';
|
||||
import {Keyboard, Text, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
|
@ -41,7 +41,12 @@ const EmojiTouchable = ({name, onEmojiPress}: TouchableEmojiProps) => {
|
|||
const theme = useTheme();
|
||||
const style = getStyleSheetFromTheme(theme);
|
||||
|
||||
const onPress = useCallback(() => onEmojiPress(name), []);
|
||||
const onPress = useCallback(() => {
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
onEmojiPress(name);
|
||||
}, [name, onEmojiPress]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
|
||||
import {BottomSheetFlashList} from '@gorhom/bottom-sheet';
|
||||
import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list';
|
||||
import Fuse from 'fuse.js';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {FlatList, type ListRenderItemInfo, StyleSheet, View} from 'react-native';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import NoResultsWithTerm from '@components/no_results_with_term';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
|
@ -46,7 +47,7 @@ const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props
|
|||
return searchEmojis(fuse, searchTerm);
|
||||
}, [fuse, searchTerm]);
|
||||
|
||||
const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]);
|
||||
const List = useMemo(() => (isTablet ? FlashList : BottomSheetFlashList), [isTablet]);
|
||||
|
||||
const keyExtractor = useCallback((item: string) => item, []);
|
||||
|
||||
|
|
@ -65,18 +66,17 @@ const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props
|
|||
name={item}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
}, [onEmojiPress]);
|
||||
|
||||
return (
|
||||
<List
|
||||
data={data}
|
||||
initialNumToRender={30}
|
||||
estimatedItemSize={40}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='always'
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import {BottomSheetFooter, type BottomSheetFooterProps, SHEET_STATE, useBottomSheet, useBottomSheetInternal} from '@gorhom/bottom-sheet';
|
||||
import React, {useCallback} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useKeyboardHeight} from '@hooks/device';
|
||||
|
|
@ -12,6 +12,14 @@ import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
|
|||
|
||||
import EmojiCategoryBar from '../emoji_category_bar';
|
||||
|
||||
function waitForSheetExtended(animatedSheetState: SharedValue<number>, callback: () => void, depth = 250) {
|
||||
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
|
||||
callback();
|
||||
} else if (depth > 0) {
|
||||
requestAnimationFrame(() => waitForSheetExtended(animatedSheetState, callback, depth - 1));
|
||||
}
|
||||
}
|
||||
|
||||
const PickerFooter = (props: BottomSheetFooterProps) => {
|
||||
const theme = useTheme();
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
|
|
@ -25,12 +33,9 @@ const PickerFooter = (props: BottomSheetFooterProps) => {
|
|||
}
|
||||
expand();
|
||||
|
||||
// @ts-expect-error wait until the bottom sheet is epanded
|
||||
while (animatedSheetState.value !== SHEET_STATE.EXTENDED) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
selectEmojiCategoryBarSection(index);
|
||||
waitForSheetExtended(animatedSheetState, () => {
|
||||
selectEmojiCategoryBarSection(index);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
|
|
|||
64
app/screens/emoji_picker/picker/sections/emoji_image.tsx
Normal file
64
app/screens/emoji_picker/picker/sections/emoji_image.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React, {memo, useCallback} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {EMOJI_ROW_MARGIN, EMOJI_SIZE} from '@constants/emoji';
|
||||
|
||||
type ImageEmojiProps = {
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
file?: ExtractedFileInfo;
|
||||
imageUrl?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
height: EMOJI_SIZE,
|
||||
marginBottom: EMOJI_ROW_MARGIN,
|
||||
},
|
||||
emoji: {
|
||||
height: EMOJI_SIZE,
|
||||
width: EMOJI_SIZE,
|
||||
},
|
||||
imageEmoji: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
}));
|
||||
|
||||
const ImageEmoji = ({file, imageUrl, onEmojiPress, path}: ImageEmojiProps) => {
|
||||
const onPress = useCallback(() => {
|
||||
onEmojiPress('');
|
||||
}, [onEmojiPress]);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.emoji}>
|
||||
<TouchableWithFeedback onPress={onPress}>
|
||||
<>
|
||||
{Boolean(file) &&
|
||||
<FileIcon
|
||||
file={file}
|
||||
iconSize={30}
|
||||
/>
|
||||
}
|
||||
{Boolean(imageUrl) &&
|
||||
<Image
|
||||
source={{uri: path}}
|
||||
style={styles.imageEmoji}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default memo(ImageEmoji);
|
||||
79
app/screens/emoji_picker/picker/sections/emoji_row.tsx
Normal file
79
app/screens/emoji_picker/picker/sections/emoji_row.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import TouchableEmoji from '@components/touchable_emoji';
|
||||
import {EMOJI_ROW_MARGIN, EMOJI_SIZE} from '@constants/emoji';
|
||||
|
||||
import ImageEmoji from './emoji_image';
|
||||
|
||||
export interface EmojiSectionRow {
|
||||
type: 'row';
|
||||
emojis: EmojiAlias[];
|
||||
sectionIndex: number;
|
||||
category: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type EmojiRowProps = {
|
||||
emojis: EmojiAlias[];
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
imageUrl?: string;
|
||||
file?: ExtractedFileInfo;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: EMOJI_ROW_MARGIN,
|
||||
},
|
||||
emoji: {
|
||||
height: EMOJI_SIZE,
|
||||
width: EMOJI_SIZE,
|
||||
},
|
||||
imageEmoji: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function EmojiRow({emojis, file, imageUrl, onEmojiPress}: EmojiRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{emojis.map((emoji, index) => {
|
||||
if (!emoji.name && !emoji.short_name) {
|
||||
return (
|
||||
<View
|
||||
key={`empty-${index.toString()}`}
|
||||
style={styles.emoji}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (emoji.category === 'image') {
|
||||
return (
|
||||
<ImageEmoji
|
||||
key={`${index.toString()}-${emoji.name}`}
|
||||
file={file}
|
||||
imageUrl={imageUrl}
|
||||
onEmojiPress={onEmojiPress}
|
||||
path={emoji.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableEmoji
|
||||
key={`${index.toString()}-${emoji.name}`}
|
||||
name={emoji.name}
|
||||
onEmojiPress={onEmojiPress}
|
||||
category={emoji.category}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {BottomSheetSectionList} from '@gorhom/bottom-sheet';
|
||||
import {Image} from 'expo-image';
|
||||
import {BottomSheetFlashList} from '@gorhom/bottom-sheet';
|
||||
import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list';
|
||||
import {chunk} from 'lodash';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, SectionList, type SectionListData, StyleSheet, View} from 'react-native';
|
||||
import sectionListGetItemLayout from 'react-native-section-list-get-item-layout';
|
||||
import {View, StyleSheet} from 'react-native';
|
||||
|
||||
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import TouchableEmoji from '@components/touchable_emoji';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {EMOJIS_PER_PAGE} from '@constants/emoji';
|
||||
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
|
||||
|
|
@ -21,59 +17,33 @@ import {fillEmoji} from '@utils/emoji/helpers';
|
|||
|
||||
import EmojiCategoryBar from '../emoji_category_bar';
|
||||
|
||||
import EmojiRow, {type EmojiSectionRow} from './emoji_row';
|
||||
import SectionFooter from './section_footer';
|
||||
import SectionHeader, {SECTION_HEADER_HEIGHT} from './section_header';
|
||||
import SectionHeader, {type EmojiSection} from './section_header';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
import type {CustomEmojiModel} from '@database/models/server';
|
||||
|
||||
const EMOJI_SIZE = 34;
|
||||
const EMOJIS_PER_ROW = 7;
|
||||
const EMOJIS_PER_ROW_TABLET = 9;
|
||||
const EMOJI_ROW_MARGIN = 12;
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
recent: 'clock-outline',
|
||||
'smileys-emotion': 'emoticon-happy-outline',
|
||||
'people-body': 'account-outline',
|
||||
'animals-nature': 'leaf-outline',
|
||||
'food-drink': 'food-apple',
|
||||
'travel-places': 'airplane-variant',
|
||||
activities: 'basketball',
|
||||
objects: 'lightbulb-outline',
|
||||
symbols: 'heart-outline',
|
||||
flags: 'flag-outline',
|
||||
custom: 'emoticon-custom-outline',
|
||||
};
|
||||
type SectionListItem = EmojiSection | EmojiSectionRow;
|
||||
|
||||
const categoryToI18n: Record<string, CategoryTranslation> = {};
|
||||
let emojiSectionsByOffset: number[] = [];
|
||||
|
||||
const getItemLayout = sectionListGetItemLayout({
|
||||
getItemHeight: () => EMOJI_SIZE + EMOJI_ROW_MARGIN,
|
||||
getSectionHeaderHeight: () => SECTION_HEADER_HEIGHT,
|
||||
sectionOffsetsCallback: (offsetsById) => {
|
||||
emojiSectionsByOffset = offsetsById;
|
||||
},
|
||||
const emptyEmoji: EmojiAlias = {
|
||||
name: '',
|
||||
short_name: '',
|
||||
aliases: [],
|
||||
};
|
||||
|
||||
const keyExtractor = (item: SectionListItem) => {
|
||||
return (item.type === 'section' ? `${item.key}` : `${item.sectionIndex}-${item.index}-${item.category}`);
|
||||
};
|
||||
|
||||
const getItemType = (item: SectionListItem) => item.type;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, paddingBottom: 20},
|
||||
containerStyle: {paddingBottom: 50},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create(({
|
||||
flex: {flex: 1},
|
||||
contentContainerStyle: {paddingBottom: 50},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: EMOJI_ROW_MARGIN,
|
||||
},
|
||||
emoji: {
|
||||
height: EMOJI_SIZE,
|
||||
width: EMOJI_SIZE,
|
||||
},
|
||||
imageEmoji: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
customEmojis: CustomEmojiModel[];
|
||||
customEmojisEnabled: boolean;
|
||||
|
|
@ -83,158 +53,152 @@ type Props = {
|
|||
recentEmojis: string[];
|
||||
}
|
||||
|
||||
type ImageEmojiProps = {
|
||||
onEmojiPress: (emoji: string) => void;
|
||||
file?: ExtractedFileInfo;
|
||||
imageUrl?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
CategoryNames.forEach((name: string) => {
|
||||
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
|
||||
categoryToI18n[name] = {
|
||||
id: CategoryTranslations.get(name)!,
|
||||
defaultMessage: CategoryMessage.get(name)!,
|
||||
icon: ICONS[name],
|
||||
icon: EMOJI_CATEGORY_ICONS[name],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const emptyEmoji: EmojiAlias = {
|
||||
name: '',
|
||||
short_name: '',
|
||||
aliases: [],
|
||||
};
|
||||
|
||||
const ImageEmoji = ({file, imageUrl, onEmojiPress, path}: ImageEmojiProps) => {
|
||||
const onPress = useCallback(() => {
|
||||
onEmojiPress('');
|
||||
}, [onEmojiPress]);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.emoji}>
|
||||
<TouchableWithFeedback onPress={onPress}>
|
||||
<>
|
||||
{Boolean(file) &&
|
||||
<FileIcon
|
||||
file={file}
|
||||
iconSize={30}
|
||||
/>
|
||||
}
|
||||
{Boolean(imageUrl) &&
|
||||
<Image
|
||||
source={{uri: path}}
|
||||
style={styles.imageEmoji}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiSections = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) => {
|
||||
export default function EmojiSectionList({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) {
|
||||
const [customEmojiPage, setCustomEmojiPage] = useState(() => Math.ceil(customEmojis.length / EMOJIS_PER_PAGE));
|
||||
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
|
||||
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
|
||||
const scrollingToIndex = useRef(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const isTablet = useIsTablet();
|
||||
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
|
||||
const list = useRef<SectionList<EmojiSection>>(null);
|
||||
const categoryIndex = useRef(currentIndex);
|
||||
const [customEmojiPage, setCustomEmojiPage] = useState(0);
|
||||
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
|
||||
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
|
||||
const offset = useRef(0);
|
||||
const manualScroll = useRef(false);
|
||||
|
||||
const sections: EmojiSection[] = useMemo(() => {
|
||||
const list = useRef<FlashList<SectionListItem> | null>(null);
|
||||
|
||||
const sections: SectionListItem[] = useMemo(() => {
|
||||
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
|
||||
|
||||
const sectionsArray = CategoryNames.map<EmojiSection>((category) => {
|
||||
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(category);
|
||||
|
||||
let data: EmojiAlias[][];
|
||||
switch (category) {
|
||||
case 'custom': {
|
||||
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
|
||||
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
const custom = customEmojisEnabled ? customEmojis.map((ce) => ({
|
||||
aliases: [],
|
||||
name: ce.name,
|
||||
short_name: '',
|
||||
})) : [];
|
||||
|
||||
data = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
case 'recent':
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
data = chunk<EmojiAlias>(recentEmojis.map((emoji) => ({
|
||||
aliases: [],
|
||||
name: emoji,
|
||||
short_name: '',
|
||||
})), EMOJIS_PER_ROW);
|
||||
break;
|
||||
default:
|
||||
data = chunk(emojiIndices.map(fillEmoji.bind(null, category)), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const d of data) {
|
||||
if (d.length < emojisPerRow) {
|
||||
d.push(
|
||||
...(new Array(emojisPerRow - d.length).fill(emptyEmoji)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'section',
|
||||
...categoryToI18n[category],
|
||||
data,
|
||||
key: category,
|
||||
};
|
||||
}).filter((s: EmojiSection) => s.data.length);
|
||||
});
|
||||
|
||||
if (imageUrl || file) {
|
||||
sectionsArray.unshift({
|
||||
data: [[{
|
||||
aliases: [],
|
||||
name: imageUrl || file?.name || '',
|
||||
short_name: imageUrl || file?.name || '',
|
||||
category: 'image',
|
||||
}]],
|
||||
type: 'section',
|
||||
id: 'emoji_picker.default',
|
||||
defaultMessage: 'Default',
|
||||
icon: 'bookmark-outline',
|
||||
id: 'emoji_picker.default',
|
||||
key: 'default',
|
||||
renderItem: ({item}: ListRenderItemInfo<EmojiAlias[]>) => {
|
||||
return (
|
||||
<ImageEmoji
|
||||
file={file}
|
||||
onEmojiPress={onEmojiPress}
|
||||
imageUrl={imageUrl}
|
||||
path={item[0].name}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sectionsArray;
|
||||
}, [customEmojis, customEmojisEnabled, isTablet, imageUrl, file]);
|
||||
return sectionsArray.reduce<SectionListItem[]>((acc, section, sectionIndex) => {
|
||||
acc.push(section);
|
||||
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(section.key);
|
||||
let emojiArray: EmojiAlias[][];
|
||||
switch (section.key) {
|
||||
case 'custom': {
|
||||
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
|
||||
const mapCustom = (ce: CustomEmojiModel) => ({
|
||||
aliases: [],
|
||||
name: ce.name,
|
||||
short_name: '',
|
||||
});
|
||||
const custom = customEmojisEnabled ? customEmojis.map(mapCustom) : [];
|
||||
emojiArray = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
case 'recent': {
|
||||
const recentMap = (emoji: string) => ({
|
||||
aliases: [],
|
||||
name: emoji,
|
||||
short_name: '',
|
||||
});
|
||||
if (recentEmojis.length === 0) {
|
||||
acc.pop();
|
||||
return acc;
|
||||
}
|
||||
emojiArray = chunk<EmojiAlias>(recentEmojis.map(recentMap), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
case 'default':
|
||||
acc.push({
|
||||
type: 'row',
|
||||
emojis: [{
|
||||
aliases: [],
|
||||
name: imageUrl || file?.name || '',
|
||||
short_name: imageUrl || file?.name || '',
|
||||
category: 'image',
|
||||
}],
|
||||
sectionIndex,
|
||||
category: section.key,
|
||||
index: 0,
|
||||
});
|
||||
return acc;
|
||||
default:
|
||||
emojiArray = chunk(emojiIndices.map(fillEmoji.bind(null, section.key)), emojisPerRow);
|
||||
break;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEmojiCategoryBarIcons(sections.map((s) => ({
|
||||
key: s.key,
|
||||
icon: s.icon,
|
||||
})));
|
||||
}, [sections]);
|
||||
for (let index = 0; index < emojiArray.length; index++) {
|
||||
const d = emojiArray[index];
|
||||
const emojis = d.length < emojisPerRow ? d.concat(new Array(emojisPerRow - d.length).fill(emptyEmoji)) : d;
|
||||
acc.push({
|
||||
type: 'row',
|
||||
emojis,
|
||||
sectionIndex,
|
||||
category: section.key,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
const onLoadMoreCustomEmojis = useCallback(async () => {
|
||||
return acc;
|
||||
}, []);
|
||||
}, [customEmojis, customEmojisEnabled, file, imageUrl, isTablet, recentEmojis]);
|
||||
|
||||
const stickyHeaderIndices = useMemo(() =>
|
||||
sections.
|
||||
map((item, index) => (item.type === 'section' ? index : undefined)).
|
||||
filter((item) => item !== undefined) as number[],
|
||||
[sections]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<SectionListItem>) => {
|
||||
if (item.type === 'section') {
|
||||
return (
|
||||
<SectionHeader
|
||||
key={item.key}
|
||||
section={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmojiRow
|
||||
key={`${item.sectionIndex}-${item.index}-${item.category}`}
|
||||
emojis={item.emojis}
|
||||
file={file}
|
||||
imageUrl={imageUrl}
|
||||
onEmojiPress={onEmojiPress}
|
||||
/>
|
||||
);
|
||||
}, [file, imageUrl, onEmojiPress]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
scrollingToIndex.current = true;
|
||||
list.current?.scrollToIndex({animated: false, index: stickyHeaderIndices[index], viewOffset: 0});
|
||||
setEmojiCategoryBarSection(index);
|
||||
setTimeout(() => {
|
||||
scrollingToIndex.current = false;
|
||||
}, 250);
|
||||
}, [stickyHeaderIndices]);
|
||||
|
||||
const loadMoreCustomEmojis = useCallback(async () => {
|
||||
if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingCustomEmojis(true);
|
||||
const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE);
|
||||
if (data?.length) {
|
||||
|
|
@ -244,107 +208,64 @@ const EmojiSections = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmo
|
|||
}
|
||||
|
||||
setFetchingCustomEmojis(false);
|
||||
}, [customEmojiPage, customEmojisEnabled, loadedAllCustomEmojis, fetchingCustomEmojis]);
|
||||
}, [customEmojisEnabled, fetchingCustomEmojis, loadedAllCustomEmojis, serverUrl, customEmojiPage]);
|
||||
|
||||
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const {contentOffset} = e.nativeEvent;
|
||||
const direction = contentOffset.y > offset.current ? 'up' : 'down';
|
||||
offset.current = contentOffset.y;
|
||||
|
||||
if (manualScroll.current) {
|
||||
const handleStickyHeaderIndexChanged = useCallback((index: number) => {
|
||||
if (scrollingToIndex.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = contentOffset.y >= emojiSectionsByOffset[categoryIndex.current + 1] - SECTION_HEADER_HEIGHT ? categoryIndex.current + 1 : categoryIndex.current;
|
||||
const prevIndex = Math.max(0, contentOffset.y <= emojiSectionsByOffset[categoryIndex.current] - SECTION_HEADER_HEIGHT ? categoryIndex.current - 1 : categoryIndex.current);
|
||||
if (nextIndex > categoryIndex.current && direction === 'up') {
|
||||
categoryIndex.current = nextIndex;
|
||||
setEmojiCategoryBarSection(nextIndex);
|
||||
} else if (prevIndex < categoryIndex.current && direction === 'down') {
|
||||
categoryIndex.current = prevIndex;
|
||||
setEmojiCategoryBarSection(prevIndex);
|
||||
const stickyIndex = stickyHeaderIndices.indexOf(index);
|
||||
if (stickyIndex !== -1 && currentIndex !== stickyIndex) {
|
||||
requestAnimationFrame(() => {
|
||||
setEmojiCategoryBarSection(stickyIndex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
manualScroll.current = true;
|
||||
list.current?.scrollToLocation({sectionIndex: index, itemIndex: 0, animated: false, viewOffset: 0});
|
||||
setEmojiCategoryBarSection(index);
|
||||
setTimeout(() => {
|
||||
manualScroll.current = false;
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const renderSectionHeader = useCallback(({section}: {section: SectionListData<EmojiAlias[], EmojiSection>}) => {
|
||||
return (
|
||||
<SectionHeader section={section}/>
|
||||
);
|
||||
}, []);
|
||||
}, [currentIndex, stickyHeaderIndices]);
|
||||
|
||||
const renderFooter = useMemo(() => {
|
||||
return fetchingCustomEmojis ? <SectionFooter/> : null;
|
||||
}, [fetchingCustomEmojis]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<EmojiAlias[]>) => {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{item.map((emoji: EmojiAlias, index: number) => {
|
||||
if (!emoji.name && !emoji.short_name) {
|
||||
return (
|
||||
<View
|
||||
key={`empty-${index.toString()}`}
|
||||
style={styles.emoji}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const List = useMemo(() => (isTablet ? FlashList : BottomSheetFlashList), [isTablet]);
|
||||
|
||||
return (
|
||||
<TouchableEmoji
|
||||
key={emoji.name}
|
||||
name={emoji.name}
|
||||
onEmojiPress={onEmojiPress}
|
||||
category={emoji.category}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const List = useMemo(() => (isTablet ? SectionList : BottomSheetSectionList), [isTablet]);
|
||||
useEffect(() => {
|
||||
setEmojiCategoryBarIcons(sections.filter((s) => s.type === 'section').map((s) => ({
|
||||
key: s.key,
|
||||
icon: s.icon,
|
||||
})));
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex != null) {
|
||||
scrollToIndex(selectedIndex);
|
||||
}
|
||||
|
||||
// do not include scrollToIndex in dependencies
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
<View style={styles.container}>
|
||||
<List
|
||||
|
||||
// @ts-expect-error bottom sheet definition
|
||||
getItemLayout={getItemLayout}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='always'
|
||||
contentContainerStyle={styles.containerStyle}
|
||||
data={sections}
|
||||
estimatedItemSize={EMOJI_SIZE + EMOJI_ROW_MARGIN}
|
||||
getItemType={getItemType}
|
||||
keyExtractor={keyExtractor}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={onLoadMoreCustomEmojis}
|
||||
onEndReachedThreshold={2}
|
||||
onScroll={onScroll}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={loadMoreCustomEmojis}
|
||||
onStickyHeaderIndexChanged={handleStickyHeaderIndexChanged}
|
||||
|
||||
//@ts-expect-error type definition for ref
|
||||
ref={list}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
sections={sections}
|
||||
contentContainerStyle={styles.contentContainerStyle}
|
||||
stickySectionHeadersEnabled={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID='emoji_picker.emoji_sections.section_list'
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
/>
|
||||
{isTablet &&
|
||||
<EmojiCategoryBar/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiSections;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import {useTheme} from '@context/theme';
|
|||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
export interface EmojiSection {
|
||||
type: 'section';
|
||||
id: string;
|
||||
defaultMessage?: string;
|
||||
icon: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
section: EmojiSection;
|
||||
}
|
||||
|
|
|
|||
62
app/utils/emoji/prefetch.test.ts
Normal file
62
app/utils/emoji/prefetch.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image as ExpoImage} from 'expo-image';
|
||||
import {Image, Platform} from 'react-native';
|
||||
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import {prefetchCustomEmojiImages} from './prefetch';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
|
||||
jest.mock('expo-image', () => ({
|
||||
Image: {
|
||||
prefetch: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
},
|
||||
Image: {
|
||||
prefetch: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@utils/log');
|
||||
|
||||
describe('prefetchCustomEmojiImages', () => {
|
||||
const mockClient = {
|
||||
getCustomEmojiImageUrl: jest.fn((id) => `url/${id}`),
|
||||
} as unknown as Client;
|
||||
|
||||
const emojis = [
|
||||
{id: 'emoji1', name: 'emoji_name1'},
|
||||
{id: 'emoji2', name: 'emoji_name2'},
|
||||
] as CustomEmoji[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should prefetch custom emoji images on iOS', () => {
|
||||
Platform.OS = 'ios';
|
||||
|
||||
prefetchCustomEmojiImages(mockClient, emojis);
|
||||
|
||||
expect(logDebug).toHaveBeenCalledWith('Prefetching 2 custom emoji images');
|
||||
expect(ExpoImage.prefetch).toHaveBeenCalledWith(['url/emoji1', 'url/emoji2'], 'disk');
|
||||
});
|
||||
|
||||
it('should prefetch custom emoji images on Android', () => {
|
||||
Platform.OS = 'android';
|
||||
|
||||
prefetchCustomEmojiImages(mockClient, emojis);
|
||||
|
||||
expect(logDebug).toHaveBeenCalledWith('Prefetching 2 custom emoji images');
|
||||
expect(Image.prefetch).toHaveBeenCalledWith('url/emoji1');
|
||||
expect(Image.prefetch).toHaveBeenCalledWith('url/emoji2');
|
||||
});
|
||||
});
|
||||
20
app/utils/emoji/prefetch.ts
Normal file
20
app/utils/emoji/prefetch.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image as ExpoImage} from 'expo-image';
|
||||
import {Image, Platform} from 'react-native';
|
||||
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
|
||||
export function prefetchCustomEmojiImages(client: Client, emojis: CustomEmoji[]) {
|
||||
logDebug(`Prefetching ${emojis.length} custom emoji images`);
|
||||
if (Platform.OS === 'ios') {
|
||||
ExpoImage.prefetch(emojis.map((ce) => client.getCustomEmojiImageUrl(ce.id)), 'disk');
|
||||
} else {
|
||||
emojis.forEach((ce) => {
|
||||
Image.prefetch(client.getCustomEmojiImageUrl(ce.id));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1841,6 +1841,27 @@ PODS:
|
|||
- React-Core
|
||||
- RNFileViewer (2.1.5):
|
||||
- React-Core
|
||||
- RNFlashList (1.7.3):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
- RCT-Folly (= 2024.01.01.00)
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNGestureHandler (2.21.2):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
|
|
@ -2203,6 +2224,7 @@ DEPENDENCIES:
|
|||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
|
||||
- RNFileViewer (from `../node_modules/react-native-file-viewer`)
|
||||
- "RNFlashList (from `../node_modules/@shopify/flash-list`)"
|
||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||
- RNKeychain (from `../node_modules/react-native-keychain`)
|
||||
- RNLocalize (from `../node_modules/react-native-localize`)
|
||||
|
|
@ -2430,6 +2452,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/@react-native-community/datetimepicker"
|
||||
RNFileViewer:
|
||||
:path: "../node_modules/react-native-file-viewer"
|
||||
RNFlashList:
|
||||
:path: "../node_modules/@shopify/flash-list"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
RNKeychain:
|
||||
|
|
@ -2569,6 +2593,7 @@ SPEC CHECKSUMS:
|
|||
RNCClipboard: dbcf25b8f666b4685c02eeb65be981d30198e505
|
||||
RNDateTimePicker: 818460dc31b0dc5ec58289003e27dd8d022fb79c
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
RNFlashList: 3f19b05498275481fa67506db51057eee62fa54a
|
||||
RNGestureHandler: 5b24d10761754ad271b714e536c457fd89b17c54
|
||||
RNKeychain: bd09fc5dcf4e0c4166a5d4012beaf2e093fcd9f5
|
||||
RNLocalize: d024afa9204c13885e61dc88b8190651bcaabac9
|
||||
|
|
|
|||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -41,6 +41,7 @@
|
|||
"@react-navigation/stack": "7.1.0",
|
||||
"@rneui/base": "4.0.0-rc.8",
|
||||
"@sentry/react-native": "6.4.0",
|
||||
"@shopify/flash-list": "1.7.3",
|
||||
"@stream-io/flat-list-mvcp": "0.10.3",
|
||||
"@voximplant/react-native-foreground-service": "3.0.2",
|
||||
"APNG4Android": "github:mattermost/APNG4Android#15a7e93d59542d18c1e562d49b3f4896dd1643a6",
|
||||
|
|
@ -7437,6 +7438,21 @@
|
|||
"node": ">=14.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@shopify/flash-list": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-1.7.3.tgz",
|
||||
"integrity": "sha512-RLhNptm02aqpqZvjj9pJPcU+EVYxOAJhPRCmDOaUbUP86+636w+plsbjpBPSYGvPZhPj56RtZ9FBlvolPeEmYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"recyclerlistview": "4.2.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"license": "BSD-3-Clause",
|
||||
|
|
@ -20380,6 +20396,21 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/recyclerlistview": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/recyclerlistview/-/recyclerlistview-4.2.1.tgz",
|
||||
"integrity": "sha512-NtVYjofwgUCt1rEsTp6jHQg/47TWjnO92TU2kTVgJ9wsc/ely4HnizHHa+f/dI7qaw4+zcSogElrLjhMltN2/g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "4.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"ts-object-utils": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 15.2.1",
|
||||
"react-native": ">= 0.30.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"dev": true,
|
||||
|
|
@ -22314,6 +22345,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-object-utils": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz",
|
||||
"integrity": "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"dev": true,
|
||||
|
|
@ -22345,7 +22382,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"@react-navigation/stack": "7.1.0",
|
||||
"@rneui/base": "4.0.0-rc.8",
|
||||
"@sentry/react-native": "6.4.0",
|
||||
"@shopify/flash-list": "1.7.3",
|
||||
"@stream-io/flat-list-mvcp": "0.10.3",
|
||||
"@voximplant/react-native-foreground-service": "3.0.2",
|
||||
"APNG4Android": "github:mattermost/APNG4Android#15a7e93d59542d18c1e562d49b3f4896dd1643a6",
|
||||
|
|
|
|||
53
patches/@shopify+flash-list+1.7.3.patch
Normal file
53
patches/@shopify+flash-list+1.7.3.patch
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
diff --git a/node_modules/@shopify/flash-list/dist/FlashList.js b/node_modules/@shopify/flash-list/dist/FlashList.js
|
||||
index 486a284..1ad99e4 100644
|
||||
--- a/node_modules/@shopify/flash-list/dist/FlashList.js
|
||||
+++ b/node_modules/@shopify/flash-list/dist/FlashList.js
|
||||
@@ -159,6 +159,8 @@ var FlashList = /** @class */ (function (_super) {
|
||||
(_a = _this.stickyContentContainerRef) === null || _a === void 0 ? void 0 : _a.setEnabled(_this.isStickyEnabled);
|
||||
};
|
||||
_this.rowRendererSticky = function (index) {
|
||||
+ var _a, _b;
|
||||
+ (_b = (_a = _this.props).onStickyHeaderIndexChanged) === null || _b === void 0 ? void 0 : _b.call(_a, index);
|
||||
return _this.rowRendererWithIndex(index, FlashListProps_1.RenderTargetOptions.StickyHeader);
|
||||
};
|
||||
_this.rowRendererWithIndex = function (index, target) {
|
||||
diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
|
||||
index 19055e9..aa586ed 100644
|
||||
--- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
|
||||
+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
|
||||
@@ -187,6 +187,10 @@ export interface FlashListProps<TItem> extends ScrollViewProps {
|
||||
viewableItems: ViewToken[];
|
||||
changed: ViewToken[];
|
||||
}) => void) | null | undefined;
|
||||
+ /**
|
||||
+ * Called when the sticky headers change. This event is raised when the sticky header index changes.
|
||||
+ */
|
||||
+ onStickyHeaderIndexChanged?: ((index: number) => void) | null | undefined;
|
||||
/**
|
||||
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality.
|
||||
* Make sure to also set the refreshing prop correctly.
|
||||
diff --git a/node_modules/@shopify/flash-list/src/FlashList.tsx b/node_modules/@shopify/flash-list/src/FlashList.tsx
|
||||
index 0e9f071..a158be1 100644
|
||||
--- a/node_modules/@shopify/flash-list/src/FlashList.tsx
|
||||
+++ b/node_modules/@shopify/flash-list/src/FlashList.tsx
|
||||
@@ -642,6 +642,7 @@ class FlashList<T> extends React.PureComponent<
|
||||
};
|
||||
|
||||
private rowRendererSticky = (index: number) => {
|
||||
+ this.props.onStickyHeaderIndexChanged?.(index);
|
||||
return this.rowRendererWithIndex(index, RenderTargetOptions.StickyHeader);
|
||||
};
|
||||
|
||||
diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts
|
||||
index 77e6096..f61fe03 100644
|
||||
--- a/node_modules/@shopify/flash-list/src/FlashListProps.ts
|
||||
+++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts
|
||||
@@ -251,6 +251,8 @@ export interface FlashListProps<TItem> extends ScrollViewProps {
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
+ onStickyHeaderIndexChanged?: ((index: number) => void) | null | undefined;
|
||||
+
|
||||
/**
|
||||
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality.
|
||||
* Make sure to also set the refreshing prop correctly.
|
||||
9
types/screens/emoji_selector.d.ts
vendored
9
types/screens/emoji_selector.d.ts
vendored
|
|
@ -8,15 +8,6 @@ type EmojiAlias = {
|
|||
category?: string;
|
||||
}
|
||||
|
||||
type EmojiSection = {
|
||||
data: EmojiAlias[][];
|
||||
defaultMessage?: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
key: string;
|
||||
renderItem?: ({item}: ListRenderItemInfo<EmojiAlias[]>) => JSX.Element;
|
||||
}
|
||||
|
||||
type CategoryTranslation = {
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue