diff --git a/android/app/build.gradle b/android/app/build.gradle
index d519fad90..ad6c8f973 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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')
diff --git a/app/actions/remote/custom_emoji.test.ts b/app/actions/remote/custom_emoji.test.ts
index 69f3f4ad3..797e6102a 100644
--- a/app/actions/remote/custom_emoji.test.ts
+++ b/app/actions/remote/custom_emoji.test.ts
@@ -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);
diff --git a/app/actions/remote/custom_emoji.ts b/app/actions/remote/custom_emoji.ts
index 4b5188311..1543e203b 100644
--- a/app/actions/remote/custom_emoji.ts
+++ b/app/actions/remote/custom_emoji.ts
@@ -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));
diff --git a/app/components/emoji/emoji.tsx b/app/components/emoji/emoji.tsx
index 80dd997b9..37b3bac24 100644
--- a/app/components/emoji/emoji.tsx
+++ b/app/components/emoji/emoji.tsx
@@ -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 (
-
- );
+
+ return Platform.select({
+ ios: (
+
+ ),
+ android: (
+
+ ),
+ });
}
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 (
-
- );
+ return Platform.select({
+ ios: (
+
+ ),
+ android: (
+
+ ),
+ });
};
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
diff --git a/app/constants/emoji.ts b/app/constants/emoji.ts
index 267b48c75..ee24332ca 100644
--- a/app/constants/emoji.ts
+++ b/app/constants/emoji.ts
@@ -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 = {
+ 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,
};
diff --git a/app/screens/emoji_picker/picker/filtered/emoji_item.tsx b/app/screens/emoji_picker/picker/filtered/emoji_item.tsx
index 4c1035fc6..e311ff4a1 100644
--- a/app/screens/emoji_picker/picker/filtered/emoji_item.tsx
+++ b/app/screens/emoji_picker/picker/filtered/emoji_item.tsx
@@ -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 (
(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 (
);
};
diff --git a/app/screens/emoji_picker/picker/footer/index.tsx b/app/screens/emoji_picker/picker/footer/index.tsx
index d7052c66a..dcf1adc9c 100644
--- a/app/screens/emoji_picker/picker/footer/index.tsx
+++ b/app/screens/emoji_picker/picker/footer/index.tsx
@@ -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, 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(() => {
diff --git a/app/screens/emoji_picker/picker/sections/emoji_image.tsx b/app/screens/emoji_picker/picker/sections/emoji_image.tsx
new file mode 100644
index 000000000..67f264b87
--- /dev/null
+++ b/app/screens/emoji_picker/picker/sections/emoji_image.tsx
@@ -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 (
+
+
+
+ <>
+ {Boolean(file) &&
+
+ }
+ {Boolean(imageUrl) &&
+
+ }
+ >
+
+
+
+ );
+};
+export default memo(ImageEmoji);
diff --git a/app/screens/emoji_picker/picker/sections/emoji_row.tsx b/app/screens/emoji_picker/picker/sections/emoji_row.tsx
new file mode 100644
index 000000000..dc5e23780
--- /dev/null
+++ b/app/screens/emoji_picker/picker/sections/emoji_row.tsx
@@ -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 (
+
+ {emojis.map((emoji, index) => {
+ if (!emoji.name && !emoji.short_name) {
+ return (
+
+ );
+ }
+
+ if (emoji.category === 'image') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/app/screens/emoji_picker/picker/sections/index.tsx b/app/screens/emoji_picker/picker/sections/index.tsx
index c9d47e86f..6ed18b48f 100644
--- a/app/screens/emoji_picker/picker/sections/index.tsx
+++ b/app/screens/emoji_picker/picker/sections/index.tsx
@@ -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 = {
- 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 = {};
-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 (
-
-
-
- <>
- {Boolean(file) &&
-
- }
- {Boolean(imageUrl) &&
-
- }
- >
-
-
-
- );
-};
-
-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>(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 | null>(null);
+
+ const sections: SectionListItem[] = useMemo(() => {
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
-
const sectionsArray = CategoryNames.map((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(builtInCustom.concat(custom), emojisPerRow);
- break;
- }
- case 'recent':
- // eslint-disable-next-line max-nested-callbacks
- data = chunk(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) => {
- return (
-
- );
- },
});
}
- return sectionsArray;
- }, [customEmojis, customEmojisEnabled, isTablet, imageUrl, file]);
+ return sectionsArray.reduce((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(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(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) => {
+ if (item.type === 'section') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }, [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) => {
- 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}) => {
- return (
-
- );
- }, []);
+ }, [currentIndex, stickyHeaderIndices]);
const renderFooter = useMemo(() => {
return fetchingCustomEmojis ? : null;
}, [fetchingCustomEmojis]);
- const renderItem = useCallback(({item}: ListRenderItemInfo) => {
- return (
-
- {item.map((emoji: EmojiAlias, index: number) => {
- if (!emoji.name && !emoji.short_name) {
- return (
-
- );
- }
+ const List = useMemo(() => (isTablet ? FlashList : BottomSheetFlashList), [isTablet]);
- return (
-
- );
- })}
-
- );
- }, []);
-
- 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 (
-
+
{isTablet &&
}
);
-};
-
-export default EmojiSections;
+}
diff --git a/app/screens/emoji_picker/picker/sections/section_header.tsx b/app/screens/emoji_picker/picker/sections/section_header.tsx
index 1a79f48ab..1a1c4a497 100644
--- a/app/screens/emoji_picker/picker/sections/section_header.tsx
+++ b/app/screens/emoji_picker/picker/sections/section_header.tsx
@@ -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;
}
diff --git a/app/utils/emoji/prefetch.test.ts b/app/utils/emoji/prefetch.test.ts
new file mode 100644
index 000000000..9dc07777a
--- /dev/null
+++ b/app/utils/emoji/prefetch.test.ts
@@ -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');
+ });
+});
diff --git a/app/utils/emoji/prefetch.ts b/app/utils/emoji/prefetch.ts
new file mode 100644
index 000000000..a417f5501
--- /dev/null
+++ b/app/utils/emoji/prefetch.ts
@@ -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));
+ });
+ }
+}
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index d4ccb7c0e..fb7e4cfda 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 26922201c..2b43c0a88 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index fd06ca461..8c84083cc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/patches/@shopify+flash-list+1.7.3.patch b/patches/@shopify+flash-list+1.7.3.patch
new file mode 100644
index 000000000..e1f38811f
--- /dev/null
+++ b/patches/@shopify+flash-list+1.7.3.patch
@@ -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 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 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 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.
diff --git a/types/screens/emoji_selector.d.ts b/types/screens/emoji_selector.d.ts
index 7ff886cca..e6db41bfd 100644
--- a/types/screens/emoji_selector.d.ts
+++ b/types/screens/emoji_selector.d.ts
@@ -8,15 +8,6 @@ type EmojiAlias = {
category?: string;
}
-type EmojiSection = {
- data: EmojiAlias[][];
- defaultMessage?: string;
- icon: string;
- id: string;
- key: string;
- renderItem?: ({item}: ListRenderItemInfo) => JSX.Element;
-}
-
type CategoryTranslation = {
id: string;
defaultMessage: string;