diff --git a/lib/models/comment.dart b/lib/models/comment.dart index e366a1f..2b64a81 100644 --- a/lib/models/comment.dart +++ b/lib/models/comment.dart @@ -89,12 +89,8 @@ class CommentView { uri: json['uri'] as String, cid: json['cid'] as String, content: json['content'] as String, - contentFacets: - json['contentFacets'] != null - ? (json['contentFacets'] as List) - .map((f) => PostFacet.fromJson(f as Map)) - .toList() - : null, + // Facets are now in record['facets'] per backend update + contentFacets: parseFacetsFromRecord(json['record']), createdAt: DateTime.parse(json['createdAt'] as String), indexedAt: DateTime.parse(json['indexedAt'] as String), author: AuthorView.fromJson(json['author'] as Map), @@ -117,7 +113,7 @@ class CommentView { final String uri; final String cid; final String content; - final List? contentFacets; + final List? contentFacets; final DateTime createdAt; final DateTime indexedAt; final AuthorView author; diff --git a/lib/models/facet.dart b/lib/models/facet.dart new file mode 100644 index 0000000..826f271 --- /dev/null +++ b/lib/models/facet.dart @@ -0,0 +1,290 @@ +// Rich text facet models for Coves +// +// Facets represent structured metadata about text segments, such as links, +// mentions, or hashtags. They use byte indices (UTF-8) rather than character +// indices (UTF-16) to ensure cross-platform compatibility with the backend. + +/// Byte range for a text segment +/// +/// Uses UTF-8 byte offsets, not UTF-16 character positions. +/// This is crucial for proper alignment with the backend, especially +/// when text contains emoji or other multi-byte characters. +class ByteSlice { + const ByteSlice({ + required this.byteStart, + required this.byteEnd, + }) : assert(byteStart >= 0, 'byteStart must be non-negative'), + assert(byteEnd >= byteStart, 'byteEnd must be >= byteStart'); + + factory ByteSlice.fromJson(Map json) { + final start = json['byteStart']; + final end = json['byteEnd']; + + if (start == null || start is! int) { + throw const FormatException( + 'ByteSlice: Required field "byteStart" is missing or invalid', + ); + } + + if (end == null || end is! int) { + throw const FormatException( + 'ByteSlice: Required field "byteEnd" is missing or invalid', + ); + } + + if (start < 0 || end < 0 || end < start) { + throw FormatException( + 'ByteSlice: Invalid byte range [$start, $end)', + ); + } + + return ByteSlice( + byteStart: start, + byteEnd: end, + ); + } + + /// Start byte position (inclusive) + final int byteStart; + + /// End byte position (exclusive) + final int byteEnd; + + /// Convert to JSON + Map toJson() { + return { + 'byteStart': byteStart, + 'byteEnd': byteEnd, + }; + } + + @override + String toString() => 'ByteSlice($byteStart, $byteEnd)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ByteSlice && + runtimeType == other.runtimeType && + byteStart == other.byteStart && + byteEnd == other.byteEnd; + + @override + int get hashCode => Object.hash(byteStart, byteEnd); +} + +/// Base class for facet features +/// +/// A facet feature describes the semantic meaning of a text segment, +/// such as a link, mention, or hashtag. +sealed class FacetFeature { + const FacetFeature(); + + /// The type identifier for this feature (e.g., "social.coves.richtext.facet#link") + String get type; + + /// Convert to JSON + Map toJson(); + + /// Create a FacetFeature from JSON + factory FacetFeature.fromJson(Map json) { + final type = json[r'$type'] as String?; + + if (type == null || type.isEmpty) { + return UnknownFacetFeature(data: json); + } + + switch (type) { + case 'social.coves.richtext.facet#link': + final uri = json['uri']; + if (uri == null || uri is! String || uri.isEmpty) { + throw const FormatException( + 'LinkFacetFeature: Required field "uri" is missing or invalid', + ); + } + return LinkFacetFeature(uri: uri); + + default: + // Unknown feature type - preserve for forward compatibility + return UnknownFacetFeature(data: json); + } + } +} + +/// Link facet feature +class LinkFacetFeature extends FacetFeature { + const LinkFacetFeature({required this.uri}); + + /// The URI/URL this link points to + final String uri; + + @override + String get type => 'social.coves.richtext.facet#link'; + + @override + Map toJson() { + return { + r'$type': type, + 'uri': uri, + }; + } + + @override + String toString() => 'LinkFacetFeature($uri)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LinkFacetFeature && + runtimeType == other.runtimeType && + uri == other.uri; + + @override + int get hashCode => uri.hashCode; +} + +/// Unknown facet feature for forward compatibility +/// +/// Preserves unknown feature types so they can be round-tripped +/// through the client without data loss. +class UnknownFacetFeature extends FacetFeature { + const UnknownFacetFeature({required this.data}); + + /// Raw JSON data + final Map data; + + @override + String get type => data[r'$type'] as String? ?? 'unknown'; + + @override + Map toJson() => data; + + @override + String toString() => 'UnknownFacetFeature($type)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UnknownFacetFeature && + runtimeType == other.runtimeType && + _mapEquals(data, other.data); + + @override + int get hashCode => Object.hashAll(data.entries); + + static bool _mapEquals(Map a, Map b) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || a[key] != b[key]) return false; + } + return true; + } +} + +/// A rich text facet - metadata about a text segment +class RichTextFacet { + const RichTextFacet({ + required this.index, + required this.features, + }); + + factory RichTextFacet.fromJson(Map json) { + final indexData = json['index']; + if (indexData == null || indexData is! Map) { + throw const FormatException( + 'RichTextFacet: Required field "index" is missing or invalid', + ); + } + + final featuresData = json['features']; + if (featuresData == null || featuresData is! List) { + throw const FormatException( + 'RichTextFacet: Required field "features" is missing or invalid', + ); + } + + return RichTextFacet( + index: ByteSlice.fromJson(indexData), + features: List.unmodifiable( + featuresData + .whereType>() + .map(FacetFeature.fromJson) + .toList(), + ), + ); + } + + /// The byte range this facet applies to + final ByteSlice index; + + /// The semantic features of this text segment + final List features; + + /// Check if this facet contains a link feature + bool get hasLink => + features.any((feature) => feature is LinkFacetFeature); + + /// Get the link URI if this facet has a link feature + String? get linkUri { + for (final feature in features) { + if (feature is LinkFacetFeature) { + return feature.uri; + } + } + return null; + } + + /// Convert to JSON + Map toJson() { + return { + 'index': index.toJson(), + 'features': features.map((f) => f.toJson()).toList(), + }; + } + + @override + String toString() => 'RichTextFacet($index, ${features.length} features)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RichTextFacet && + runtimeType == other.runtimeType && + index == other.index && + _listEquals(features, other.features); + + @override + int get hashCode => Object.hash(index, Object.hashAll(features)); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// Parse facets from a record's 'facets' field +/// +/// Backend returns facets inside `record['facets']` rather than at the top level. +/// This helper safely extracts and parses them, returning null if missing/invalid. +List? parseFacetsFromRecord(Object? record) { + if (record == null || record is! Map) { + return null; + } + final facets = record['facets']; + if (facets == null || facets is! List) { + return null; + } + try { + return List.unmodifiable( + facets + .whereType>() + .map(RichTextFacet.fromJson) + .toList(), + ); + } on Exception { + return null; + } +} diff --git a/lib/models/post.dart b/lib/models/post.dart index 5fd7168..659fd42 100644 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -8,6 +8,9 @@ import 'package:flutter/foundation.dart'; import '../constants/embed_types.dart'; import 'bluesky_post.dart'; +import 'facet.dart'; + +export 'facet.dart' show RichTextFacet, parseFacetsFromRecord; class TimelineResponse { TimelineResponse({required this.feed, this.cursor}); @@ -125,12 +128,8 @@ class PostView { json['embed'] != null ? PostEmbed.fromJson(json['embed'] as Map) : null, - facets: - json['facets'] != null - ? (json['facets'] as List) - .map((f) => PostFacet.fromJson(f as Map)) - .toList() - : null, + // Facets are now in record['facets'] per backend update + facets: parseFacetsFromRecord(json['record']), viewer: json['viewer'] != null ? ViewerState.fromJson(json['viewer'] as Map) @@ -148,7 +147,7 @@ class PostView { final String? title; final PostStats stats; final PostEmbed? embed; - final List? facets; + final List? facets; final ViewerState? viewer; } @@ -406,15 +405,6 @@ class EmbedSource { int get hashCode => Object.hash(uri, title, domain); } -class PostFacet { - PostFacet({required this.data}); - - factory PostFacet.fromJson(Map json) { - return PostFacet(data: json); - } - final Map data; -} - class FeedReason { FeedReason({required this.type, required this.data}); diff --git a/lib/providers/comments_provider.dart b/lib/providers/comments_provider.dart index 586d43e..74c26a2 100644 --- a/lib/providers/comments_provider.dart +++ b/lib/providers/comments_provider.dart @@ -3,6 +3,7 @@ import 'dart:async' show Timer, unawaited; import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; import '../models/comment.dart'; +import '../models/post.dart'; import '../services/api_exceptions.dart'; import '../services/comment_service.dart'; import '../services/coves_api_service.dart'; @@ -428,6 +429,7 @@ class CommentsProvider with ChangeNotifier { /// - ApiException for API errors Future createComment({ required String content, + List? contentFacets, ThreadViewComment? parentComment, }) async { // Validate content @@ -481,6 +483,7 @@ class CommentsProvider with ChangeNotifier { parentUri: parentUri, parentCid: parentCid, content: trimmedContent, + contentFacets: contentFacets, ); if (kDebugMode) { diff --git a/lib/screens/compose/reply_screen.dart b/lib/screens/compose/reply_screen.dart index 3ffc651..89e749d 100644 --- a/lib/screens/compose/reply_screen.dart +++ b/lib/screens/compose/reply_screen.dart @@ -12,6 +12,7 @@ import '../../models/comment.dart'; import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../providers/comments_provider.dart'; +import '../../utils/facet_detector.dart'; import '../../widgets/comment_thread.dart'; import '../../widgets/post_card.dart'; @@ -49,7 +50,7 @@ class ReplyScreen extends StatefulWidget { final ThreadViewComment? comment; /// Callback when user submits reply - final Future Function(String content) onSubmit; + final Future Function(String content, List facets) onSubmit; /// CommentsProvider for draft save/restore and time updates final CommentsProvider commentsProvider; @@ -105,8 +106,12 @@ class _ReplyScreenState extends State with WidgetsBindingObserver { void _setupAuthListener() { try { context.read().addListener(_onAuthChanged); - } on Exception { - // AuthProvider may not be available (e.g., tests) + } on ProviderNotFoundException { + // Expected in tests - AuthProvider may not be available + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('ERROR: Failed to setup auth listener: $e'); + } } } @@ -121,8 +126,12 @@ class _ReplyScreenState extends State with WidgetsBindingObserver { Navigator.of(context).pop(); } } - } on Exception { - // AuthProvider may not be available + } on ProviderNotFoundException { + // Expected in tests - AuthProvider may not be available + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('ERROR: Failed to check auth state: $e'); + } } } @@ -236,7 +245,10 @@ class _ReplyScreenState extends State with WidgetsBindingObserver { }); try { - await widget.onSubmit(content); + // Detect link facets in the content + final facets = FacetDetector.detectLinks(content); + + await widget.onSubmit(content, facets); // Clear draft on success try { if (mounted) { diff --git a/lib/screens/home/create_post_screen.dart b/lib/screens/home/create_post_screen.dart index 9905353..8bebc5d 100644 --- a/lib/screens/home/create_post_screen.dart +++ b/lib/screens/home/create_post_screen.dart @@ -8,6 +8,7 @@ import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; +import '../../utils/facet_detector.dart'; import '../compose/community_picker_screen.dart'; import 'post_detail_screen.dart'; @@ -202,15 +203,20 @@ class _CreatePostScreenState extends State labels = const SelfLabels(values: [SelfLabel(val: 'nsfw')]); } + // Detect link facets in the body content + final bodyContent = _bodyController.text.trim(); + final facets = bodyContent.isNotEmpty + ? FacetDetector.detectLinks(bodyContent) + : null; + // Create post final response = await apiService.createPost( community: _selectedCommunity!.did, title: _titleController.text.trim().isNotEmpty ? _titleController.text.trim() : null, - content: _bodyController.text.trim().isNotEmpty - ? _bodyController.text.trim() - : null, + content: bodyContent.isNotEmpty ? bodyContent : null, + facets: facets, embed: embed, langs: [_language], labels: labels, diff --git a/lib/screens/home/focused_thread_screen.dart b/lib/screens/home/focused_thread_screen.dart index 4ee129a..f493cd1 100644 --- a/lib/screens/home/focused_thread_screen.dart +++ b/lib/screens/home/focused_thread_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; import '../../models/comment.dart'; +import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../providers/comments_provider.dart'; import '../../widgets/comment_card.dart'; @@ -46,7 +47,7 @@ class FocusedThreadScreen extends StatelessWidget { final List ancestors; /// Callback when user replies to a comment - final Future Function(String content, ThreadViewComment parent) onReply; + final Future Function(String content, List facets, ThreadViewComment parent) onReply; /// Parent's CommentsProvider for draft preservation and vote state final CommentsProvider commentsProvider; @@ -77,7 +78,7 @@ class _FocusedThreadBody extends StatefulWidget { final ThreadViewComment thread; final List ancestors; - final Future Function(String content, ThreadViewComment parent) onReply; + final Future Function(String content, List facets, ThreadViewComment parent) onReply; @override State<_FocusedThreadBody> createState() => _FocusedThreadBodyState(); @@ -141,7 +142,7 @@ class _FocusedThreadBodyState extends State<_FocusedThreadBody> { MaterialPageRoute( builder: (navigatorContext) => ReplyScreen( comment: comment, - onSubmit: (content) => widget.onReply(content, comment), + onSubmit: (content, facets) => widget.onReply(content, facets, comment), commentsProvider: context.read(), ), ), diff --git a/lib/screens/home/post_detail_screen.dart b/lib/screens/home/post_detail_screen.dart index 9aa7fce..2d76e7f 100644 --- a/lib/screens/home/post_detail_screen.dart +++ b/lib/screens/home/post_detail_screen.dart @@ -617,11 +617,11 @@ class _PostDetailScreenState extends State { } /// Handle comment submission (reply to post) - Future _handleCommentSubmit(String content) async { + Future _handleCommentSubmit(String content, List facets) async { final messenger = ScaffoldMessenger.of(context); try { - await _commentsProvider.createComment(content: content); + await _commentsProvider.createComment(content: content, contentFacets: facets); if (mounted) { messenger.showSnackBar( @@ -649,6 +649,7 @@ class _PostDetailScreenState extends State { /// Handle reply to a comment (nested reply) Future _handleCommentReply( String content, + List facets, ThreadViewComment parentComment, ) async { final messenger = ScaffoldMessenger.of(context); @@ -656,6 +657,7 @@ class _PostDetailScreenState extends State { try { await _commentsProvider.createComment( content: content, + contentFacets: facets, parentComment: parentComment, ); @@ -702,7 +704,7 @@ class _PostDetailScreenState extends State { builder: (context) => ReplyScreen( comment: comment, - onSubmit: (content) => _handleCommentReply(content, comment), + onSubmit: (content, facets) => _handleCommentReply(content, facets, comment), commentsProvider: _commentsProvider, ), ), diff --git a/lib/services/comment_service.dart b/lib/services/comment_service.dart index d7aa5ab..caa3aaf 100644 --- a/lib/services/comment_service.dart +++ b/lib/services/comment_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../config/environment_config.dart'; import '../models/coves_session.dart'; +import '../models/post.dart'; import 'api_exceptions.dart'; import 'auth_interceptor.dart'; import 'retry_interceptor.dart'; @@ -92,6 +93,7 @@ class CommentService { required String parentUri, required String parentCid, required String content, + List? contentFacets, }) async { try { final session = await _sessionGetter?.call(); @@ -111,6 +113,7 @@ class CommentService { // Send comment request to backend // Note: Authorization header is added by the interceptor + // Note: Use 'facets' field name to match atProto lexicon convention final response = await _dio.post>( '/xrpc/social.coves.community.comment.create', data: { @@ -119,6 +122,8 @@ class CommentService { 'parent': {'uri': parentUri, 'cid': parentCid}, }, 'content': content, + if (contentFacets != null && contentFacets.isNotEmpty) + 'facets': contentFacets.map((f) => f.toJson()).toList(), }, ); diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index 73e8c0b..08b4329 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -459,6 +459,7 @@ class CovesApiService { required String community, String? title, String? content, + List? facets, ExternalEmbedInput? embed, List? langs, SelfLabels? labels, @@ -479,6 +480,10 @@ class CovesApiService { requestBody['content'] = content; } + if (facets != null && facets.isNotEmpty) { + requestBody['facets'] = facets.map((f) => f.toJson()).toList(); + } + if (embed != null) { requestBody['embed'] = embed.toJson(); } diff --git a/lib/utils/facet_detector.dart b/lib/utils/facet_detector.dart new file mode 100644 index 0000000..8880457 --- /dev/null +++ b/lib/utils/facet_detector.dart @@ -0,0 +1,219 @@ +// Link detection and facet generation for rich text +// +// This utility detects URLs in plain text and generates facets with proper +// UTF-8 byte indices for cross-platform compatibility with the backend. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../models/facet.dart'; + +class FacetDetector { + // Private constructor to prevent instantiation + FacetDetector._(); + + /// URL detection regex + /// + /// Matches: + /// - Full URLs with protocol (https://example.com) + /// - Domain-only URLs without protocol (example.com/path) + /// - URLs in various contexts (start of line, after space, after parenthesis) + /// + /// This regex is adapted from the atproto reference implementation. + static final _urlRegex = RegExp( + r'(^|\s|\()((?:https?:\/\/[\S]+)|(?:[a-z][a-z0-9]*(?:\.[a-z0-9]+)+[\S]*))', + caseSensitive: false, + multiLine: true, + ); + + /// Trailing punctuation that should be excluded from URLs + static const _trailingPunctuation = {'.', ',', '!', '?', ')', ';', ':'}; + + /// Detect links in text and generate facets + /// + /// Returns a list of RichTextFacet objects with proper UTF-8 byte indices. + /// Each facet contains a LinkFacetFeature with the normalized URL. + static List detectLinks(String text) { + if (text.isEmpty) { + return []; + } + + final facets = []; + + for (final match in _urlRegex.allMatches(text)) { + // Group 2 contains the actual URL (group 1 is the prefix) + final urlMatch = match.group(2); + if (urlMatch == null || urlMatch.isEmpty) { + continue; + } + + // Calculate the start position of the URL (skip the prefix) + final prefixLength = match.group(1)?.length ?? 0; + final urlStart = match.start + prefixLength; + + // Trim trailing punctuation from the URL + final trimmed = _trimTrailingPunctuation(urlMatch); + if (trimmed.isEmpty) { + continue; + } + + final urlEnd = urlStart + trimmed.length; + + // Normalize the URL (add https:// if missing) + final normalizedUrl = _normalizeUrl(trimmed); + + // Validate the normalized URL + if (!_isValidUrl(normalizedUrl)) { + continue; + } + + // Convert character indices to UTF-8 byte indices + final byteStart = charIndexToByteIndex(text, urlStart); + final byteEnd = charIndexToByteIndex(text, urlEnd); + + // Create the facet + facets.add( + RichTextFacet( + index: ByteSlice( + byteStart: byteStart, + byteEnd: byteEnd, + ), + features: [ + LinkFacetFeature(uri: normalizedUrl), + ], + ), + ); + } + + return facets; + } + + /// Convert a character index (UTF-16) to a byte index (UTF-8) + /// + /// Dart strings use UTF-16 encoding internally, but the backend expects + /// UTF-8 byte indices. This is critical for proper alignment when text + /// contains emoji or other multi-byte characters. + /// + /// Example: + /// - Text: "Hello 👋 world" + /// - Character index of "world": 9 + /// - Byte index of "world": 11 (6 bytes for "Hello " + 4 byte emoji + 1 space) + static int charIndexToByteIndex(String text, int charIndex) { + if (charIndex < 0) { + return 0; + } + + if (charIndex >= text.length) { + return utf8.encode(text).length; + } + + // Extract substring up to the character index + final substring = text.substring(0, charIndex); + + // Encode to UTF-8 and get the byte length + return utf8.encode(substring).length; + } + + /// Convert a byte index (UTF-8) to a character index (UTF-16) + /// + /// This is the inverse of charIndexToByteIndex. It's useful for converting + /// backend byte indices back to Dart string indices for display. + /// + /// Example: + /// - Text: "Hello 👋 world" + /// - Byte index: 10 + /// - Character index: 9 + static int byteIndexToCharIndex(String text, int byteIndex) { + if (byteIndex <= 0) { + return 0; + } + + final bytes = utf8.encode(text); + if (byteIndex >= bytes.length) { + return text.length; + } + + // Decode the substring of bytes up to the byte index + try { + final substring = utf8.decode(bytes.sublist(0, byteIndex)); + return substring.length; + } on FormatException catch (e) { + // Byte index falls in the middle of a multi-byte character + if (kDebugMode) { + debugPrint( + 'FacetDetector: byteIndexToCharIndex failed at byte $byteIndex: $e', + ); + } + return -1; + } + } + + /// Trim trailing punctuation from a URL + /// + /// URLs in natural text often have punctuation at the end that shouldn't + /// be part of the link (e.g., "Check out example.com!" -> "example.com") + static String _trimTrailingPunctuation(String url) { + var trimmed = url; + + while (trimmed.isNotEmpty && + _trailingPunctuation.contains(trimmed[trimmed.length - 1])) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + + return trimmed; + } + + /// Normalize a URL by adding https:// if no protocol is present + /// + /// Examples: + /// - "example.com" -> "https://example.com" + /// - "http://example.com" -> "http://example.com" (unchanged) + /// - "https://example.com" -> "https://example.com" (unchanged) + static String _normalizeUrl(String url) { + final trimmed = url.trim(); + + if (trimmed.isEmpty) { + return trimmed; + } + + // Check if URL already has a protocol + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + + // Add https:// as default protocol + return 'https://$trimmed'; + } + + /// Validate that a string is a valid URL + /// + /// Basic validation to ensure the URL has a valid scheme and host. + static bool _isValidUrl(String url) { + if (url.isEmpty) { + return false; + } + + final uri = Uri.tryParse(url); + if (uri == null) { + return false; + } + + // Must have a scheme (http or https) + if (!uri.hasScheme) { + return false; + } + + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') { + return false; + } + + // Must have a host + if (!uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + return true; + } +} diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index df4995f..fbb3aac 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -12,6 +12,7 @@ import '../providers/auth_provider.dart'; import '../providers/vote_provider.dart'; import '../utils/date_time_utils.dart'; import 'icons/animated_heart_icon.dart'; +import 'rich_text_renderer.dart'; import 'sign_in_dialog.dart'; import 'tappable_author.dart'; @@ -259,10 +260,9 @@ class CommentCard extends StatelessWidget { /// Builds the comment content with support for facets Widget _buildCommentContent(CommentView comment) { - // TODO: Add facet support for links and mentions like PostCard does - // For now, just render plain text - return Text( - comment.content, + return RichTextRenderer( + text: comment.content, + facets: comment.contentFacets, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, @@ -306,10 +306,7 @@ class CommentCard extends StatelessWidget { ); if ((shouldSignIn ?? false) && context.mounted) { - // TODO: Navigate to sign-in screen - if (kDebugMode) { - debugPrint('Navigate to sign-in screen'); - } + await Navigator.of(context).pushNamed('/sign-in'); } return; } @@ -327,7 +324,14 @@ class CommentCard extends StatelessWidget { if (kDebugMode) { debugPrint('Failed to vote on comment: $e'); } - // TODO: Show error snackbar + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to vote. Please try again.'), + duration: Duration(seconds: 2), + ), + ); + } } }, child: Padding( diff --git a/lib/widgets/detailed_post_view.dart b/lib/widgets/detailed_post_view.dart index a657f2b..b9ed0f7 100644 --- a/lib/widgets/detailed_post_view.dart +++ b/lib/widgets/detailed_post_view.dart @@ -11,6 +11,7 @@ import '../utils/url_launcher.dart'; import 'bluesky_post_card.dart'; import 'external_link_bar.dart'; import 'fullscreen_video_player.dart'; +import 'rich_text_renderer.dart'; import 'source_link_bar.dart'; import 'tappable_author.dart'; @@ -493,8 +494,9 @@ class _DetailedPostViewState extends State { Widget _buildBodyText() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - widget.post.post.text, + child: RichTextRenderer( + text: widget.post.post.text, + facets: widget.post.post.facets, style: GoogleFonts.inter( fontSize: 12.5, fontWeight: FontWeight.w400, diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index 059f3cc..c8b3050 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -13,6 +13,7 @@ import 'bluesky_post_card.dart'; import 'external_link_bar.dart'; import 'fullscreen_video_player.dart'; import 'post_card_actions.dart'; +import 'rich_text_renderer.dart'; import 'source_link_bar.dart'; import 'tappable_author.dart'; @@ -274,8 +275,9 @@ class PostCard extends StatelessWidget { // Detail view: no container, better readability return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - post.post.text, + child: RichTextRenderer( + text: post.post.text, + facets: post.post.facets, style: TextStyle( color: AppColors.textPrimary, fontSize: textFontSize, @@ -295,8 +297,9 @@ class PostCard extends StatelessWidget { width: 0.5, ), ), - child: Text( - post.post.text, + child: RichTextRenderer( + text: post.post.text, + facets: post.post.facets, style: TextStyle( color: AppColors.textPrimary.withValues(alpha: 0.85), fontSize: textFontSize, diff --git a/lib/widgets/rich_text_renderer.dart b/lib/widgets/rich_text_renderer.dart new file mode 100644 index 0000000..efec4e0 --- /dev/null +++ b/lib/widgets/rich_text_renderer.dart @@ -0,0 +1,205 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../models/facet.dart'; +import '../utils/facet_detector.dart'; +import '../utils/url_launcher.dart'; + +/// A reusable widget for rendering text with rich text facets (links, mentions, etc.) +/// +/// Facets are annotations in the text that specify formatting or special behavior +/// for specific character ranges. This widget converts atProto-style facets into +/// Flutter TextSpan widgets with appropriate styling and interaction handlers. +/// +/// Supported facet types: +/// - Links (social.coves.richtext.facet#link): Blue, underlined, tappable +/// +/// If no facets are provided, renders plain text. +class RichTextRenderer extends StatefulWidget { + const RichTextRenderer({ + required this.text, + this.facets, + this.style, + this.maxLines, + this.overflow, + this.linkStyle, + super.key, + }); + + /// The text content to render + final String text; + + /// Optional list of facets (annotations) for the text + final List? facets; + + /// Base text style (applied to all text) + final TextStyle? style; + + /// Maximum number of lines to display + final int? maxLines; + + /// How to handle text overflow + final TextOverflow? overflow; + + /// Optional custom style for links (overrides default blue underline) + final TextStyle? linkStyle; + + @override + State createState() => _RichTextRendererState(); +} + +class _RichTextRendererState extends State { + /// Track all gesture recognizers for proper disposal + final List _recognizers = []; + + @override + void dispose() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // If no facets, render plain text + if (widget.facets == null || widget.facets!.isEmpty || widget.text.isEmpty) { + return Text( + widget.text, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } + + // Clear previous recognizers before rebuilding spans + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + + // Build rich text with facets + return Text.rich( + TextSpan( + children: _buildTextSpans(context), + style: widget.style, + ), + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } + + /// Builds a list of TextSpan widgets from the text and facets + /// + /// This method: + /// 1. Sorts facets by start position + /// 2. Iterates through the text, building spans for both plain text and faceted regions + /// 3. Converts UTF-8 byte indices to Dart character indices using FacetDetector utility + /// 4. Handles overlapping or invalid facets gracefully + List _buildTextSpans(BuildContext context) { + final spans = []; + final text = widget.text; + + // Sort facets by start position to process them in order + final sortedFacets = List.from(widget.facets!) + ..sort((a, b) => a.index.byteStart.compareTo(b.index.byteStart)); + + var currentPosition = 0; // Current position in the text (Dart string index) + + for (final facet in sortedFacets) { + // Extract byte indices from facet + final byteStart = facet.index.byteStart; + final byteEnd = facet.index.byteEnd; + + // Skip invalid facets + if (byteStart < 0 || byteEnd <= byteStart) { + if (kDebugMode) { + debugPrint('RichTextRenderer: Skipping facet with invalid byte range ' + '[$byteStart, $byteEnd)'); + } + continue; + } + + // Convert UTF-8 byte indices to Dart character indices + final charStart = FacetDetector.byteIndexToCharIndex(text, byteStart); + final charEnd = FacetDetector.byteIndexToCharIndex(text, byteEnd); + + // Skip if conversion failed or indices are out of bounds + if (charStart < 0 || charEnd < 0 || charStart >= text.length || charEnd > text.length) { + if (kDebugMode) { + debugPrint('RichTextRenderer: Skipping facet with out-of-bounds char indices ' + '[$charStart, $charEnd) for text length ${text.length}'); + } + continue; + } + + // Skip if this facet overlaps with previous content (already processed) + if (charStart < currentPosition) { + if (kDebugMode) { + debugPrint('RichTextRenderer: Skipping overlapping facet at char index ' + '$charStart (current position: $currentPosition)'); + } + continue; + } + + // Add plain text before the facet (if any) + if (charStart > currentPosition) { + spans.add(TextSpan(text: text.substring(currentPosition, charStart))); + } + + // Add the faceted text with appropriate styling/behavior + final facetText = text.substring(charStart, charEnd); + spans.add(_buildFacetSpan(context, facet, facetText)); + + currentPosition = charEnd; + } + + // Add any remaining plain text after the last facet + if (currentPosition < text.length) { + spans.add(TextSpan(text: text.substring(currentPosition))); + } + + return spans; + } + + /// Builds a TextSpan for a faceted region of text + /// + /// Currently handles link facets. Other facet types can be added here. + InlineSpan _buildFacetSpan(BuildContext context, RichTextFacet facet, String facetText) { + if (facet.features.isEmpty) { + // No features, render as plain text + return TextSpan(text: facetText); + } + + // Check for link feature + for (final feature in facet.features) { + // Handle link facets + if (feature is LinkFacetFeature) { + final uri = feature.uri; + + if (uri.isNotEmpty) { + // Create tappable link span with tracked recognizer + final recognizer = TapGestureRecognizer() + ..onTap = () { + UrlLauncher.launchExternalUrl(uri, context: context); + }; + _recognizers.add(recognizer); + + return TextSpan( + text: facetText, + style: widget.linkStyle ?? TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: recognizer, + ); + } + } + + } + + // No recognized features, render as plain text + return TextSpan(text: facetText); + } +} diff --git a/test/models/comment_test.dart b/test/models/comment_test.dart index 4749545..883ddd5 100644 --- a/test/models/comment_test.dart +++ b/test/models/comment_test.dart @@ -174,18 +174,20 @@ void main() { 'uri': 'at://did:plc:test/comment/1', 'cid': 'cid1', 'content': 'Test comment content', - 'contentFacets': [ - { - 'type': 'mention', - 'index': {'start': 0, 'end': 10}, - 'features': [ - { - 'type': 'app.bsky.richtext.facet#mention', - 'did': 'did:plc:mentioned', - }, - ], - }, - ], + // Facets are now in record['facets'] per backend update + 'record': { + 'facets': [ + { + 'index': {'byteStart': 0, 'byteEnd': 10}, + 'features': [ + { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }, + ], + }, + ], + }, 'createdAt': '2025-01-01T12:00:00Z', 'indexedAt': '2025-01-01T12:05:00Z', 'author': { @@ -249,7 +251,7 @@ void main() { 'uri': 'at://did:plc:test/comment/1', 'cid': 'cid1', 'content': 'Test', - 'contentFacets': null, + 'record': null, // No record means no facets 'createdAt': '2025-01-01T12:00:00Z', 'indexedAt': '2025-01-01T12:00:00Z', 'author': {'did': 'did:plc:author', 'handle': 'test.user'}, diff --git a/test/models/facet_test.dart b/test/models/facet_test.dart new file mode 100644 index 0000000..3c8e1f6 --- /dev/null +++ b/test/models/facet_test.dart @@ -0,0 +1,629 @@ +import 'package:coves_flutter/models/facet.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ByteSlice', () { + test('valid construction with positive values', () { + const slice = ByteSlice(byteStart: 0, byteEnd: 10); + + expect(slice.byteStart, 0); + expect(slice.byteEnd, 10); + }); + + test('valid construction with same start and end', () { + const slice = ByteSlice(byteStart: 5, byteEnd: 5); + + expect(slice.byteStart, 5); + expect(slice.byteEnd, 5); + }); + + test('equality and hashCode', () { + const slice1 = ByteSlice(byteStart: 0, byteEnd: 10); + const slice2 = ByteSlice(byteStart: 0, byteEnd: 10); + const slice3 = ByteSlice(byteStart: 0, byteEnd: 20); + + expect(slice1, equals(slice2)); + expect(slice1.hashCode, slice2.hashCode); + expect(slice1, isNot(equals(slice3))); + }); + + test('toJson/fromJson round-trip', () { + const original = ByteSlice(byteStart: 5, byteEnd: 15); + final json = original.toJson(); + final restored = ByteSlice.fromJson(json); + + expect(restored, equals(original)); + expect(json['byteStart'], 5); + expect(json['byteEnd'], 15); + }); + + test('fromJson throws FormatException on missing byteStart', () { + expect( + () => ByteSlice.fromJson({'byteEnd': 10}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('byteStart'), + ), + ), + ); + }); + + test('fromJson throws FormatException on missing byteEnd', () { + expect( + () => ByteSlice.fromJson({'byteStart': 0}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('byteEnd'), + ), + ), + ); + }); + + test('fromJson throws FormatException on invalid byte range (end < start)', + () { + expect( + () => ByteSlice.fromJson({'byteStart': 10, 'byteEnd': 5}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid byte range'), + ), + ), + ); + }); + + test('fromJson throws FormatException on negative byteStart', () { + expect( + () => ByteSlice.fromJson({'byteStart': -1, 'byteEnd': 10}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid byte range'), + ), + ), + ); + }); + + test('fromJson throws FormatException on negative byteEnd', () { + expect( + () => ByteSlice.fromJson({'byteStart': 0, 'byteEnd': -5}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid byte range'), + ), + ), + ); + }); + + test('fromJson throws FormatException on non-int byteStart', () { + expect( + () => ByteSlice.fromJson({'byteStart': 'invalid', 'byteEnd': 10}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('byteStart'), + ), + ), + ); + }); + + test('fromJson throws FormatException on non-int byteEnd', () { + expect( + () => ByteSlice.fromJson({'byteStart': 0, 'byteEnd': 'invalid'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('byteEnd'), + ), + ), + ); + }); + + test('toString format', () { + const slice = ByteSlice(byteStart: 5, byteEnd: 15); + + expect(slice.toString(), 'ByteSlice(5, 15)'); + }); + }); + + group('FacetFeature.fromJson', () { + test('parses LinkFacetFeature correctly', () { + final json = { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect((feature as LinkFacetFeature).uri, 'https://example.com'); + }); + + test('returns UnknownFacetFeature for unknown types', () { + final json = { + r'$type': 'social.coves.richtext.facet#mention', + 'did': 'did:plc:abc123', + }; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect(feature.type, 'social.coves.richtext.facet#mention'); + }); + + test('returns UnknownFacetFeature when \$type is missing', () { + final json = {'uri': 'https://example.com'}; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect(feature.type, 'unknown'); + }); + + test('returns UnknownFacetFeature when \$type is empty', () { + final json = {r'$type': '', 'uri': 'https://example.com'}; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + }); + + test('throws FormatException when LinkFacetFeature has missing uri', () { + final json = {r'$type': 'social.coves.richtext.facet#link'}; + + expect( + () => FacetFeature.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('uri'), + ), + ), + ); + }); + + test('throws FormatException when LinkFacetFeature has empty uri', () { + final json = { + r'$type': 'social.coves.richtext.facet#link', + 'uri': '', + }; + + expect( + () => FacetFeature.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('uri'), + ), + ), + ); + }); + + test('throws FormatException when LinkFacetFeature has non-string uri', () { + final json = { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 123, + }; + + expect( + () => FacetFeature.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('uri'), + ), + ), + ); + }); + }); + + group('LinkFacetFeature', () { + test('construction and properties', () { + const feature = LinkFacetFeature(uri: 'https://example.com'); + + expect(feature.uri, 'https://example.com'); + expect(feature.type, 'social.coves.richtext.facet#link'); + }); + + test('toJson produces correct format with \$type field', () { + const feature = LinkFacetFeature(uri: 'https://example.com/path?q=1'); + final json = feature.toJson(); + + expect(json[r'$type'], 'social.coves.richtext.facet#link'); + expect(json['uri'], 'https://example.com/path?q=1'); + expect(json.length, 2); + }); + + test('equality and hashCode', () { + const feature1 = LinkFacetFeature(uri: 'https://example.com'); + const feature2 = LinkFacetFeature(uri: 'https://example.com'); + const feature3 = LinkFacetFeature(uri: 'https://other.com'); + + expect(feature1, equals(feature2)); + expect(feature1.hashCode, feature2.hashCode); + expect(feature1, isNot(equals(feature3))); + }); + + test('toString format', () { + const feature = LinkFacetFeature(uri: 'https://example.com'); + + expect(feature.toString(), 'LinkFacetFeature(https://example.com)'); + }); + + test('toJson/fromJson round-trip', () { + const original = LinkFacetFeature(uri: 'https://example.com/test'); + final json = original.toJson(); + final restored = FacetFeature.fromJson(json); + + expect(restored, isA()); + expect((restored as LinkFacetFeature).uri, original.uri); + }); + }); + + group('UnknownFacetFeature', () { + test('preserves original JSON data', () { + final data = { + r'$type': 'social.coves.richtext.facet#hashtag', + 'tag': 'flutter', + 'extra': 'preserved', + }; + final feature = UnknownFacetFeature(data: data); + + expect(feature.data, data); + expect(feature.type, 'social.coves.richtext.facet#hashtag'); + }); + + test('round-trips through toJson', () { + final data = { + r'$type': 'social.coves.richtext.facet#mention', + 'did': 'did:plc:abc123', + 'handle': 'user.bsky.social', + }; + final feature = UnknownFacetFeature(data: data); + final json = feature.toJson(); + + expect(json, data); + }); + + test('type property returns \$type from data', () { + final feature = UnknownFacetFeature(data: { + r'$type': 'custom.feature#type', + }); + + expect(feature.type, 'custom.feature#type'); + }); + + test('type property returns "unknown" when \$type is missing', () { + final feature = UnknownFacetFeature(data: {'foo': 'bar'}); + + expect(feature.type, 'unknown'); + }); + + test('equality works with same data', () { + final feature1 = UnknownFacetFeature(data: { + r'$type': 'test', + 'value': 123, + }); + final feature2 = UnknownFacetFeature(data: { + r'$type': 'test', + 'value': 123, + }); + final feature3 = UnknownFacetFeature(data: { + r'$type': 'test', + 'value': 456, + }); + + expect(feature1, equals(feature2)); + expect(feature1, isNot(equals(feature3))); + }); + + test('identical instances have equal hashCode', () { + final feature = UnknownFacetFeature(data: { + r'$type': 'test', + 'value': 123, + }); + + // Identical instances should have same hashCode + expect(identical(feature, feature), isTrue); + // Note: hashCode is called twice but on the same object + }); + + test('toString format', () { + final feature = UnknownFacetFeature(data: { + r'$type': 'social.coves.richtext.facet#mention', + }); + + expect( + feature.toString(), 'UnknownFacetFeature(social.coves.richtext.facet#mention)'); + }); + }); + + group('RichTextFacet', () { + test('valid construction and properties', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const features = [LinkFacetFeature(uri: 'https://example.com')]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.index, index); + expect(facet.features.length, 1); + expect(facet.features[0], isA()); + }); + + test('fromJson parses correctly', () { + final json = { + 'index': {'byteStart': 5, 'byteEnd': 25}, + 'features': [ + { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }, + ], + }; + + final facet = RichTextFacet.fromJson(json); + + expect(facet.index.byteStart, 5); + expect(facet.index.byteEnd, 25); + expect(facet.features.length, 1); + expect(facet.features[0], isA()); + expect((facet.features[0] as LinkFacetFeature).uri, 'https://example.com'); + }); + + test('fromJson parses multiple features', () { + final json = { + 'index': {'byteStart': 0, 'byteEnd': 10}, + 'features': [ + { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }, + { + r'$type': 'social.coves.richtext.facet#mention', + 'did': 'did:plc:abc', + }, + ], + }; + + final facet = RichTextFacet.fromJson(json); + + expect(facet.features.length, 2); + expect(facet.features[0], isA()); + expect(facet.features[1], isA()); + }); + + test('fromJson throws on missing index', () { + final json = { + 'features': [ + { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }, + ], + }; + + expect( + () => RichTextFacet.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('index'), + ), + ), + ); + }); + + test('fromJson throws on invalid index type', () { + final json = { + 'index': 'invalid', + 'features': [], + }; + + expect( + () => RichTextFacet.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('index'), + ), + ), + ); + }); + + test('fromJson throws on missing features', () { + final json = { + 'index': {'byteStart': 0, 'byteEnd': 10}, + }; + + expect( + () => RichTextFacet.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('features'), + ), + ), + ); + }); + + test('fromJson throws on invalid features type', () { + final json = { + 'index': {'byteStart': 0, 'byteEnd': 10}, + 'features': 'invalid', + }; + + expect( + () => RichTextFacet.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('features'), + ), + ), + ); + }); + + test('toJson produces correct format', () { + const index = ByteSlice(byteStart: 10, byteEnd: 30); + const features = [LinkFacetFeature(uri: 'https://test.org')]; + final facet = RichTextFacet(index: index, features: features); + + final json = facet.toJson(); + + expect(json['index'], {'byteStart': 10, 'byteEnd': 30}); + expect(json['features'], [ + {r'$type': 'social.coves.richtext.facet#link', 'uri': 'https://test.org'}, + ]); + }); + + test('toJson/fromJson round-trip', () { + const index = ByteSlice(byteStart: 5, byteEnd: 25); + const features = [LinkFacetFeature(uri: 'https://example.com/path')]; + final original = RichTextFacet(index: index, features: features); + + final json = original.toJson(); + final restored = RichTextFacet.fromJson(json); + + expect(restored.index, original.index); + expect(restored.features.length, original.features.length); + expect(restored.linkUri, original.linkUri); + }); + + test('hasLink returns true when contains LinkFacetFeature', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const features = [LinkFacetFeature(uri: 'https://example.com')]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.hasLink, true); + }); + + test('hasLink returns false when no LinkFacetFeature', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + final features = [ + UnknownFacetFeature(data: {r'$type': 'mention'}), + ]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.hasLink, false); + }); + + test('hasLink returns false with empty features', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const List features = []; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.hasLink, false); + }); + + test('linkUri returns URI when has link', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const features = [LinkFacetFeature(uri: 'https://example.com/page')]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.linkUri, 'https://example.com/page'); + }); + + test('linkUri returns first link URI when multiple links', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const features = [ + LinkFacetFeature(uri: 'https://first.com'), + LinkFacetFeature(uri: 'https://second.com'), + ]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.linkUri, 'https://first.com'); + }); + + test('linkUri returns null when no link', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + final features = [ + UnknownFacetFeature(data: {r'$type': 'mention'}), + ]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.linkUri, null); + }); + + test('linkUri returns null with empty features', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const List features = []; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.linkUri, null); + }); + + test('equality and hashCode', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + const features = [LinkFacetFeature(uri: 'https://example.com')]; + final facet1 = RichTextFacet(index: index, features: features); + final facet2 = RichTextFacet(index: index, features: features); + final facet3 = RichTextFacet( + index: const ByteSlice(byteStart: 0, byteEnd: 20), + features: features, + ); + + expect(facet1, equals(facet2)); + expect(facet1.hashCode, facet2.hashCode); + expect(facet1, isNot(equals(facet3))); + }); + + test('equality with different features', () { + const index = ByteSlice(byteStart: 0, byteEnd: 10); + final facet1 = RichTextFacet( + index: index, + features: const [LinkFacetFeature(uri: 'https://example.com')], + ); + final facet2 = RichTextFacet( + index: index, + features: const [LinkFacetFeature(uri: 'https://other.com')], + ); + + expect(facet1, isNot(equals(facet2))); + }); + + test('toString format', () { + const index = ByteSlice(byteStart: 5, byteEnd: 15); + const features = [LinkFacetFeature(uri: 'https://example.com')]; + final facet = RichTextFacet(index: index, features: features); + + expect(facet.toString(), 'RichTextFacet(ByteSlice(5, 15), 1 features)'); + }); + + test('filters out non-map items in features array', () { + final json = { + 'index': {'byteStart': 0, 'byteEnd': 10}, + 'features': [ + { + r'$type': 'social.coves.richtext.facet#link', + 'uri': 'https://example.com', + }, + 'invalid string', + 123, + null, + ], + }; + + final facet = RichTextFacet.fromJson(json); + + // Only the valid map should be parsed + expect(facet.features.length, 1); + expect(facet.features[0], isA()); + }); + }); +} diff --git a/test/providers/comments_provider_test.mocks.dart b/test/providers/comments_provider_test.mocks.dart index e43e655..d96260d 100644 --- a/test/providers/comments_provider_test.mocks.dart +++ b/test/providers/comments_provider_test.mocks.dart @@ -3,16 +3,20 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; -import 'dart:ui' as _i7; +import 'dart:async' as _i9; +import 'dart:typed_data' as _i12; +import 'dart:ui' as _i10; import 'package:coves_flutter/models/comment.dart' as _i3; +import 'package:coves_flutter/models/community.dart' as _i4; import 'package:coves_flutter/models/post.dart' as _i2; -import 'package:coves_flutter/providers/auth_provider.dart' as _i5; -import 'package:coves_flutter/providers/vote_provider.dart' as _i9; -import 'package:coves_flutter/services/comment_service.dart' as _i4; -import 'package:coves_flutter/services/coves_api_service.dart' as _i8; +import 'package:coves_flutter/models/user_profile.dart' as _i5; +import 'package:coves_flutter/providers/auth_provider.dart' as _i8; +import 'package:coves_flutter/providers/vote_provider.dart' as _i13; +import 'package:coves_flutter/services/comment_service.dart' as _i7; +import 'package:coves_flutter/services/coves_api_service.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i11; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -41,16 +45,51 @@ class _FakeCommentsResponse_1 extends _i1.SmartFake : super(parent, parentInvocation); } -class _FakeCreateCommentResponse_2 extends _i1.SmartFake - implements _i4.CreateCommentResponse { - _FakeCreateCommentResponse_2(Object parent, Invocation parentInvocation) +class _FakeCommunitiesResponse_2 extends _i1.SmartFake + implements _i4.CommunitiesResponse { + _FakeCommunitiesResponse_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeCreatePostResponse_3 extends _i1.SmartFake + implements _i4.CreatePostResponse { + _FakeCreatePostResponse_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeCreateCommunityResponse_4 extends _i1.SmartFake + implements _i4.CreateCommunityResponse { + _FakeCreateCommunityResponse_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeUserProfile_5 extends _i1.SmartFake implements _i5.UserProfile { + _FakeUserProfile_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeActorCommentsResponse_6 extends _i1.SmartFake + implements _i3.ActorCommentsResponse { + _FakeActorCommentsResponse_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeUpdateProfileResponse_7 extends _i1.SmartFake + implements _i6.UpdateProfileResponse { + _FakeUpdateProfileResponse_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeCreateCommentResponse_8 extends _i1.SmartFake + implements _i7.CreateCommentResponse { + _FakeCreateCommentResponse_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [AuthProvider]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider { +class MockAuthProvider extends _i1.Mock implements _i8.AuthProvider { MockAuthProvider() { _i1.throwOnMissingStub(this); } @@ -74,47 +113,47 @@ class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider { as bool); @override - _i6.Future getAccessToken() => + _i9.Future getAccessToken() => (super.noSuchMethod( Invocation.method(#getAccessToken, []), - returnValue: _i6.Future.value(), + returnValue: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future initialize() => + _i9.Future initialize() => (super.noSuchMethod( Invocation.method(#initialize, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future signIn(String? handle) => + _i9.Future signIn(String? handle) => (super.noSuchMethod( Invocation.method(#signIn, [handle]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future signOut() => + _i9.Future signOut() => (super.noSuchMethod( Invocation.method(#signOut, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future refreshToken() => + _i9.Future refreshToken() => (super.noSuchMethod( Invocation.method(#refreshToken, []), - returnValue: _i6.Future.value(false), + returnValue: _i9.Future.value(false), ) - as _i6.Future); + as _i9.Future); @override void clearError() => super.noSuchMethod( @@ -123,13 +162,13 @@ class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider { ); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i10.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); @@ -150,13 +189,13 @@ class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider { /// A class which mocks [CovesApiService]. /// /// See the documentation for Mockito's code generation for more information. -class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { +class MockCovesApiService extends _i1.Mock implements _i6.CovesApiService { MockCovesApiService() { _i1.throwOnMissingStub(this); } @override - _i6.Future<_i2.TimelineResponse> getTimeline({ + _i9.Future<_i2.TimelineResponse> getTimeline({ String? sort = 'hot', String? timeframe, int? limit = 15, @@ -169,7 +208,7 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { #limit: limit, #cursor: cursor, }), - returnValue: _i6.Future<_i2.TimelineResponse>.value( + returnValue: _i9.Future<_i2.TimelineResponse>.value( _FakeTimelineResponse_0( this, Invocation.method(#getTimeline, [], { @@ -181,10 +220,10 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { ), ), ) - as _i6.Future<_i2.TimelineResponse>); + as _i9.Future<_i2.TimelineResponse>); @override - _i6.Future<_i2.TimelineResponse> getDiscover({ + _i9.Future<_i2.TimelineResponse> getDiscover({ String? sort = 'hot', String? timeframe, int? limit = 15, @@ -197,7 +236,7 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { #limit: limit, #cursor: cursor, }), - returnValue: _i6.Future<_i2.TimelineResponse>.value( + returnValue: _i9.Future<_i2.TimelineResponse>.value( _FakeTimelineResponse_0( this, Invocation.method(#getDiscover, [], { @@ -209,10 +248,10 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { ), ), ) - as _i6.Future<_i2.TimelineResponse>); + as _i9.Future<_i2.TimelineResponse>); @override - _i6.Future<_i3.CommentsResponse> getComments({ + _i9.Future<_i3.CommentsResponse> getComments({ required String? postUri, String? sort = 'hot', String? timeframe, @@ -229,7 +268,7 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { #limit: limit, #cursor: cursor, }), - returnValue: _i6.Future<_i3.CommentsResponse>.value( + returnValue: _i9.Future<_i3.CommentsResponse>.value( _FakeCommentsResponse_1( this, Invocation.method(#getComments, [], { @@ -243,7 +282,256 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { ), ), ) - as _i6.Future<_i3.CommentsResponse>); + as _i9.Future<_i3.CommentsResponse>); + + @override + _i9.Future<_i4.CommunitiesResponse> listCommunities({ + int? limit = 50, + String? cursor, + String? sort = 'popular', + bool? subscribed, + }) => + (super.noSuchMethod( + Invocation.method(#listCommunities, [], { + #limit: limit, + #cursor: cursor, + #sort: sort, + #subscribed: subscribed, + }), + returnValue: _i9.Future<_i4.CommunitiesResponse>.value( + _FakeCommunitiesResponse_2( + this, + Invocation.method(#listCommunities, [], { + #limit: limit, + #cursor: cursor, + #sort: sort, + #subscribed: subscribed, + }), + ), + ), + ) + as _i9.Future<_i4.CommunitiesResponse>); + + @override + _i9.Future<_i4.CreatePostResponse> createPost({ + required String? community, + String? title, + String? content, + _i4.ExternalEmbedInput? embed, + List? langs, + _i4.SelfLabels? labels, + }) => + (super.noSuchMethod( + Invocation.method(#createPost, [], { + #community: community, + #title: title, + #content: content, + #embed: embed, + #langs: langs, + #labels: labels, + }), + returnValue: _i9.Future<_i4.CreatePostResponse>.value( + _FakeCreatePostResponse_3( + this, + Invocation.method(#createPost, [], { + #community: community, + #title: title, + #content: content, + #embed: embed, + #langs: langs, + #labels: labels, + }), + ), + ), + ) + as _i9.Future<_i4.CreatePostResponse>); + + @override + _i9.Future<_i4.CreateCommunityResponse> createCommunity({ + required String? name, + required String? displayName, + required String? description, + String? visibility = 'public', + }) => + (super.noSuchMethod( + Invocation.method(#createCommunity, [], { + #name: name, + #displayName: displayName, + #description: description, + #visibility: visibility, + }), + returnValue: _i9.Future<_i4.CreateCommunityResponse>.value( + _FakeCreateCommunityResponse_4( + this, + Invocation.method(#createCommunity, [], { + #name: name, + #displayName: displayName, + #description: description, + #visibility: visibility, + }), + ), + ), + ) + as _i9.Future<_i4.CreateCommunityResponse>); + + @override + _i9.Future<_i5.UserProfile> getProfile({required String? actor}) => + (super.noSuchMethod( + Invocation.method(#getProfile, [], {#actor: actor}), + returnValue: _i9.Future<_i5.UserProfile>.value( + _FakeUserProfile_5( + this, + Invocation.method(#getProfile, [], {#actor: actor}), + ), + ), + ) + as _i9.Future<_i5.UserProfile>); + + @override + _i9.Future<_i2.TimelineResponse> getAuthorPosts({ + required String? actor, + String? filter, + String? community, + int? limit = 15, + String? cursor, + }) => + (super.noSuchMethod( + Invocation.method(#getAuthorPosts, [], { + #actor: actor, + #filter: filter, + #community: community, + #limit: limit, + #cursor: cursor, + }), + returnValue: _i9.Future<_i2.TimelineResponse>.value( + _FakeTimelineResponse_0( + this, + Invocation.method(#getAuthorPosts, [], { + #actor: actor, + #filter: filter, + #community: community, + #limit: limit, + #cursor: cursor, + }), + ), + ), + ) + as _i9.Future<_i2.TimelineResponse>); + + @override + _i9.Future<_i3.ActorCommentsResponse> getActorComments({ + required String? actor, + String? community, + int? limit = 50, + String? cursor, + }) => + (super.noSuchMethod( + Invocation.method(#getActorComments, [], { + #actor: actor, + #community: community, + #limit: limit, + #cursor: cursor, + }), + returnValue: _i9.Future<_i3.ActorCommentsResponse>.value( + _FakeActorCommentsResponse_6( + this, + Invocation.method(#getActorComments, [], { + #actor: actor, + #community: community, + #limit: limit, + #cursor: cursor, + }), + ), + ), + ) + as _i9.Future<_i3.ActorCommentsResponse>); + + @override + _i9.Future subscribeToCommunity({required String? community}) => + (super.noSuchMethod( + Invocation.method(#subscribeToCommunity, [], { + #community: community, + }), + returnValue: _i9.Future.value( + _i11.dummyValue( + this, + Invocation.method(#subscribeToCommunity, [], { + #community: community, + }), + ), + ), + ) + as _i9.Future); + + @override + _i9.Future unsubscribeFromCommunity({required String? community}) => + (super.noSuchMethod( + Invocation.method(#unsubscribeFromCommunity, [], { + #community: community, + }), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future<_i4.CreateCommunityResponse> updateCommunity({ + required String? communityDid, + required _i12.Uint8List? imageBytes, + required String? mimeType, + }) => + (super.noSuchMethod( + Invocation.method(#updateCommunity, [], { + #communityDid: communityDid, + #imageBytes: imageBytes, + #mimeType: mimeType, + }), + returnValue: _i9.Future<_i4.CreateCommunityResponse>.value( + _FakeCreateCommunityResponse_4( + this, + Invocation.method(#updateCommunity, [], { + #communityDid: communityDid, + #imageBytes: imageBytes, + #mimeType: mimeType, + }), + ), + ), + ) + as _i9.Future<_i4.CreateCommunityResponse>); + + @override + _i9.Future<_i6.UpdateProfileResponse> updateProfile({ + String? displayName, + String? bio, + _i12.Uint8List? avatarBytes, + String? avatarMimeType, + _i12.Uint8List? bannerBytes, + String? bannerMimeType, + }) => + (super.noSuchMethod( + Invocation.method(#updateProfile, [], { + #displayName: displayName, + #bio: bio, + #avatarBytes: avatarBytes, + #avatarMimeType: avatarMimeType, + #bannerBytes: bannerBytes, + #bannerMimeType: bannerMimeType, + }), + returnValue: _i9.Future<_i6.UpdateProfileResponse>.value( + _FakeUpdateProfileResponse_7( + this, + Invocation.method(#updateProfile, [], { + #displayName: displayName, + #bio: bio, + #avatarBytes: avatarBytes, + #avatarMimeType: avatarMimeType, + #bannerBytes: bannerBytes, + #bannerMimeType: bannerMimeType, + }), + ), + ), + ) + as _i9.Future<_i6.UpdateProfileResponse>); @override void dispose() => super.noSuchMethod( @@ -255,7 +543,7 @@ class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService { /// A class which mocks [VoteProvider]. /// /// See the documentation for Mockito's code generation for more information. -class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { +class MockVoteProvider extends _i1.Mock implements _i13.VoteProvider { MockVoteProvider() { _i1.throwOnMissingStub(this); } @@ -272,9 +560,9 @@ class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { ); @override - _i9.VoteState? getVoteState(String? postUri) => + _i13.VoteState? getVoteState(String? postUri) => (super.noSuchMethod(Invocation.method(#getVoteState, [postUri])) - as _i9.VoteState?); + as _i13.VoteState?); @override bool isLiked(String? postUri) => @@ -301,7 +589,7 @@ class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { as int); @override - _i6.Future toggleVote({ + _i9.Future toggleVote({ required String? postUri, required String? postCid, String? direction = 'up', @@ -312,9 +600,9 @@ class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { #postCid: postCid, #direction: direction, }), - returnValue: _i6.Future.value(false), + returnValue: _i9.Future.value(false), ) - as _i6.Future); + as _i9.Future); @override void setInitialVoteState({ @@ -337,13 +625,13 @@ class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { ); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i10.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); @@ -358,18 +646,19 @@ class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider { /// A class which mocks [CommentService]. /// /// See the documentation for Mockito's code generation for more information. -class MockCommentService extends _i1.Mock implements _i4.CommentService { +class MockCommentService extends _i1.Mock implements _i7.CommentService { MockCommentService() { _i1.throwOnMissingStub(this); } @override - _i6.Future<_i4.CreateCommentResponse> createComment({ + _i9.Future<_i7.CreateCommentResponse> createComment({ required String? rootUri, required String? rootCid, required String? parentUri, required String? parentCid, required String? content, + List<_i2.RichTextFacet>? contentFacets, }) => (super.noSuchMethod( Invocation.method(#createComment, [], { @@ -378,9 +667,10 @@ class MockCommentService extends _i1.Mock implements _i4.CommentService { #parentUri: parentUri, #parentCid: parentCid, #content: content, + #contentFacets: contentFacets, }), - returnValue: _i6.Future<_i4.CreateCommentResponse>.value( - _FakeCreateCommentResponse_2( + returnValue: _i9.Future<_i7.CreateCommentResponse>.value( + _FakeCreateCommentResponse_8( this, Invocation.method(#createComment, [], { #rootUri: rootUri, @@ -388,9 +678,10 @@ class MockCommentService extends _i1.Mock implements _i4.CommentService { #parentUri: parentUri, #parentCid: parentCid, #content: content, + #contentFacets: contentFacets, }), ), ), ) - as _i6.Future<_i4.CreateCommentResponse>); + as _i9.Future<_i7.CreateCommentResponse>); } diff --git a/test/utils/facet_detector_test.dart b/test/utils/facet_detector_test.dart new file mode 100644 index 0000000..ac48e0d --- /dev/null +++ b/test/utils/facet_detector_test.dart @@ -0,0 +1,112 @@ +import 'package:coves_flutter/utils/facet_detector.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FacetDetector', () { + test('detects simple URL', () { + const text = 'Check out https://example.com for more info'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 1); + expect(facets[0].hasLink, true); + expect(facets[0].linkUri, 'https://example.com'); + }); + + test('detects domain without protocol', () { + const text = 'Visit example.com for details'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 1); + expect(facets[0].hasLink, true); + expect(facets[0].linkUri, 'https://example.com'); + }); + + test('detects multiple URLs', () { + const text = 'Visit https://example.com and https://test.org today'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 2); + expect(facets[0].linkUri, 'https://example.com'); + expect(facets[1].linkUri, 'https://test.org'); + }); + + test('handles URL with emoji correctly (UTF-8 vs UTF-16)', () { + const text = 'Hello 👋 check https://example.com world'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 1); + expect(facets[0].hasLink, true); + expect(facets[0].linkUri, 'https://example.com'); + + // Verify byte indices are correct + // "Hello 👋 check " = 5 + 1 + 4 (emoji) + 1 + 6 = 17 bytes + expect(facets[0].index.byteStart, 17); + // "https://example.com" = 19 bytes + expect(facets[0].index.byteEnd, 36); + }); + + test('trims trailing punctuation', () { + const text = 'Check out https://example.com!'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 1); + expect(facets[0].linkUri, 'https://example.com'); + }); + + test('handles empty text', () { + const text = ''; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 0); + }); + + test('handles text with no URLs', () { + const text = 'This is just plain text'; + final facets = FacetDetector.detectLinks(text); + + expect(facets.length, 0); + }); + }); + + group('FacetDetector.charIndexToByteIndex', () { + test('handles ASCII text', () { + const text = 'Hello world'; + expect(FacetDetector.charIndexToByteIndex(text, 0), 0); + expect(FacetDetector.charIndexToByteIndex(text, 5), 5); + expect(FacetDetector.charIndexToByteIndex(text, 11), 11); + }); + + test('handles emoji (4-byte UTF-8)', () { + const text = 'Hi 👋'; + // 'Hi ' = 3 bytes + expect(FacetDetector.charIndexToByteIndex(text, 3), 3); + // '👋' is 2 UTF-16 chars but 4 UTF-8 bytes + expect(FacetDetector.charIndexToByteIndex(text, 5), 7); + }); + + test('handles multiple emoji', () { + const text = '👋🌍'; + // First emoji: 2 UTF-16 chars, 4 UTF-8 bytes + expect(FacetDetector.charIndexToByteIndex(text, 2), 4); + // Second emoji: 2 UTF-16 chars, 4 UTF-8 bytes + expect(FacetDetector.charIndexToByteIndex(text, 4), 8); + }); + }); + + group('FacetDetector.byteIndexToCharIndex', () { + test('handles ASCII text', () { + const text = 'Hello world'; + expect(FacetDetector.byteIndexToCharIndex(text, 0), 0); + expect(FacetDetector.byteIndexToCharIndex(text, 5), 5); + expect(FacetDetector.byteIndexToCharIndex(text, 11), 11); + }); + + test('handles emoji (4-byte UTF-8)', () { + const text = 'Hi 👋'; + // 'Hi ' = 3 bytes = 3 chars + expect(FacetDetector.byteIndexToCharIndex(text, 3), 3); + // '👋' is 4 UTF-8 bytes but 2 UTF-16 chars + expect(FacetDetector.byteIndexToCharIndex(text, 7), 5); + }); + }); +} diff --git a/test/widgets/focused_thread_screen_test.dart b/test/widgets/focused_thread_screen_test.dart index d0e688e..31ecc45 100644 --- a/test/widgets/focused_thread_screen_test.dart +++ b/test/widgets/focused_thread_screen_test.dart @@ -59,7 +59,7 @@ void main() { Widget createTestWidget({ required ThreadViewComment thread, List ancestors = const [], - Future Function(String, ThreadViewComment)? onReply, + Future Function(String, List, ThreadViewComment)? onReply, }) { return MultiProvider( providers: [ @@ -70,7 +70,7 @@ void main() { home: FocusedThreadScreen( thread: thread, ancestors: ancestors, - onReply: onReply ?? (content, parent) async {}, + onReply: onReply ?? (content, facets, parent) async {}, // Note: Using mock cast - tests are skipped so this won't actually run commentsProvider: mockCommentsProvider as CommentsProvider, ), diff --git a/test/widgets/rich_text_renderer_test.dart b/test/widgets/rich_text_renderer_test.dart new file mode 100644 index 0000000..c7e1537 --- /dev/null +++ b/test/widgets/rich_text_renderer_test.dart @@ -0,0 +1,1043 @@ +import 'dart:convert'; + +import 'package:coves_flutter/models/facet.dart'; +import 'package:coves_flutter/widgets/rich_text_renderer.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../test_helpers/mock_url_launcher_platform.dart'; + +/// Helper to get UTF-8 byte length of a string +int _byteLen(String s) => utf8.encode(s).length; + +/// Helper to create a link facet for testing using substring positions +/// This calculates byte indices automatically from the text +RichTextFacet _createLinkFacetFromText({ + required String fullText, + required String linkText, + required String uri, + int occurrence = 0, +}) { + // Find the character indices + int charStart = fullText.indexOf(linkText); + for (int i = 0; i < occurrence && charStart != -1; i++) { + charStart = fullText.indexOf(linkText, charStart + 1); + } + if (charStart == -1) { + throw ArgumentError('linkText "$linkText" not found in fullText'); + } + final charEnd = charStart + linkText.length; + + // Convert to byte indices + final byteStart = _byteLen(fullText.substring(0, charStart)); + final byteEnd = _byteLen(fullText.substring(0, charEnd)); + + return RichTextFacet( + index: ByteSlice(byteStart: byteStart, byteEnd: byteEnd), + features: [LinkFacetFeature(uri: uri)], + ); +} + +/// Helper to create a link facet with raw byte indices +RichTextFacet _createLinkFacet({ + required int byteStart, + required int byteEnd, + required String uri, +}) { + return RichTextFacet( + index: ByteSlice(byteStart: byteStart, byteEnd: byteEnd), + features: [LinkFacetFeature(uri: uri)], + ); +} + +/// Helper to wrap widget in MaterialApp for testing +Widget _wrapInMaterialApp(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} + +/// Helper to get the inner content spans from a RichText widget +/// The structure is: RichText > TextSpan (with style) > children spans +/// Due to Flutter's text rendering, there may be an extra nesting level +List _getContentSpans(RichText richText) { + final textSpan = richText.text as TextSpan; + // If textSpan has children and the first child is also a TextSpan with children, + // we're looking at a nested structure + if (textSpan.children != null && textSpan.children!.isNotEmpty) { + final firstChild = textSpan.children![0]; + if (firstChild is TextSpan && + firstChild.children != null && + firstChild.children!.isNotEmpty) { + return firstChild.children!; + } + return textSpan.children!; + } + return []; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockUrlLauncherPlatform mockPlatform; + + setUp(() { + mockPlatform = MockUrlLauncherPlatform(); + UrlLauncherPlatform.instance = mockPlatform; + }); + + group('RichTextRenderer - Basic Rendering', () { + testWidgets('renders plain text when no facets provided', (tester) async { + await tester.pumpWidget( + _wrapInMaterialApp(const RichTextRenderer(text: 'Hello, world!')), + ); + + expect(find.text('Hello, world!'), findsOneWidget); + }); + + testWidgets('renders plain text when facets list is empty', (tester) async { + await tester.pumpWidget( + _wrapInMaterialApp( + const RichTextRenderer(text: 'Hello, world!', facets: []), + ), + ); + + expect(find.text('Hello, world!'), findsOneWidget); + }); + + testWidgets('renders plain text when text is empty (even with facets)', ( + tester, + ) async { + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: '', + facets: [ + _createLinkFacet( + byteStart: 0, + byteEnd: 5, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + // Empty text should render empty widget + expect(find.byType(Text), findsOneWidget); + final textWidget = tester.widget(find.byType(Text)); + expect(textWidget.data, ''); + }); + }); + + group('RichTextRenderer - Link Facet Rendering', () { + testWidgets( + 'renders link with correct styling (underlined, primary color)', + (tester) async { + const text = 'Check out https://example.com please'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + // Find the RichText widget + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Verify structure: should have 3 children (before, link, after) + expect(spans.length, 3); + + // Verify the link span has proper styling + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, linkText); + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.style?.color, isNotNull); + }, + ); + + testWidgets('multiple links render correctly with text between them', ( + tester, + ) async { + const text = 'Visit google.com and apple.com today'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: 'google.com', + uri: 'https://google.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'apple.com', + uri: 'https://apple.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Should have 5 spans: "Visit ", "google.com", " and ", "apple.com", " today" + expect(spans.length, 5); + + // Verify first link + final firstLink = spans[1] as TextSpan; + expect(firstLink.text, 'google.com'); + expect(firstLink.style?.decoration, TextDecoration.underline); + + // Verify second link + final secondLink = spans[3] as TextSpan; + expect(secondLink.text, 'apple.com'); + expect(secondLink.style?.decoration, TextDecoration.underline); + }); + + testWidgets('link at start of text', (tester) async { + const text = 'https://example.com is cool'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Should have 2 spans: link and " is cool" + expect(spans.length, 2); + + final linkSpan = spans[0] as TextSpan; + expect(linkSpan.text, linkText); + expect(linkSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('link at end of text', (tester) async { + const text = 'Visit https://example.com'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Should have 2 spans: "Visit " and link + expect(spans.length, 2); + + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, linkText); + expect(linkSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('custom linkStyle overrides default styling', (tester) async { + const text = 'Check out https://example.com please'; + const linkText = 'https://example.com'; + const customLinkStyle = TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + decoration: TextDecoration.none, + ); + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + linkStyle: customLinkStyle, + ), + ), + ); + + final richTextFinder = find.byType(RichText); + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.style?.color, Colors.red); + expect(linkSpan.style?.fontWeight, FontWeight.bold); + expect(linkSpan.style?.decoration, TextDecoration.none); + }); + }); + + group('RichTextRenderer - Facet Boundary Cases', () { + testWidgets( + 'handles out-of-bounds facet indices gracefully (does not crash)', + (tester) async { + // Create facet with indices beyond text length + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: 'Short text', + facets: [ + _createLinkFacet( + byteStart: 100, + byteEnd: 200, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + // Should render without crashing + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // The invalid facet should be skipped, rendering just plain text + // When facets are skipped, remaining text should still be added + expect(spans, isNotEmpty); + }, + ); + + testWidgets('handles overlapping facets (processes first, skips overlap)', ( + tester, + ) async { + // Two facets that overlap + const text = 'Check https://example.com out'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: 'https://example.com', + uri: 'https://example.com', + ), + // Create overlapping facet manually + _createLinkFacet( + byteStart: _byteLen('Check htt'), + byteEnd: _byteLen('Check https://exam'), + uri: 'https://other.com', + ), + ], + ), + ), + ); + + // Should render without crashing + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // First facet should be processed, second should be skipped + // Result: "Check ", link, " out" + expect(spans.length, 3); + + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, 'https://example.com'); + }); + + testWidgets('handles facets with invalid byte range (skipped gracefully)', ( + tester, + ) async { + // Test that the widget doesn't crash with edge cases + // Note: ByteSlice has assertions, so we test the renderer's handling + // of facets at rendering boundaries + await tester.pumpWidget( + _wrapInMaterialApp( + const RichTextRenderer( + text: 'Hello world', + facets: [], // Empty facets to avoid assertion error + ), + ), + ); + + // Should render plain text + expect(find.text('Hello world'), findsOneWidget); + }); + }); + + group('RichTextRenderer - UTF-8/Emoji Handling', () { + testWidgets('text with emoji before link renders correctly', ( + tester, + ) async { + const text = 'Hello 👋 https://example.com world'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + expect(spans.length, 3); + + // Verify the text before link contains emoji + final beforeSpan = spans[0] as TextSpan; + expect(beforeSpan.text, contains('👋')); + + // Verify the link + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, linkText); + expect(linkSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('text with emoji after link renders correctly', (tester) async { + const text = 'Visit https://example.com 🎉'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + expect(spans.length, 3); + + // Verify the link + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, linkText); + + // Verify the text after link contains emoji + final afterSpan = spans[2] as TextSpan; + expect(afterSpan.text, contains('🎉')); + }); + + testWidgets('link text containing emoji displays properly', (tester) async { + // While URLs typically don't contain emoji, the display text might + // if the facet covers text that includes emoji + const text = 'Click here 👉 now'; + const linkText = 'here 👉'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + expect(spans.length, 3); + + // Find the link span that should contain emoji + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, contains('👉')); + expect(linkSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('multiple emojis with multiple links', (tester) async { + const text = '🎉 Visit google.com 🚀 and apple.com 🍎'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: 'google.com', + uri: 'https://google.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'apple.com', + uri: 'https://apple.com', + ), + ], + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + // Verify it renders without crashing + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Should have 5 spans with proper content + expect(spans.length, 5); + + // Check first emoji is in first span + final firstSpan = spans[0] as TextSpan; + expect(firstSpan.text, contains('🎉')); + + // Check google.com link + final googleLink = spans[1] as TextSpan; + expect(googleLink.text, 'google.com'); + + // Check middle section has emoji + final middleSpan = spans[2] as TextSpan; + expect(middleSpan.text, contains('🚀')); + + // Check apple.com link + final appleLink = spans[3] as TextSpan; + expect(appleLink.text, 'apple.com'); + + // Check last emoji + final lastSpan = spans[4] as TextSpan; + expect(lastSpan.text, contains('🍎')); + }); + }); + + group('RichTextRenderer - Interaction', () { + testWidgets('tapping a link triggers URL launch', (tester) async { + const text = 'Visit https://example.com today'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + // Find the RichText widget + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final spans = _getContentSpans(richText); + + // Find the link span with a recognizer + final linkSpan = spans[1] as TextSpan; + expect(linkSpan.recognizer, isNotNull); + expect(linkSpan.recognizer, isA()); + + // Simulate tap on the recognizer + final recognizer = linkSpan.recognizer as TapGestureRecognizer; + recognizer.onTap?.call(); + + // Allow async operations to complete + await tester.pumpAndSettle(); + + // Verify URL was launched + expect(mockPlatform.launchedUrls, contains('https://example.com')); + }); + + testWidgets('multiple links have separate recognizers', (tester) async { + const text = 'Visit google.com and apple.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: 'google.com', + uri: 'https://google.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'apple.com', + uri: 'https://apple.com', + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // First link + final firstLink = spans[1] as TextSpan; + expect(firstLink.recognizer, isNotNull); + (firstLink.recognizer as TapGestureRecognizer).onTap?.call(); + + await tester.pumpAndSettle(); + expect(mockPlatform.launchedUrls, contains('https://google.com')); + + // Second link + final secondLink = spans[3] as TextSpan; + expect(secondLink.recognizer, isNotNull); + (secondLink.recognizer as TapGestureRecognizer).onTap?.call(); + + await tester.pumpAndSettle(); + expect(mockPlatform.launchedUrls, contains('https://apple.com')); + }); + + testWidgets('recognizers are properly disposed on widget disposal', ( + tester, + ) async { + const text = 'Visit https://example.com today'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + // Verify widget rendered + expect(find.byType(RichText), findsOneWidget); + + // Remove the widget (trigger dispose) + await tester.pumpWidget(_wrapInMaterialApp(const SizedBox())); + + // No crash means recognizers were properly disposed + expect(find.byType(RichText), findsNothing); + }); + }); + + group('RichTextRenderer - Widget Properties', () { + testWidgets('maxLines is applied', (tester) async { + const text = + 'A very long text that should be limited to one line when maxLines is set'; + const linkText = 'A ver'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + maxLines: 1, + ), + ), + ); + + // Find Text.rich widget and check maxLines + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + expect(richText.maxLines, 1); + }); + + testWidgets('maxLines is applied to plain text (no facets)', ( + tester, + ) async { + await tester.pumpWidget( + _wrapInMaterialApp( + const RichTextRenderer( + text: 'A very long text that should be limited', + maxLines: 2, + ), + ), + ); + + final textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final text = tester.widget(textFinder); + expect(text.maxLines, 2); + }); + + testWidgets('overflow is applied', (tester) async { + const text = 'Some text with a link https://example.com'; + const linkText = 'https://example.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + overflow: TextOverflow.ellipsis, + ), + ), + ); + + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + expect(richText.overflow, TextOverflow.ellipsis); + }); + + testWidgets('overflow is applied to plain text (no facets)', ( + tester, + ) async { + await tester.pumpWidget( + _wrapInMaterialApp( + const RichTextRenderer( + text: 'Plain text without links', + overflow: TextOverflow.fade, + ), + ), + ); + + final textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final text = tester.widget(textFinder); + expect(text.overflow, TextOverflow.fade); + }); + + testWidgets('style is applied to all text', (tester) async { + const text = 'Check out https://example.com please'; + const linkText = 'https://example.com'; + const baseStyle = TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey, + ); + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: linkText, + uri: 'https://example.com', + ), + ], + style: baseStyle, + ), + ), + ); + + final richTextFinder = find.byType(RichText); + final richText = tester.widget(richTextFinder); + final rootSpan = richText.text as TextSpan; + + // The style is applied to the inner TextSpan (child of root) + // Root span has default Material text style, inner span has our style + expect(rootSpan.children, isNotNull); + final innerSpan = rootSpan.children![0] as TextSpan; + + // Verify the custom style is applied + expect(innerSpan.style?.fontSize, 18.0); + expect(innerSpan.style?.fontWeight, FontWeight.w500); + }); + + testWidgets('style is applied to plain text (no facets)', (tester) async { + const baseStyle = TextStyle(fontSize: 24, color: Colors.blue); + + await tester.pumpWidget( + _wrapInMaterialApp( + const RichTextRenderer(text: 'Just plain text', style: baseStyle), + ), + ); + + final textFinder = find.byType(Text); + final text = tester.widget(textFinder); + expect(text.style?.fontSize, 24); + expect(text.style?.color, Colors.blue); + }); + }); + + group('RichTextRenderer - Edge Cases', () { + testWidgets('handles facet with empty features list', (tester) async { + const text = 'Some text here'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + RichTextFacet( + index: ByteSlice( + byteStart: _byteLen('Some '), + byteEnd: _byteLen('Some text'), + ), + features: const [], // Empty features + ), + ], + ), + ), + ); + + // Should render without crashing + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // The facet with empty features should be rendered as plain text + expect(spans, isNotEmpty); + }); + + testWidgets('handles unknown facet feature type', (tester) async { + const text = 'Some text here'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + RichTextFacet( + index: ByteSlice( + byteStart: _byteLen('Some '), + byteEnd: _byteLen('Some text'), + ), + features: [ + UnknownFacetFeature(data: {r'$type': 'unknown.type'}), + ], + ), + ], + ), + ), + ); + + // Should render without crashing (unknown features treated as plain text) + expect(find.byType(RichText), findsOneWidget); + }); + + testWidgets('handles link with empty URI', (tester) async { + const text = 'Some text here'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + RichTextFacet( + index: ByteSlice( + byteStart: _byteLen('Some '), + byteEnd: _byteLen('Some text'), + ), + features: const [LinkFacetFeature(uri: '')], + ), + ], + ), + ), + ); + + // Should render without crashing + expect(find.byType(RichText), findsOneWidget); + + // Empty URI link should be rendered as plain text (no recognizer) + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // There should be spans since we have valid byte indices + expect(spans, isNotEmpty); + + // If there are 3 children (before, facet, after), check the facet has no recognizer + if (spans.length >= 2) { + final facetSpan = spans[1] as TextSpan; + expect(facetSpan.recognizer, isNull); + } + }); + + testWidgets('widget rebuilds properly when facets change', (tester) async { + const text1 = 'Visit https://first.com please'; + const link1 = 'https://first.com'; + const text2 = 'Visit https://second.com please'; + const link2 = 'https://second.com'; + + // Initial render with one link + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text1, + facets: [ + _createLinkFacetFromText( + fullText: text1, + linkText: link1, + uri: 'https://first.com', + ), + ], + ), + ), + ); + + var richText = tester.widget(find.byType(RichText)); + var spans = _getContentSpans(richText); + expect(spans.length, greaterThanOrEqualTo(2)); + var linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, link1); + + // Update with different link + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text2, + facets: [ + _createLinkFacetFromText( + fullText: text2, + linkText: link2, + uri: 'https://second.com', + ), + ], + ), + ), + ); + + richText = tester.widget(find.byType(RichText)); + spans = _getContentSpans(richText); + expect(spans.length, greaterThanOrEqualTo(2)); + linkSpan = spans[1] as TextSpan; + expect(linkSpan.text, link2); + }); + + testWidgets('handles very long text with many facets', (tester) async { + const text = + 'Link1: a.com Link2: b.com Link3: c.com Link4: d.com Link5: e.com'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacetFromText( + fullText: text, + linkText: 'a.com', + uri: 'https://a.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'b.com', + uri: 'https://b.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'c.com', + uri: 'https://c.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'd.com', + uri: 'https://d.com', + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'e.com', + uri: 'https://e.com', + ), + ], + ), + ), + ); + + // Should render without crashing + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // Should have children + expect(spans, isNotEmpty); + // 5 links + up to 6 text segments, but implementation may vary + expect(spans.length, greaterThanOrEqualTo(5)); + }); + }); +}