From 21bc4a5f053803b8b4d03c71cb5a4be3e9b0d97d Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 24 Mar 2026 16:47:40 -0700 Subject: [PATCH] feat: improved image url handling --- .../foundation/primitives/models/media.dart | 24 +------- lib/src/foundation/utils/media_url_utils.dart | 60 +++++++++++++++++++ lib/src/foundation/utils/utils.dart | 1 + .../content/utils/media/media_utils.dart | 53 ++-------------- .../content/widgets/media/media_view.dart | 28 ++++++++- 5 files changed, 92 insertions(+), 74 deletions(-) create mode 100644 lib/src/foundation/utils/media_url_utils.dart diff --git a/lib/src/foundation/primitives/models/media.dart b/lib/src/foundation/primitives/models/media.dart index cc3315f36..3ed0f7927 100644 --- a/lib/src/foundation/primitives/models/media.dart +++ b/lib/src/foundation/primitives/models/media.dart @@ -1,4 +1,5 @@ import 'package:thunder/src/foundation/primitives/enums/media_type.dart'; +import 'package:thunder/src/foundation/utils/media_url_utils.dart'; /// The Media class represents information for a given media source. class Media { @@ -40,7 +41,7 @@ class Media { String? contentType; /// Gets the full-size image URL, if any - String? get imageUrl => _isImageUrl(mediaUrl ?? '') ? mediaUrl : thumbnailUrl; + String? get imageUrl => isSupportedImageUrl(mediaUrl ?? '') ? mediaUrl : thumbnailUrl; @override String toString() { @@ -58,24 +59,3 @@ class Media { '''; } } - -bool _isImageUrl(String url) { - final imageExtensions = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.bmp', - '.webp', - '.avif', - '@jpeg', - ]; - - if (url.contains('/image_proxy')) return true; - - final uri = Uri.tryParse(url); - if (uri == null) return false; - - final path = uri.path.toLowerCase(); - return imageExtensions.any(path.endsWith); -} diff --git a/lib/src/foundation/utils/media_url_utils.dart b/lib/src/foundation/utils/media_url_utils.dart new file mode 100644 index 000000000..0f4ab4e94 --- /dev/null +++ b/lib/src/foundation/utils/media_url_utils.dart @@ -0,0 +1,60 @@ +const List imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '@jpeg']; + +/// Extracts the target URL from known image proxy services. +/// Currently supports: +/// - `image_proxy` with a `url` query parameter +/// - DuckDuckGo's `external-content.duckduckgo.com` with a `u` query parameter +String? extractProxyTargetUrl(Uri uri) { + final urlTarget = uri.queryParameters['url']; + if (uri.path.contains('/image_proxy') && urlTarget?.isNotEmpty == true) { + return urlTarget; + } + + final duckDuckGoTarget = uri.queryParameters['u']; + if (uri.host == 'external-content.duckduckgo.com' && uri.path.startsWith('/iu') && duckDuckGoTarget?.isNotEmpty == true) { + return duckDuckGoTarget; + } + + return null; +} + +/// Resolves the original image URL by recursively extracting proxy targets until no more proxies are found. +String resolveProxyImageUrl(String url) { + String currentUrl = url; + + while (true) { + final uri = Uri.tryParse(currentUrl); + if (uri == null) return currentUrl; + + final proxyTargetUrl = extractProxyTargetUrl(uri); + if (proxyTargetUrl == null) return currentUrl; + + final parsedUri = Uri.tryParse(proxyTargetUrl); + if (parsedUri == null) return currentUrl; + + currentUrl = parsedUri.toString(); + } +} + +/// Checks if the given URL is a supported image URL, either directly or through a proxy. +bool isImageProxyUrlResolved(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + + return extractProxyTargetUrl(uri) != null; +} + +/// Checks if the URL has a supported image file extension. +bool hasImageFileExtension(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + + final path = uri.path.toLowerCase(); + return imageExtensions.any(path.endsWith); +} + +/// Determines if the given URL is a supported image URL, either directly or through a proxy. +bool isSupportedImageUrl(String url) { + if (isImageProxyUrlResolved(url)) return true; + return hasImageFileExtension(resolveProxyImageUrl(url)); +} diff --git a/lib/src/foundation/utils/utils.dart b/lib/src/foundation/utils/utils.dart index 714e99fa1..92f00186b 100644 --- a/lib/src/foundation/utils/utils.dart +++ b/lib/src/foundation/utils/utils.dart @@ -1,4 +1,5 @@ export 'cache/image_cache_utils.dart'; export 'cache/image_dimension_cache.dart'; export 'cache/platform_version_cache.dart'; +export 'media_url_utils.dart'; export 'utils_internal.dart'; diff --git a/lib/src/shared/content/utils/media/media_utils.dart b/lib/src/shared/content/utils/media/media_utils.dart index fc50dea99..a46b71c0e 100644 --- a/lib/src/shared/content/utils/media/media_utils.dart +++ b/lib/src/shared/content/utils/media/media_utils.dart @@ -18,6 +18,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:image_dimension_parser/image_dimension_parser.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/utils/media_url_utils.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/shared/content/widgets/media/experimental_image_viewer.dart'; import 'package:thunder/src/shared/content/widgets/media/image_viewer.dart'; @@ -25,62 +26,16 @@ import 'package:thunder/src/shared/content/widgets/media/image_viewer.dart'; final Map _imageDimensionsCache = {}; /// Given a URL, returns the original URL if it is a proxy URL. -String fetchProxyImageUrl(String url) { - String currentUrl = url; - - while (true) { - Uri uri; - - try { - uri = Uri.parse(currentUrl); - } catch (e) { - return currentUrl; - } - - if (isImageProxyUrl(currentUrl)) { - Uri? parsedUri = Uri.tryParse(uri.queryParameters['url'] ?? ''); - - if (parsedUri != null) { - currentUrl = parsedUri.toString(); - continue; - } - } - - return currentUrl; - } -} +String fetchProxyImageUrl(String url) => resolveProxyImageUrl(url); /// Checks if the given URL is an image proxy URL. bool isImageProxyUrl(String url) { - try { - final uri = Uri.parse(url); - return uri.path.contains('/image_proxy') && uri.queryParameters.containsKey('url'); - } catch (e) { - return false; - } + return isImageProxyUrlResolved(url); } /// Determines if the given URL is an image URL. bool isImageUrl(String url) { - final imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '@jpeg']; - - if (isImageProxyUrl(url)) return true; - - Uri uri; - - try { - uri = Uri.parse(url); - } catch (e) { - return false; - } - - for (final extension in imageExtensions) { - if (uri.path.toLowerCase().endsWith(extension)) { - return true; - } - } - - return false; + return isSupportedImageUrl(url); } /// Determines if the given URL is an SVG. diff --git a/lib/src/shared/content/widgets/media/media_view.dart b/lib/src/shared/content/widgets/media/media_view.dart index 759796e1d..480c6509d 100644 --- a/lib/src/shared/content/widgets/media/media_view.dart +++ b/lib/src/shared/content/widgets/media/media_view.dart @@ -156,10 +156,18 @@ class _MediaViewState extends State with TickerProviderStateMixin { handleVideoLink(context, url: url); } + String? get _resolvedImageUrl => widget.media.imageUrl ?? widget.media.mediaUrl ?? widget.media.originalUrl ?? widget.media.thumbnailUrl; + + String? get _resolvedPreviewUrl => widget.media.thumbnailUrl ?? widget.media.imageUrl ?? widget.media.mediaUrl ?? widget.media.originalUrl; + /// Overlays the image as an ImageViewer. void showImage() { _markPostAsRead(); - _openImage(url: widget.media.imageUrl); + + final url = _resolvedImageUrl; + if (url != null) { + _openImage(url: url); + } } double getMinHeight() { @@ -209,6 +217,7 @@ class _MediaViewState extends State with TickerProviderStateMixin { final imageUrlCandidate = widget.media.imageUrl ?? widget.media.mediaUrl ?? widget.media.originalUrl; final isImage = isImageUrl(imageUrlCandidate ?? ''); + final previewUrl = _resolvedPreviewUrl; // If hiding thumbnails is enabled or if the media has no image URL, // display a link preview instead in comfortable mode. @@ -317,7 +326,7 @@ class _MediaViewState extends State with TickerProviderStateMixin { opacity: _overlayAnimationController, child: buildImageViewerWidget( context, - url: widget.media.thumbnailUrl ?? widget.media.mediaUrl, + url: previewUrl, postId: widget.postId, navigateToPost: widget.navigateToPost, isPeek: true, @@ -340,6 +349,19 @@ class _MediaViewState extends State with TickerProviderStateMixin { ); } + if (widget.media.mediaType == MediaType.image && previewUrl == null) { + if (widget.media.originalUrl != null) { + return LinkInformation( + viewMode: widget.viewMode, + url: widget.media.originalUrl, + mediaType: widget.media.mediaType, + showEdgeToEdgeImages: widget.edgeToEdgeImages, + ); + } + + return const SizedBox.shrink(); + } + if (widget.media.mediaType == MediaType.video) { child = InkWell( splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), @@ -406,7 +428,7 @@ class _MediaViewState extends State with TickerProviderStateMixin { alignment: Alignment.center, children: [ ImagePreview( - url: widget.media.thumbnailUrl ?? widget.media.imageUrl ?? widget.media.originalUrl!, + url: previewUrl!, contentType: widget.media.contentType, width: width, height: height,