From 632ff3cfd289bd04f79e57d8e3c8594558c3cb3c Mon Sep 17 00:00:00 2001
From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com>
Date: Tue, 5 May 2026 16:16:45 +0200
Subject: [PATCH 1/2] fix: video thumbnail loading (#3584)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## ๐ฏ Goal
This PR fixes video thumbnail loading logic so that it is consistent
across video thumbnails and image attachments.
Additionally, it:
- Improves performance on Android by reducing the number of moving parts
in the shimmering layer (same as we did for iOS)
- Introduces an orchestrator so that each shimmering view doesn't create
its own `ValueAnimator` unnecessarily
- Makes sure offscreen views are not animating (they still stay
registered though unless they actually unmount/detach)
- Fixes offline images with the new `FastImage` library in the sample
app
## ๐ Implementation details
## ๐จ UI Changes
iOS
Android
## ๐งช Testing
## โ๏ธ Checklist
- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
- [ ] SampleApp iOS and Android
- [ ] Expo iOS and Android
---
.../src/components/FastImageAdapter.tsx | 48 ++++++
.../SampleAppComponentOverrides.tsx | 57 +++----
.../android/StreamShimmerFrameLayout.kt | 156 ++++++++++++------
package/src/components/Attachment/Gallery.tsx | 59 +------
.../components/Attachment/GalleryImage.tsx | 94 ++++++++++-
.../Attachment/ImageLoadingIndicator.tsx | 18 +-
.../components/Attachment/VideoThumbnail.tsx | 31 +++-
.../__tests__/VideoThumbnail.test.tsx | 44 +++++
8 files changed, 346 insertions(+), 161 deletions(-)
create mode 100644 examples/SampleApp/src/components/FastImageAdapter.tsx
create mode 100644 package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx
diff --git a/examples/SampleApp/src/components/FastImageAdapter.tsx b/examples/SampleApp/src/components/FastImageAdapter.tsx
new file mode 100644
index 0000000000..2717160c8d
--- /dev/null
+++ b/examples/SampleApp/src/components/FastImageAdapter.tsx
@@ -0,0 +1,48 @@
+import React, { useMemo } from 'react';
+import { ImageProps } from 'react-native';
+import FastImage from '@d11/react-native-fast-image';
+import type { FastImageProps } from '@d11/react-native-fast-image';
+import { useChatContext } from 'stream-chat-react-native';
+
+type FastImageAdapterProps = Omit &
+ Pick;
+
+export const FastImageAdapter = React.memo((props: ImageProps) => {
+ const { isOnline } = useChatContext();
+ const {
+ source,
+ transition = FastImage.transition.fade,
+ ...rest
+ } = props as FastImageAdapterProps;
+
+ const resolvedSource = useMemo(() => {
+ if (
+ !source ||
+ typeof source !== 'object' ||
+ Array.isArray(source) ||
+ !('uri' in source) ||
+ typeof source.uri !== 'string' ||
+ !/^https?:\/\//i.test(source.uri)
+ ) {
+ return source;
+ }
+
+ return {
+ ...source,
+ cache:
+ source.cache ??
+ (isOnline === false ? FastImage.cacheControl.cacheOnly : FastImage.cacheControl.immutable),
+ priority: source.priority ?? FastImage.priority.normal,
+ };
+ }, [isOnline, source]);
+
+ return (
+ )}
+ source={resolvedSource}
+ transition={transition}
+ />
+ );
+});
+
+FastImageAdapter.displayName = 'FastImageAdapter';
diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx
index 5bba6e1624..78037184c9 100644
--- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx
+++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx
@@ -2,42 +2,39 @@ import React, { useMemo } from 'react';
import { Platform, StyleSheet, useColorScheme, View } from 'react-native';
import type { ComponentOverrides } from 'stream-chat-react-native';
import { BlurView } from '@react-native-community/blur';
-import FastImage from '@d11/react-native-fast-image';
-import {
- useTheme,
-} from 'stream-chat-react-native';
+import { useTheme } from 'stream-chat-react-native';
import { CustomAttachmentPickerContent } from './AttachmentPickerContent';
import { CustomChannelPreviewStatus } from './ChannelPreview';
+import { FastImageAdapter } from './FastImageAdapter';
import { MessageLocation } from './LocationSharing/MessageLocation';
import type { MessageOverlayBackdropConfigItem } from './SecretMenu';
-const MessageOverlayBlurBackground: NonNullable =
- () => {
- const {
- theme: { semantics },
- } = useTheme();
- const scheme = useColorScheme();
- const isDark = scheme === 'dark';
- const isIOS = Platform.OS === 'ios';
+const MessageOverlayBlurBackground: NonNullable<
+ ComponentOverrides['MessageOverlayBackground']
+> = () => {
+ const {
+ theme: { semantics },
+ } = useTheme();
+ const scheme = useColorScheme();
+ const isDark = scheme === 'dark';
+ const isIOS = Platform.OS === 'ios';
- return (
- <>
-
-
- >
- );
- };
+ return (
+ <>
+
+
+ >
+ );
+};
const RenderNull = () => null;
@@ -48,7 +45,7 @@ export const useSampleAppComponentOverrides = (
() => ({
AttachmentPickerContent: CustomAttachmentPickerContent,
ChannelListHeaderNetworkDownIndicator: RenderNull,
- ImageComponent: FastImage,
+ ImageComponent: FastImageAdapter,
MessageLocation,
NetworkDownIndicator: RenderNull,
ChannelPreviewStatus: CustomChannelPreviewStatus,
diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt
index da84863d77..b2f2a64492 100644
--- a/package/shared-native/android/StreamShimmerFrameLayout.kt
+++ b/package/shared-native/android/StreamShimmerFrameLayout.kt
@@ -1,16 +1,16 @@
package com.streamchatreactnative
-import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
+import android.graphics.Rect
import android.graphics.Shader
import android.util.AttributeSet
+import android.view.Choreographer
import android.view.View
-import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import kotlin.math.roundToInt
@@ -38,12 +38,12 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
isDither = true
}
private val shimmerMatrix = Matrix()
+ private val visibleViewportRect = Rect()
private var shimmerShader: LinearGradient? = null
private var shimmerTranslateX: Float = 0f
- private var animatedDurationMs: Long = 0L
- private var animatedViewWidth: Float = 0f
- private var animator: ValueAnimator? = null
+ private var isRegisteredForShimmerFrames: Boolean = false
+ private var shimmerStartTimeNanos: Long = UNSET_FRAME_TIME_NANOS
init {
setWillNotDraw(false)
@@ -68,6 +68,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
if (duration > 0) duration.toLong() else DEFAULT_DURATION_MS
if (durationMs == normalizedDurationMs) return
durationMs = normalizedDurationMs
+ shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
updateAnimatorState()
}
@@ -79,8 +80,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
}
fun updateAnimatorState() {
- // Centralized lifecycle gate for animation start/stop. This keeps shimmer off for detached or
- // hidden views to avoid wasting UI-thread work in long lists.
+ // Centralized lifecycle gate for the shared frame clock. This keeps shimmer off for detached or
+ // hidden views and prevents every mounted shimmer from owning a separate ValueAnimator.
if (shouldAnimateShimmer()) {
startShimmer()
} else {
@@ -96,7 +97,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
}
override fun onDetachedFromWindow() {
- // Detached views are not drawable; stop and clear animator so a future attach starts cleanly.
+ // Detached views are not drawable; unregister so a future attach starts cleanly.
stopShimmer()
super.onDetachedFromWindow()
}
@@ -114,9 +115,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
- if (changedView === this) {
- updateAnimatorState()
- }
+ updateAnimatorState()
}
override fun dispatchDraw(canvas: Canvas) {
@@ -155,13 +154,10 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
return
}
- // Wide multi-stop strip creates a softer "glassy" sweep and avoids the hard thin-line look.
+ // Match iOS CAGradientLayer shimmer stops so both platforms have the same visual falloff.
val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
val transparentHighlight = colorWithAlpha(gradientColor, 0f)
- val edgeBase = colorWithAlpha(gradientColor, EDGE_HIGHLIGHT_ALPHA_FACTOR)
val softBase = colorWithAlpha(gradientColor, SOFT_HIGHLIGHT_ALPHA_FACTOR)
- val mediumBase = colorWithAlpha(gradientColor, MID_HIGHLIGHT_ALPHA_FACTOR)
- val innerBase = colorWithAlpha(gradientColor, INNER_HIGHLIGHT_ALPHA_FACTOR)
shimmerShader = LinearGradient(
0f,
0f,
@@ -169,28 +165,16 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
0f,
intArrayOf(
transparentHighlight,
- edgeBase,
softBase,
- mediumBase,
- innerBase,
gradientColor,
- innerBase,
- mediumBase,
softBase,
- edgeBase,
transparentHighlight,
),
floatArrayOf(
0f,
- 0.08f,
- 0.2f,
- 0.32f,
- 0.4f,
+ 0.35f,
0.5f,
- 0.6f,
- 0.68f,
- 0.8f,
- 0.92f,
+ 0.65f,
1f,
),
Shader.TileMode.CLAMP,
@@ -198,34 +182,59 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
}
private fun startShimmer() {
+ if (isRegisteredForShimmerFrames) return
val viewWidth = width.toFloat()
- if (viewWidth <= 0f) return
- // Keep the existing animator only when size and duration still match the current request.
- if (animator != null && animatedViewWidth == viewWidth && animatedDurationMs == durationMs) return
+ shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
+ if (viewWidth > 0f) {
+ shimmerTranslateX = -(viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
+ }
+ isRegisteredForShimmerFrames = true
+ StreamShimmerFrameClock.register(this)
+ }
- stopShimmer()
+ private fun stopShimmer() {
+ if (isRegisteredForShimmerFrames) {
+ isRegisteredForShimmerFrames = false
+ StreamShimmerFrameClock.unregister(this)
+ }
+ resetShimmerFrameState()
+ }
+
+ internal fun onSharedShimmerFrame(frameTimeNanos: Long) {
+ val viewWidth = width.toFloat()
+ if (viewWidth <= 0f || !hasVisibleViewport()) return
+
+ if (shimmerStartTimeNanos == UNSET_FRAME_TIME_NANOS) {
+ shimmerStartTimeNanos = frameTimeNanos
+ }
// Animate from fully offscreen left to fully offscreen right so the strip enters/exits cleanly.
val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
- animatedViewWidth = viewWidth
- animatedDurationMs = durationMs
- animator = ValueAnimator.ofFloat(-shimmerWidth, viewWidth).apply {
- duration = durationMs
- repeatCount = ValueAnimator.INFINITE
- interpolator = LinearInterpolator()
- addUpdateListener {
- shimmerTranslateX = it.animatedValue as Float
- invalidate()
- }
- start()
- }
+ val durationNanos = (durationMs * NANOS_PER_MILLISECOND).coerceAtLeast(1L)
+ val elapsedNanos = (frameTimeNanos - shimmerStartTimeNanos).coerceAtLeast(0L)
+ val progress = (elapsedNanos % durationNanos).toFloat() / durationNanos.toFloat()
+
+ shimmerTranslateX = -shimmerWidth + ((viewWidth + shimmerWidth) * progress)
+ invalidate()
}
- private fun stopShimmer() {
- animator?.cancel()
- animator = null
- animatedDurationMs = 0L
- animatedViewWidth = 0f
+ internal fun onRemovedFromSharedFrameClock() {
+ isRegisteredForShimmerFrames = false
+ resetShimmerFrameState()
+ }
+
+ internal fun shouldRunSharedShimmerFrame(): Boolean {
+ return shouldAnimateShimmer()
+ }
+
+ private fun hasVisibleViewport(): Boolean {
+ visibleViewportRect.setEmpty()
+ return getGlobalVisibleRect(visibleViewportRect) && !visibleViewportRect.isEmpty
+ }
+
+ private fun resetShimmerFrameState() {
+ shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
+ shimmerTranslateX = 0f
}
private fun shouldAnimateShimmer(): Boolean {
@@ -251,10 +260,51 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
private const val DEFAULT_BASE_COLOR = 0x00FFFFFF
private const val DEFAULT_DURATION_MS = 1200L
private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF
+ private const val NANOS_PER_MILLISECOND = 1_000_000L
private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f
- private const val EDGE_HIGHLIGHT_ALPHA_FACTOR = 0.1f
private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f
- private const val MID_HIGHLIGHT_ALPHA_FACTOR = 0.48f
- private const val INNER_HIGHLIGHT_ALPHA_FACTOR = 0.72f
+ private const val UNSET_FRAME_TIME_NANOS = -1L
+ }
+}
+
+private object StreamShimmerFrameClock : Choreographer.FrameCallback {
+ private val activeViews = LinkedHashSet()
+ private var frameScheduled = false
+
+ fun register(view: StreamShimmerFrameLayout) {
+ activeViews.add(view)
+ scheduleNextFrame()
+ }
+
+ fun unregister(view: StreamShimmerFrameLayout) {
+ activeViews.remove(view)
+ if (activeViews.isEmpty() && frameScheduled) {
+ Choreographer.getInstance().removeFrameCallback(this)
+ frameScheduled = false
+ }
+ }
+
+ override fun doFrame(frameTimeNanos: Long) {
+ frameScheduled = false
+ if (activeViews.isEmpty()) return
+
+ val iterator = activeViews.iterator()
+ while (iterator.hasNext()) {
+ val view = iterator.next()
+ if (view.shouldRunSharedShimmerFrame()) {
+ view.onSharedShimmerFrame(frameTimeNanos)
+ } else {
+ iterator.remove()
+ view.onRemovedFromSharedFrameClock()
+ }
+ }
+
+ scheduleNextFrame()
+ }
+
+ private fun scheduleNextFrame() {
+ if (frameScheduled || activeViews.isEmpty()) return
+ Choreographer.getInstance().postFrameCallback(this)
+ frameScheduled = true
}
}
diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx
index 716d2e325c..0b933819eb 100644
--- a/package/src/components/Attachment/Gallery.tsx
+++ b/package/src/components/Attachment/Gallery.tsx
@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
-import { ImageErrorEvent, Pressable, StyleSheet, Text, View } from 'react-native';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { Attachment, LocalMessage } from 'stream-chat';
-import { GalleryImage } from './GalleryImage';
+import { LoadableGalleryImage } from './GalleryImage';
import { buildGallery } from './utils/buildGallery/buildGallery';
import type { Thumbnail } from './utils/buildGallery/types';
@@ -35,8 +35,6 @@ import {
} from '../../contexts/overlayContext/OverlayContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { useLoadingImage } from '../../hooks/useLoadingImage';
-import { useStableCallback } from '../../hooks/useStableCallback';
import { isVideoPlayerAvailable } from '../../native';
import { primitives } from '../../theme';
import { FileTypes } from '../../types/types';
@@ -331,15 +329,6 @@ const GalleryImageThumbnail = ({
borderRadius,
thumbnail,
}: Pick) => {
- const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } =
- useComponentsContext();
- const {
- isLoadingImage,
- isLoadingImageError,
- onReloadImage,
- setLoadingImage,
- setLoadingImageError,
- } = useLoadingImage();
const {
theme: {
messageItemView: { gallery },
@@ -347,44 +336,14 @@ const GalleryImageThumbnail = ({
} = useTheme();
const styles = useStyles();
- const onLoadStart = useStableCallback(() => {
- setLoadingImageError(false);
- setLoadingImage(true);
- });
- const onLoad = useStableCallback(() => {
- setTimeout(() => {
- setLoadingImage(false);
- setLoadingImageError(false);
- }, 0);
- });
- const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => {
- console.warn(error);
- setLoadingImage(false);
- setLoadingImageError(true);
- });
return (
-
- {isLoadingImageError ? (
-
- ) : (
- <>
-
- {isLoadingImage ? : null}
-
- >
- )}
-
+
);
};
const areEqual = (prevProps: GalleryPropsWithContext, nextProps: GalleryPropsWithContext) => {
diff --git a/package/src/components/Attachment/GalleryImage.tsx b/package/src/components/Attachment/GalleryImage.tsx
index 70a754d540..0b7d0b7221 100644
--- a/package/src/components/Attachment/GalleryImage.tsx
+++ b/package/src/components/Attachment/GalleryImage.tsx
@@ -1,7 +1,18 @@
import React from 'react';
-import { Image, ImageProps, StyleSheet } from 'react-native';
+import {
+ Image,
+ ImageErrorEvent,
+ ImageProps,
+ ImageStyle,
+ StyleProp,
+ StyleSheet,
+ View,
+ ViewStyle,
+} from 'react-native';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
+import { useLoadingImage } from '../../hooks/useLoadingImage';
+import { useStableCallback } from '../../hooks/useStableCallback';
import { getUrlWithoutParams, isLocalUrl, makeImageCompatibleUrl } from '../../utils/utils';
@@ -10,7 +21,13 @@ export type GalleryImageWithContextProps = GalleryImageProps & {
};
export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => {
- const { ImageComponent = Image, uri, style, ...rest } = props;
+ const {
+ accessibilityLabel = 'Gallery Image',
+ ImageComponent = Image,
+ uri,
+ style,
+ ...rest
+ } = props;
// Caching image components such as FastImage will not work with local images.
// This for the case of local uris, we use the default Image component.
@@ -18,7 +35,7 @@ export const GalleryImageWithContext = (props: GalleryImageWithContextProps) =>
return (
return (
{
return ;
};
+export type LoadableGalleryImageProps = Pick<
+ GalleryImageProps,
+ 'accessibilityLabel' | 'resizeMode' | 'uri'
+> & {
+ children?: React.ReactNode;
+ containerStyle?: StyleProp;
+ imageStyle?: StyleProp;
+ localId?: string;
+};
+
+export const LoadableGalleryImage = ({
+ accessibilityLabel = 'Gallery Image',
+ children,
+ containerStyle,
+ imageStyle,
+ localId,
+ resizeMode,
+ uri,
+}: LoadableGalleryImageProps) => {
+ const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } =
+ useComponentsContext();
+ const {
+ isLoadingImage,
+ isLoadingImageError,
+ onReloadImage,
+ setLoadingImage,
+ setLoadingImageError,
+ } = useLoadingImage();
+
+ const onLoadStart = useStableCallback(() => {
+ setLoadingImageError(false);
+ setLoadingImage(true);
+ });
+ const onLoad = useStableCallback(() => {
+ setTimeout(() => {
+ setLoadingImage(false);
+ setLoadingImageError(false);
+ }, 0);
+ });
+ const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => {
+ console.warn(error);
+ setLoadingImage(false);
+ setLoadingImageError(true);
+ });
+
+ return (
+
+ {isLoadingImageError ? (
+
+ ) : (
+ <>
+
+ {children}
+ {isLoadingImage ? : null}
+
+ >
+ )}
+
+ );
+};
+
const styles = StyleSheet.create({
image: {
flex: 1,
diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx
index 29897f22ba..c5958f11b4 100644
--- a/package/src/components/Attachment/ImageLoadingIndicator.tsx
+++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx
@@ -1,10 +1,10 @@
import React from 'react';
-import { ActivityIndicator, StyleSheet, View } from 'react-native';
+import { StyleSheet } from 'react-native';
import { useTheme } from '../../contexts';
import { NativeShimmerView } from '../UIComponents/NativeShimmerView';
-export const ImageLoadingIndicator = () => {
+export const ImageLoadingIndicator = React.memo(() => {
const {
theme: { semantics },
} = useTheme();
@@ -15,18 +15,6 @@ export const ImageLoadingIndicator = () => {
enabled
gradientColor={semantics.skeletonLoadingHighlight}
style={StyleSheet.absoluteFill}
- >
-
-
-
-
+ />
);
-};
-
-const styles = StyleSheet.create({
- centered: {
- alignItems: 'center',
- flex: 1,
- justifyContent: 'center',
- },
});
diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx
index 8e30036bbb..118ab96e91 100644
--- a/package/src/components/Attachment/VideoThumbnail.tsx
+++ b/package/src/components/Attachment/VideoThumbnail.tsx
@@ -1,7 +1,8 @@
import React from 'react';
import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
-import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
+import { LoadableGalleryImage } from './GalleryImage';
+
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { VideoPlayIndicator } from '../ui/VideoPlayIndicator';
@@ -12,6 +13,10 @@ const styles = StyleSheet.create({
flex: 1,
overflow: 'hidden',
},
+ playIndicatorContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
});
export type VideoThumbnailProps = {
@@ -32,18 +37,26 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => {
},
},
} = useTheme();
- const { AttachmentUploadIndicator, ImageComponent } = useComponentsContext();
const { imageStyle, localId, style, thumb_url } = props;
return (
-
-
-
+ {thumb_url ? (
+
+
+
+
+
+ ) : null}
);
};
diff --git a/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx
new file mode 100644
index 0000000000..7aa79b5783
--- /dev/null
+++ b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { ImageProps, View } from 'react-native';
+
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext';
+import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext';
+import { VideoThumbnail } from '../VideoThumbnail';
+
+const CustomImageComponent = (props: ImageProps) => (
+
+);
+
+const renderVideoThumbnail = (thumb_url: string) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe('VideoThumbnail', () => {
+ it('uses the configured ImageComponent for remote thumbnail URLs', () => {
+ renderVideoThumbnail('https://example.com/video-thumbnail.jpg');
+
+ expect(screen.getByTestId('custom-image-component')).toBeTruthy();
+ });
+
+ it('uses the default Image fallback for local thumbnail paths', () => {
+ renderVideoThumbnail('file:///tmp/video-thumbnail.jpg');
+
+ expect(screen.queryByTestId('custom-image-component')).toBeNull();
+ expect(screen.getByLabelText('Video Thumbnail')).toBeTruthy();
+ });
+
+ it('renders the image loading indicator while the thumbnail is loading', () => {
+ renderVideoThumbnail('file:///tmp/video-thumbnail.jpg');
+
+ fireEvent(screen.getByLabelText('Video Thumbnail'), 'loadStart');
+
+ expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy();
+ });
+});
From 25f8d36b8876d797e528d1510630e74b0a4e8491 Mon Sep 17 00:00:00 2001
From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com>
Date: Tue, 5 May 2026 16:56:33 +0200
Subject: [PATCH 2/2] fix: bump stream-chat to fix commands regression (#3585)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## ๐ฏ Goal
## ๐ Implementation details
## ๐จ UI Changes
iOS
Android
## ๐งช Testing
## โ๏ธ Checklist
- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
- [ ] SampleApp iOS and Android
- [ ] Expo iOS and Android
---
package/package.json | 2 +-
package/src/utils/utils.ts | 8 ++++----
package/yarn.lock | 8 ++++----
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/package/package.json b/package/package.json
index 8d2252c573..21b1d25e0d 100644
--- a/package/package.json
+++ b/package/package.json
@@ -83,7 +83,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
- "stream-chat": "^9.43.0",
+ "stream-chat": "^9.43.1",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts
index 2c6533634f..282a72dcb6 100644
--- a/package/src/utils/utils.ts
+++ b/package/src/utils/utils.ts
@@ -325,8 +325,8 @@ export const findInMessagesByDate = (
* Useful for the `areEqual` logic in the React.memo of the Message component/sub-components.
*/
export const checkMessageEquality = (
- prevMessage?: LocalMessage,
- nextMessage?: LocalMessage,
+ prevMessage?: LocalMessage | null,
+ nextMessage?: LocalMessage | null,
): boolean => {
const prevMessageExists = !!prevMessage;
const nextMessageExists = !!nextMessage;
@@ -357,8 +357,8 @@ export const checkMessageEquality = (
* Useful for the `areEqual` logic in the React.memo of the Message component/sub-components.
*/
export const checkQuotedMessageEquality = (
- prevQuotedMessage?: LocalMessage,
- nextQuotedMessage?: LocalMessage,
+ prevQuotedMessage?: LocalMessage | null,
+ nextQuotedMessage?: LocalMessage | null,
): boolean => {
const prevQuotedMessageExists = !!prevQuotedMessage;
const nextQuotedMessageExists = !!nextQuotedMessage;
diff --git a/package/yarn.lock b/package/yarn.lock
index e43da00e63..14eff97d5e 100644
--- a/package/yarn.lock
+++ b/package/yarn.lock
@@ -8507,10 +8507,10 @@ stdin-discarder@^0.2.2:
resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be"
integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==
-stream-chat@^9.43.0:
- version "9.43.0"
- resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.0.tgz#216a80abadea83dcee6fb339b76035b26af2beb5"
- integrity sha512-gc12LZTmRWvSi6EjnMK7Y+D8xOQIouVUO2flUShazG/NqVccJhXYphQ96PzK7Wym+5wwwitTaJqq0m/1VUPBCA==
+stream-chat@^9.43.1:
+ version "9.43.1"
+ resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.1.tgz#5b2cccdd95ce92cc44c6691c527eeee271ce37bd"
+ integrity sha512-lP1B3ulv2B20tqbn0xWUaVuKgBPAtgiKRGTBgmZsAIcOKDziR0xbYmZuC8zo9+L6yPh3euSdbF5w+CQ/Rn1FiQ==
dependencies:
"@types/jsonwebtoken" "^9.0.8"
"@types/ws" "^8.5.14"