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;