diff --git a/app/components/block_renderer/mm_blocks_image.tsx b/app/components/block_renderer/mm_blocks_image.tsx index f54ee49920..c91a0e3936 100644 --- a/app/components/block_renderer/mm_blocks_image.tsx +++ b/app/components/block_renderer/mm_blocks_image.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {type ImageLoadEventData} from 'expo-image'; -import React, {useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; import {Pressable, View} from 'react-native'; import Animated from 'react-native-reanimated'; import {SvgUri} from 'react-native-svg'; @@ -86,7 +86,7 @@ const MmBlocksImageContent = ({ return computeMmBlocksImageLayout(caps, layoutWidth, imageMetadata, viewPortWidth); }, [caps, imageMetadata, intrinsicSize, layoutWidth, viewPortWidth]); - const fileId = useRef(`uid-mm-blocks-image-${urlSafeBase64Encode(imageUrl)}`); + const fileId = useMemo(() => `uid-mm-blocks-image-${urlSafeBase64Encode(imageUrl)}`, [imageUrl]); const dimensionsStyle = useMemo(() => ({ width: layout.width, @@ -97,7 +97,7 @@ const MmBlocksImageContent = ({ const onPress = useCallback(() => { const item: GalleryItemType = { - id: fileId.current, + id: fileId, postId, uri: displaySrc, width: layout.galleryWidth, @@ -106,10 +106,10 @@ const MmBlocksImageContent = ({ mime_type: lookupMimeType(imageUrl) || 'image/png', type: 'image', lastPictureUpdate: 0, - cacheKey: fileId.current, + cacheKey: fileId, }; openGalleryAtIndex(galleryIdentifier, 0, [item]); - }, [altText, displaySrc, galleryIdentifier, imageUrl, layout.galleryHeight, layout.galleryWidth, postId]); + }, [altText, displaySrc, fileId, galleryIdentifier, imageUrl, layout.galleryHeight, layout.galleryWidth, postId]); const {ref, onGestureEvent, styles: galleryStyles} = useGalleryItem( galleryIdentifier, @@ -159,9 +159,9 @@ const MmBlocksImageContent = ({ return ( ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('@context/gallery', () => ({ + GalleryInit: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@hooks/device', () => ({ + useIsTablet: () => false, +})); + +jest.mock('@hooks/gallery', () => ({ + useGalleryItem: jest.fn((_galleryIdentifier, _index, onPress) => ({ + ref: {current: null}, + onGestureEvent: onPress, + styles: {}, + })), +})); + +jest.mock('@utils/gallery', () => ({ + openGalleryAtIndex: jest.fn(), +})); + +jest.mock('@utils/url', () => ({ + ...jest.requireActual('@utils/url'), + isValidUrl: (url: string) => url.startsWith('https://'), +})); + +const IMAGE_URL = 'https://example.com/quotes/image/AAPL_day.png'; +const IMAGE_URL_2 = 'https://example.com/quotes/image/AAPL_week.png'; +const IMAGE_METADATA = {width: 700, height: 300, format: 'png', frame_count: 0} as PostImage; + +describe('AttachmentImage', () => { + const theme = Preferences.THEMES.denim; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function buildElement(props: Partial> = {}) { + return ( + + ); + } + + function renderImage(props: Partial> = {}) { + return renderWithIntlAndTheme(buildElement(props)); + } + + function lastProgressiveImageId() { + const calls = jest.mocked(ProgressiveImage).mock.calls; + return calls[calls.length - 1][0].id; + } + + it('should render ProgressiveImage with the image and a cache id derived from imageUrl', () => { + renderImage(); + + expect(ProgressiveImage).toHaveBeenCalledWith( + expect.objectContaining({ + imageUri: IMAGE_URL, + id: `uid-${urlSafeBase64Encode(IMAGE_URL)}`, + }), + undefined, + ); + }); + + // Regression test for the stale-image bug: when the same post is edited in place + // to a new image URL, the cache id must follow imageUrl. Previously it was frozen + // via useRef, so the new image rendered under the old cache key and stayed stale. + it('should update the cache id when imageUrl changes on re-render', () => { + const {rerender} = renderImage({imageUrl: IMAGE_URL}); + const firstId = lastProgressiveImageId(); + expect(firstId).toBe(`uid-${urlSafeBase64Encode(IMAGE_URL)}`); + + rerender(buildElement({imageUrl: IMAGE_URL_2})); + const secondId = lastProgressiveImageId(); + + expect(secondId).toBe(`uid-${urlSafeBase64Encode(IMAGE_URL_2)}`); + expect(secondId).not.toBe(firstId); + }); + + it('should open the gallery with a cacheKey matching the current imageUrl', () => { + renderImage(); + + // Trigger the gallery open via the captured gesture handler. + const {useGalleryItem} = jest.requireMock('@hooks/gallery'); + const onGestureEvent = jest.mocked(useGalleryItem).mock.results.at(-1)!.value.onGestureEvent; + onGestureEvent(); + + const galleryItems = jest.mocked(openGalleryAtIndex).mock.calls[0][2]; + expect(galleryItems).toHaveLength(1); + expect(openGalleryAtIndex).toHaveBeenCalledWith( + `post-id-AttachmentImage-${Screens.CHANNEL}`, + 0, + expect.arrayContaining([ + expect.objectContaining({ + id: `uid-${urlSafeBase64Encode(IMAGE_URL)}`, + cacheKey: `uid-${urlSafeBase64Encode(IMAGE_URL)}`, + uri: IMAGE_URL, + }), + ]), + ); + }); + + it('should render an error frame for an invalid url and skip ProgressiveImage', () => { + renderImage({imageUrl: 'not-a-url'}); + expect(ProgressiveImage).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx index 2eea4f6e72..e52057be2b 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {TouchableWithoutFeedback, View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -55,10 +55,7 @@ export type Props = { const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => { const galleryIdentifier = `${postId}-AttachmentImage-${location}`; const [error, setError] = useState(false); - const fileId = useRef(null); - if (fileId.current === null) { - fileId.current = `uid-${urlSafeBase64Encode(imageUrl)}`; - } + const fileId = useMemo(() => `uid-${urlSafeBase64Encode(imageUrl)}`, [imageUrl]); const isTablet = useIsTablet(); const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet, true)); const style = getStyleSheet(theme); @@ -70,7 +67,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId const onPress = () => { const item: GalleryItemType = { - id: fileId.current || '', + id: fileId || '', postId, uri: imageUrl, width: imageMetadata.width, @@ -79,7 +76,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId mime_type: lookupMimeType(imageUrl) || 'image/png', type: 'image', lastPictureUpdate: 0, - cacheKey: fileId.current || '', + cacheKey: fileId || '', }; openGalleryAtIndex(galleryIdentifier, 0, [item]); }; @@ -109,7 +106,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId