diff --git a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart index 915ed034e..59f20e46f 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:html/parser.dart'; -import 'package:markdown/markdown.dart' hide Text; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; +import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/core/enums/media_type.dart'; @@ -204,7 +202,7 @@ class PostCardViewComfortable extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 6.0, left: 12.0, right: 12.0), child: ScalableText( - parse(markdownToHtml(textContent)).documentElement?.text.trim() ?? textContent, + post.textPreview ?? textContent, maxLines: 4, overflow: TextOverflow.ellipsis, fontScale: contentFontSizeScale, diff --git a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart index 8e748acd7..f7c107e43 100644 --- a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart +++ b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:thunder/src/features/post/post.dart'; import 'package:visibility_detector/visibility_detector.dart'; +import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/features/community/community.dart'; @@ -57,6 +57,9 @@ class _FeedPostCardListState extends State { /// This is used to calculate the read status of posts in the range [0, lastTappedIndex] int lastTappedIndex = -1; + /// The index of the last processed post for read status. + int lastProcessedIndex = -1; + /// Whether the user is scrolling down or not. The logic for determining read posts will /// only be applied when the user is scrolling down bool isScrollingDown = false; @@ -134,7 +137,10 @@ class _FeedPostCardListState extends State { debounceTimer = Timer(const Duration(milliseconds: 500), () { // TODO: Improve logic here so that we don't have to iterate through all posts if possible. - for (int i = index; i >= 0; i--) { + int startIndex = index; + int endIndex = lastProcessedIndex > 0 ? lastProcessedIndex : 0; + + for (int i = startIndex; i >= endIndex; i--) { final post = widget.posts[i]; // If we already checked this post's read status, or we already marked it as read, skip it @@ -145,6 +151,9 @@ class _FeedPostCardListState extends State { readPostIds.add(post.id); } + // Update the last processed index + if (index > lastProcessedIndex) lastProcessedIndex = index; + if (markReadPostIds.isNotEmpty) { context.read().add(FeedItemActionedEvent(postIds: [...markReadPostIds], postAction: PostAction.multiRead, value: true)); readPostIds.addAll(markReadPostIds); // Add all post ids that were queued to prevent them from being queued again diff --git a/lib/src/features/post/data/models/thunder_post.dart b/lib/src/features/post/data/models/thunder_post.dart index 4e132da62..9af2130ce 100644 --- a/lib/src/features/post/data/models/thunder_post.dart +++ b/lib/src/features/post/data/models/thunder_post.dart @@ -66,6 +66,11 @@ class ThunderPost extends Equatable { /// The post's alternate text final String? altText; + /// The post's text preview. + /// + /// This field is not returned by the API, but is computed locally during post parsing. + final String? textPreview; + /// The post's creator final ThunderUser? creator; @@ -163,6 +168,7 @@ class ThunderPost extends Equatable { this.myVote, this.unreadComments, this.media = const [], + this.textPreview, }); @override @@ -206,6 +212,7 @@ class ThunderPost extends Equatable { myVote, unreadComments, media, + textPreview, ]; ThunderPost copyWith({ @@ -248,6 +255,7 @@ class ThunderPost extends Equatable { int? myVote, int? unreadComments, List? media, + String? textPreview, }) { return ThunderPost( id: id ?? this.id, @@ -289,6 +297,7 @@ class ThunderPost extends Equatable { myVote: myVote ?? this.myVote, unreadComments: unreadComments ?? this.unreadComments, media: media ?? this.media, + textPreview: textPreview ?? this.textPreview, ); } diff --git a/lib/src/features/post/presentation/utils/post.dart b/lib/src/features/post/presentation/utils/post.dart index 357aeedf9..ba16e0c43 100644 --- a/lib/src/features/post/presentation/utils/post.dart +++ b/lib/src/features/post/presentation/utils/post.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:html_unescape/html_unescape_small.dart'; +import 'package:html/parser.dart'; +import 'package:markdown/markdown.dart' hide Text; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/local_settings.dart'; @@ -12,6 +14,8 @@ import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/shared/utils/media/image.dart'; import 'package:thunder/src/shared/utils/media/video.dart'; +final _htmlUnescape = HtmlUnescape(); + // Optimistically updates a post. This changes the value of the post locally, without sending the network request ThunderPost optimisticallyVotePost(ThunderPost post, int voteType) { int newScore = post.score!; @@ -114,8 +118,13 @@ Future> parsePosts(List posts, {String? resolutio /// /// This includes unescaping the title and parsing any associated media. Future parsePost(ThunderPost post, bool fetchImageDimensions, bool edgeToEdgeImages, bool tabletMode) async { - final html = HtmlUnescape(); - final title = html.convert(post.name); + final title = _htmlUnescape.convert(post.name); + + // Compute text preview + String? textPreview; + if (post.body != null && post.body!.isNotEmpty) { + textPreview = parse(markdownToHtml(post.body!)).documentElement?.text.trim() ?? post.body; + } List mediaList = []; @@ -191,5 +200,5 @@ Future parsePost(ThunderPost post, bool fetchImageDimensions, bool media.height = scaledSize?.height; mediaList.add(media); - return post.copyWith(media: mediaList, name: title); + return post.copyWith(media: mediaList, name: title, textPreview: textPreview); } diff --git a/lib/src/shared/utils/media/image.dart b/lib/src/shared/utils/media/image.dart index f2931a7fa..b3ed8bb4a 100644 --- a/lib/src/shared/utils/media/image.dart +++ b/lib/src/shared/utils/media/image.dart @@ -4,13 +4,14 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; - import 'package:flutter/services.dart'; + import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart' as http; import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; +import 'package:image_dimension_parser/image_dimension_parser.dart'; import 'package:thunder/src/core/cache/image_dimension_cache.dart'; import 'package:thunder/src/shared/images/image_viewer.dart'; @@ -114,6 +115,25 @@ bool _isAvifImage(String path) { return path.toLowerCase().endsWith('.avif'); } +/// Fetches the image dimensions from the given URL using partial content fetch +Future> processImageDimensions(String imageUrl) async { + try { + final response = await http.get( + Uri.parse(imageUrl), + headers: {'Range': 'bytes=0-10240'}, // 10KB + ); + + if (response.statusCode == 206 || response.statusCode == 200) { + final sizeResult = ImageDimensionParser().parse(response.bodyBytes); + return [sizeResult.width, sizeResult.height]; + } + } catch (e) { + debugPrint('Failed to fetch dimensions in isolate: $e'); + } + + return []; +} + /// Retrieves the size of the given image given its bytes. /// Uses the `image` package which does not support AVIF format. For AVIF images, use [processAvifImage] instead. Future processImage(String filename) async { @@ -159,13 +179,27 @@ Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) Uint8List? data = imageBytes; if (data == null && imageUrl != null) { - final file = await DefaultCacheManager().getSingleFile(imageUrl); + // Try to get size using partial content fetch + try { + final dimensions = await compute(processImageDimensions, imageUrl); + + if (dimensions.isNotEmpty) { + size = Size(dimensions[0].toDouble(), dimensions[1].toDouble()); + debugPrint('Retrieved image dimensions using partial content fetch: ${dimensions[0]}x${dimensions[1]}'); + } + } catch (e) { + // Fallback to full download if partial fetch fails + debugPrint('Failed to retrieve image dimensions using partial content fetch: $e'); + } + + if (size == null) { + final file = await DefaultCacheManager().getSingleFile(imageUrl); - if (_isAvifImage(imageUrl) || _isAvifImage(file.path)) { - size = await processAvifImage(file.path); - } else { - // Other formats can be processed in a background isolate using the `image` package. - size = await compute(processImage, file.path); + if (_isAvifImage(imageUrl)) { + size = await processAvifImage(file.path); + } else { + size = await compute(processImage, file.path); + } } } diff --git a/pubspec.lock b/pubspec.lock index 4ad1bd1a2..c461fb11e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -981,6 +981,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.1" + image_dimension_parser: + dependency: "direct main" + description: + path: "." + ref: d9bfec9dfe6a53b2d032129ce56311ec02796a29 + resolved-ref: d9bfec9dfe6a53b2d032129ce56311ec02796a29 + url: "https://github.com/thunder-app/image-dimension-parser.git" + source: git + version: "1.0.0" image_picker: dependency: "direct main" description: @@ -2053,5 +2062,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.10.4 <4.0.0" flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 23e457bff..3a17ebabf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,10 @@ dependencies: git: url: https://github.com/thunder-app/markdown-editor.git ref: 34db147d5964b2e84f5a7e8edff4a5ce6b649ec8 + image_dimension_parser: + git: + url: https://github.com/thunder-app/image-dimension-parser.git + ref: d9bfec9dfe6a53b2d032129ce56311ec02796a29 l10n_esperanto: ^2.0.14 android_intent_plus: ^6.0.0 app_links: ^7.0.0