Improve emoji picker performance introducing flashList (#8650)

This commit is contained in:
Elias Nahum 2025-03-11 22:09:46 +08:00 committed by GitHub
parent 915692d828
commit 0c24a646d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 641 additions and 304 deletions

View file

@ -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')

View file

@ -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);

View file

@ -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));

View file

@ -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}) => {

View file

@ -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,
};

View file

@ -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

View file

@ -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}
/>
);
};

View file

@ -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(() => {

View 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);

View 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>
);
}

View file

@ -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;
}

View file

@ -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;
}

View 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');
});
});

View 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));
});
}
}

View file

@ -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
View file

@ -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": {

View file

@ -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",

View 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.

View file

@ -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;