diff --git a/app/containers/Avatar/Avatar.tsx b/app/containers/Avatar/Avatar.tsx index f5da333dc0e..f8372d59337 100644 --- a/app/containers/Avatar/Avatar.tsx +++ b/app/containers/Avatar/Avatar.tsx @@ -1,15 +1,13 @@ import { memo } from 'react'; import { View } from 'react-native'; -import { Image } from 'expo-image'; -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import Emoji from '../markdown/components/emoji/Emoji'; import { getAvatarURL } from '~/lib/methods/helpers/getAvatarUrl'; -import { headers } from '~/lib/methods/helpers/fetch'; import { SubscriptionType } from '~/definitions'; import { type IAvatar } from './interfaces'; import I18n from '~/i18n'; import Touch from '../Touch'; +import AvatarImage from './AvatarImage'; import { AVATAR_BORDER_RADIUS } from './constants'; const Avatar = memo( @@ -78,16 +76,7 @@ const Avatar = memo( }); } - image = ( - - ); + image = ; } if (onPress) { diff --git a/app/containers/Avatar/AvatarImage.tsx b/app/containers/Avatar/AvatarImage.tsx new file mode 100644 index 00000000000..ca8fba0e361 --- /dev/null +++ b/app/containers/Avatar/AvatarImage.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { Image } from 'expo-image'; +import { settings as RocketChatSettings } from '@rocket.chat/sdk'; + +import { headers } from '~/lib/methods/helpers/fetch'; +import { useTheme } from '~/theme'; +import AvatarSkeleton from './AvatarSkeleton'; + +interface IAvatarImage { + uri?: string; + size: number; + borderRadius: number; +} + +const AvatarImage = ({ uri, size, borderRadius }: IAvatarImage) => { + const { colors } = useTheme(); + const [finishedUri, setFinishedUri] = useState(); + const showSkeleton = !!uri && finishedUri !== uri; + + const onFinish = () => setFinishedUri(uri); + + return ( + <> + + {showSkeleton ? : null} + + ); +}; + +export default AvatarImage; diff --git a/app/containers/Avatar/AvatarSkeleton.tsx b/app/containers/Avatar/AvatarSkeleton.tsx new file mode 100644 index 00000000000..448f91074b1 --- /dev/null +++ b/app/containers/Avatar/AvatarSkeleton.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; + +import { useTheme } from '~/theme'; +import { SKELETON_DELAY } from './constants'; + +interface IAvatarSkeleton { + size: number; + borderRadius: number; +} + +const AvatarSkeleton = ({ size, borderRadius }: IAvatarSkeleton) => { + const { colors } = useTheme(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setVisible(true), SKELETON_DELAY); + return () => clearTimeout(timer); + }, []); + + if (!visible) { + return null; + } + + return ( + + + + + + ); +}; + +export default AvatarSkeleton; diff --git a/app/containers/Avatar/Avatar.test.tsx b/app/containers/Avatar/__tests__/Avatar.test.tsx similarity index 68% rename from app/containers/Avatar/Avatar.test.tsx rename to app/containers/Avatar/__tests__/Avatar.test.tsx index d86986bbf56..498792bbc1d 100644 --- a/app/containers/Avatar/Avatar.test.tsx +++ b/app/containers/Avatar/__tests__/Avatar.test.tsx @@ -1,4 +1,4 @@ import { generateSnapshots } from '~/.rnstorybook/generateSnapshots'; -import * as stories from './Avatar.stories'; +import * as stories from '../Avatar.stories'; generateSnapshots(stories); diff --git a/app/containers/Avatar/__tests__/AvatarImage.test.tsx b/app/containers/Avatar/__tests__/AvatarImage.test.tsx new file mode 100644 index 00000000000..9a14884139c --- /dev/null +++ b/app/containers/Avatar/__tests__/AvatarImage.test.tsx @@ -0,0 +1,68 @@ +import { act, fireEvent, render } from '@testing-library/react-native'; +import { Image } from 'expo-image'; + +import AvatarImage from '../AvatarImage'; +import { SKELETON_DELAY } from '../constants'; + +const showDelay = () => act(() => jest.advanceTimersByTime(SKELETON_DELAY)); + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +describe('AvatarImage', () => { + it('shows the skeleton until the image loads', () => { + const { getByTestId, queryByTestId, UNSAFE_getByType } = render( + + ); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + showDelay(); + expect(getByTestId('avatar-skeleton')).toBeTruthy(); + fireEvent(UNSAFE_getByType(Image), 'load'); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + }); + + it('hides the skeleton when the image fails to load', () => { + const { queryByTestId, UNSAFE_getByType } = render(); + showDelay(); + fireEvent(UNSAFE_getByType(Image), 'error'); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + }); + + it('restarts the delay when the uri changes after a load', () => { + const { queryByTestId, rerender, UNSAFE_getByType } = render( + + ); + showDelay(); + fireEvent(UNSAFE_getByType(Image), 'load'); + rerender(); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + showDelay(); + expect(queryByTestId('avatar-skeleton')).toBeTruthy(); + }); + + it('keeps the skeleton when the uri changes before the image loads', () => { + const { getByTestId, rerender } = render(); + showDelay(); + rerender(); + expect(getByTestId('avatar-skeleton')).toBeTruthy(); + }); + + it('hides the skeleton when the uri is removed after a load', () => { + const { queryByTestId, rerender, UNSAFE_getByType } = render( + + ); + fireEvent(UNSAFE_getByType(Image), 'load'); + rerender(); + showDelay(); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + }); + + it('never shows the skeleton when the image loads before the delay', () => { + const { queryByTestId, UNSAFE_getByType } = render(); + act(() => jest.advanceTimersByTime(SKELETON_DELAY - 1)); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + fireEvent(UNSAFE_getByType(Image), 'load'); + showDelay(); + expect(queryByTestId('avatar-skeleton')).toBeNull(); + }); +}); diff --git a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap b/app/containers/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap similarity index 67% rename from app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap rename to app/containers/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap index 7f4e3e01109..0ad181b0981 100644 --- a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap +++ b/app/containers/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -17,6 +17,7 @@ exports[`Story Snapshots: AvatarBase64 should match snapshot 1`] = ` testID="avatar" > ({ + start: { x: 0, y: 0 }, + end: { x: 1, y: 0 }, +- style: Object.assign(Object.assign({}, react_native_1.StyleSheet.absoluteFillObject), { width }), ++ style: Object.assign(Object.assign({}, react_native_1.StyleSheet.absoluteFill), { width }), + }); + const getItemStyle = (_a) => { + var { children: _, style } = _a, styleFromProps = __rest(_a, ["children", "style"]);