Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
🐞 Fixed

- Fixed mistakenly passing the hyperlink text to the `onLinkTap` callback instead of the actual `href`.
- Fixed high memory usage when displaying multiple image
attachments. [[#2228]](https://github.com/GetStream/stream-chat-flutter/issues/2228)

## 9.19.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class StreamImageAttachment extends StatelessWidget {
required this.image,
this.shape,
this.constraints = const BoxConstraints(),
this.imageThumbnailSize = const Size(400, 400),
this.imageThumbnailSize,
this.imageThumbnailResizeType = 'clip',
this.imageThumbnailCropType = 'center',
});
Expand All @@ -32,7 +32,7 @@ class StreamImageAttachment extends StatelessWidget {
final BoxConstraints constraints;

/// Size of the attachment image thumbnail.
final Size imageThumbnailSize;
final Size? imageThumbnailSize;

/// Resize type of the image attachment thumbnail.
///
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';

/// {@template giphyAttachmentThumbnail}
Expand Down Expand Up @@ -58,46 +55,16 @@ class StreamGiphyAttachmentThumbnail extends StatelessWidget {

@override
Widget build(BuildContext context) {
// If the giphy info is not available, use the image attachment thumbnail
// instead.
// Get the giphy info based on the selected type.
final info = giphy.giphyInfo(type);
if (info == null) {
return StreamImageAttachmentThumbnail(
image: giphy,
width: width,
height: height,
fit: fit,
);
}

return CachedNetworkImage(
imageUrl: info.url,
// Build the image attachment thumbnail using the giphy info url if
// available or fallback to the original giphy url.
return StreamImageAttachmentThumbnail(
image: giphy.copyWith(imageUrl: info?.url),
width: width,
height: height,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
'lib/assets/images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);

final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (context, url, error) {
return errorBuilder(
context,
error,
StackTrace.current,
);
},
errorBuilder: errorBuilder,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/utils/utils.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Expand Down Expand Up @@ -72,43 +73,64 @@ class StreamImageAttachmentThumbnail extends StatelessWidget {

@override
Widget build(BuildContext context) {
final file = image.file;
if (file != null) {
return _LocalImageAttachment(
file: file,
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Calculate optimal thumbnail size once for all paths
final effectiveThumbnailSize = switch (thumbnailSize) {
final thumbnailSize? => thumbnailSize,
_ => ThumbnailSizeCalculator.calculate(
targetSize: constraints.biggest,
originalSize: image.originalSize,
pixelRatio: MediaQuery.devicePixelRatioOf(context),
),
};

final cacheWidth = effectiveThumbnailSize?.width.round();
final cacheHeight = effectiveThumbnailSize?.height.round();

// If the remote image URL is available, we can directly show it using
// the _RemoteImageAttachment widget.
if (image.thumbUrl ?? image.imageUrl case final imageUrl?) {
var resizedImageUrl = imageUrl;
if (effectiveThumbnailSize case final thumbnailSize?) {
resizedImageUrl = imageUrl.getResizedImageUrl(
crop: thumbnailCropType,
resize: thumbnailResizeType,
width: thumbnailSize.width,
height: thumbnailSize.height,
);
}

return _RemoteImageAttachment(
url: resizedImageUrl,
width: width,
height: height,
fit: fit,
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
errorBuilder: errorBuilder,
);
}

// Otherwise, we try to show the local image file.
if (image.file case final file?) {
return _LocalImageAttachment(
file: file,
width: width,
height: height,
fit: fit,
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
errorBuilder: errorBuilder,
);
}

var imageUrl = image.thumbUrl ?? image.imageUrl ?? image.assetUrl;
if (imageUrl != null) {
final thumbnailSize = this.thumbnailSize;
if (thumbnailSize != null) {
imageUrl = imageUrl.getResizedImageUrl(
width: thumbnailSize.width,
height: thumbnailSize.height,
resize: thumbnailResizeType,
crop: thumbnailCropType,
return errorBuilder(
context,
'Image attachment is not valid',
StackTrace.current,
);
}

return _RemoteImageAttachment(
url: imageUrl,
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}

// Return error widget if no image is found.
return errorBuilder(
context,
'Image attachment is not valid',
StackTrace.current,
},
);
}
}
Expand All @@ -119,12 +141,16 @@ class _LocalImageAttachment extends StatelessWidget {
required this.errorBuilder,
this.width,
this.height,
this.cacheWidth,
this.cacheHeight,
this.fit,
});

final AttachmentFile file;
final double? width;
final double? height;
final int? cacheWidth;
final int? cacheHeight;
final BoxFit? fit;
final ThumbnailErrorBuilder errorBuilder;

Expand All @@ -136,6 +162,8 @@ class _LocalImageAttachment extends StatelessWidget {
bytes,
width: width,
height: height,
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
fit: fit,
errorBuilder: errorBuilder,
);
Expand All @@ -147,6 +175,8 @@ class _LocalImageAttachment extends StatelessWidget {
File(path),
width: width,
height: height,
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
fit: fit,
errorBuilder: errorBuilder,
);
Expand All @@ -167,12 +197,16 @@ class _RemoteImageAttachment extends StatelessWidget {
required this.errorBuilder,
this.width,
this.height,
this.cacheWidth,
this.cacheHeight,
this.fit,
});

final String url;
final double? width;
final double? height;
final int? cacheWidth;
final int? cacheHeight;
final BoxFit? fit;
final ThumbnailErrorBuilder errorBuilder;

Expand All @@ -182,6 +216,8 @@ class _RemoteImageAttachment extends StatelessWidget {
imageUrl: url,
width: width,
height: height,
memCacheWidth: cacheWidth,
memCacheHeight: cacheHeight,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'dart:ui';

/// Utility class for calculating optimal thumbnail sizes for image
/// attachments.
///
/// This calculator ensures that images are decoded and cached at
/// appropriate sizes based on display constraints, maintaining aspect
/// ratio while accounting for device pixel density.
class ThumbnailSizeCalculator {
ThumbnailSizeCalculator._();

/// Calculates the optimal thumbnail size for an image attachment.
///
/// Returns `null` if:
/// - Both [targetSize] dimensions are infinite
/// - [originalSize] is not available (needed for aspect ratio)
///
/// The calculation:
/// 1. Handles infinite constraints by calculating from the finite
/// dimension
/// 2. Maintains aspect ratio to prevent image distortion
/// 3. Applies [pixelRatio] for device-appropriate resolution
///
/// Example:
/// ```dart
/// final size = ThumbnailSizeCalculator.calculate(
/// originalSize: Size(1920, 1080),
/// targetSize: Size(400, 300),
/// pixelRatio: 2.0,
/// );
/// // Returns: Size(800, 450) - maintains 16:9 aspect ratio,
/// // scaled for 2x display
/// ```
static Size? calculate({
Size? originalSize,
required Size targetSize,
required double pixelRatio,
}) {
final originalAspectRatio = originalSize?.aspectRatio;
// If original aspect ratio is not available, skip optimization
// We need the aspect ratio to avoid incorrect cropping
if (originalAspectRatio == null) return null;

// Invalid aspect ratio indicates invalid original size
if (originalAspectRatio.isInfinite || originalAspectRatio <= 0) {
return null;
}

var thumbnailWidth = targetSize.width;
var thumbnailHeight = targetSize.height;

// Cannot calculate optimal size with infinite constraints
if (thumbnailWidth.isInfinite && thumbnailHeight.isInfinite) {
return null;
}

if (thumbnailWidth.isInfinite) {
// Width is infinite, calculate from height
thumbnailWidth = thumbnailHeight * originalAspectRatio;
}
if (thumbnailHeight.isInfinite) {
// Height is infinite, calculate from width
thumbnailHeight = thumbnailWidth / originalAspectRatio;
}

// Calculate size that maintains aspect ratio within constraints
final targetAspectRatio = thumbnailWidth / thumbnailHeight;
if (originalAspectRatio > targetAspectRatio) {
// Image is wider than container - fit to width
thumbnailHeight = thumbnailWidth / originalAspectRatio;
} else {
// Image is taller than container - fit to height
thumbnailWidth = thumbnailHeight * originalAspectRatio;
}

// Apply pixel ratio to get physical pixel dimensions
return Size(thumbnailWidth * pixelRatio, thumbnailHeight * pixelRatio);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
Expand Down Expand Up @@ -54,36 +54,16 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget {

@override
Widget build(BuildContext context) {
final thumbUrl = video.thumbUrl;
if (thumbUrl != null) {
return CachedNetworkImage(
imageUrl: thumbUrl,
final containsThumbnail = video.thumbUrl != null;
// If thumbnail is available, we can directly show it using the
// StreamImageAttachmentThumbnail widget.
if (containsThumbnail) {
return StreamImageAttachmentThumbnail(
image: video,
width: width,
height: height,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
'lib/assets/images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);

final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (context, url, error) {
return errorBuilder(
context,
error,
StackTrace.current,
);
},
errorBuilder: errorBuilder,
);
}

Expand Down
Loading