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
24 changes: 2 additions & 22 deletions lib/src/foundation/primitives/models/media.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -58,24 +59,3 @@ class Media {
''';
}
}

bool _isImageUrl(String url) {
final imageExtensions = <String>[
'.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);
}
60 changes: 60 additions & 0 deletions lib/src/foundation/utils/media_url_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const List<String> imageExtensions = <String>['.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));
}
1 change: 1 addition & 0 deletions lib/src/foundation/utils/utils.dart
Original file line number Diff line number Diff line change
@@ -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';
53 changes: 4 additions & 49 deletions lib/src/shared/content/utils/media/media_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,69 +18,24 @@ 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';

final Map<String, Size> _imageDimensionsCache = <String, Size>{};

/// 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.
Expand Down
28 changes: 25 additions & 3 deletions lib/src/shared/content/widgets/media/media_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,18 @@ class _MediaViewState extends State<MediaView> 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() {
Expand Down Expand Up @@ -209,6 +217,7 @@ class _MediaViewState extends State<MediaView> 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.
Expand Down Expand Up @@ -317,7 +326,7 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
opacity: _overlayAnimationController,
child: buildImageViewerWidget(
context,
url: widget.media.thumbnailUrl ?? widget.media.mediaUrl,
url: previewUrl,
postId: widget.postId,
navigateToPost: widget.navigateToPost,
isPeek: true,
Expand All @@ -340,6 +349,19 @@ class _MediaViewState extends State<MediaView> 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),
Expand Down Expand Up @@ -406,7 +428,7 @@ class _MediaViewState extends State<MediaView> 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,
Expand Down
Loading