diff --git a/lib/models/comment.dart b/lib/models/comment.dart index 5948461..dcc9c3e 100644 --- a/lib/models/comment.dart +++ b/lib/models/comment.dart @@ -3,6 +3,8 @@ // These models match the backend response structure from: // /xrpc/social.coves.community.comment.getComments +import 'package:flutter/foundation.dart'; + import 'post.dart'; class CommentsResponse { @@ -17,14 +19,20 @@ class CommentsResponse { // Backend returned null, use empty list commentsList = []; } else { - // Parse comment items - commentsList = - (commentsData as List) - .map( - (item) => - ThreadViewComment.fromJson(item as Map), - ) - .toList(); + // Parse comment items, skipping any that fail to parse so one + // malformed comment never kills the whole thread load + commentsList = []; + for (final item in commentsData as List) { + try { + commentsList.add( + ThreadViewComment.fromJson(item as Map), + ); + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('⚠️ Skipping malformed comment: $e'); + } + } + } } return CommentsResponse( diff --git a/lib/models/facet.dart b/lib/models/facet.dart index 18c5fa4..7a0edc9 100644 --- a/lib/models/facet.dart +++ b/lib/models/facet.dart @@ -1,7 +1,7 @@ // 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 +// mentions, or headings. They use byte indices (UTF-8) rather than character // indices (UTF-16) to ensure cross-platform compatibility with the backend. import 'package:flutter/foundation.dart'; @@ -78,50 +78,107 @@ class ByteSlice { /// Base class for facet features /// /// A facet feature describes the semantic meaning of a text segment, -/// such as a link, mention, or hashtag. +/// such as a link, mention, or a block-level structure (quote, heading, code). 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 + /// + /// The feature union is open: unknown `$type`s become [UnknownFacetFeature] + /// and render as plain text. Known types with missing/invalid attributes + /// also degrade to [UnknownFacetFeature] rather than throwing, so one bad + /// feature never strips rich text from the rest of the record. factory FacetFeature.fromJson(Map json) { - final type = json[r'$type'] as String?; + // Not a cast: a non-string $type (e.g. 42) must degrade gracefully, + // not throw a TypeError that escapes `on Exception` guards upstream. + final type = json[r'$type']; - if (type == null || type.isEmpty) { + if (type is! String || type.isEmpty) { return UnknownFacetFeature(data: json); } switch (type) { - case 'social.coves.richtext.facet#link': + case LinkFacetFeature.typeId: final uri = json['uri']; if (uri == null || uri is! String || uri.isEmpty) { - throw const FormatException( - 'LinkFacetFeature: Required field "uri" is missing or invalid', - ); + return UnknownFacetFeature(data: json); } return LinkFacetFeature(uri: uri); + case MentionFacetFeature.typeId: + final did = json['did']; + if (did == null || did is! String || did.isEmpty) { + return UnknownFacetFeature(data: json); + } + return MentionFacetFeature(did: did); + + case BoldFacetFeature.typeId: + return const BoldFacetFeature(); + + case ItalicFacetFeature.typeId: + return const ItalicFacetFeature(); + + case StrikethroughFacetFeature.typeId: + return const StrikethroughFacetFeature(); + + case SpoilerFacetFeature.typeId: + final reason = json['reason']; + return SpoilerFacetFeature(reason: reason is String ? reason : null); + + case BlockquoteFacetFeature.typeId: + // Absent level means 1. The lexicon tells writers to clamp nesting + // deeper than 6 to level 6; we mirror that leniency on read + // (clamping <1 up to 1 as well) rather than dropping the facet. + final level = json['level']; + if (level == null) { + return const BlockquoteFacetFeature(); + } + if (level is! int) { + return UnknownFacetFeature(data: json); + } + return BlockquoteFacetFeature(level: level.clamp(1, 6)); + + case HeadingFacetFeature.typeId: + // Level is required for headings; degrade to plain text without it. + final level = json['level']; + if (level is! int) { + return UnknownFacetFeature(data: json); + } + return HeadingFacetFeature(level: level.clamp(1, 6)); + + case CodeFacetFeature.typeId: + return const CodeFacetFeature(); + + case CodeBlockFacetFeature.typeId: + final language = json['language']; + return CodeBlockFacetFeature( + language: language is String && language.isNotEmpty ? language : null, + ); + default: // Unknown feature type - preserve for forward compatibility return UnknownFacetFeature(data: json); } } + + /// The type identifier for this feature (e.g., "social.coves.richtext.facet#link") + String get type; + + /// Convert to JSON + Map toJson(); } /// Link facet feature class LinkFacetFeature extends FacetFeature { const LinkFacetFeature({required this.uri}); + static const typeId = 'social.coves.richtext.facet#link'; + /// The URI/URL this link points to final String uri; @override - String get type => 'social.coves.richtext.facet#link'; + String get type => typeId; @override Map toJson() { @@ -145,6 +202,248 @@ class LinkFacetFeature extends FacetFeature { int get hashCode => uri.hashCode; } +/// Mention of a user or community +/// +/// The annotated text is usually a handle with '@' (user) or '!' (community) +/// prefix, but the reference is a DID. +class MentionFacetFeature extends FacetFeature { + const MentionFacetFeature({required this.did}); + + static const typeId = 'social.coves.richtext.facet#mention'; + + /// DID of the mentioned user or community + final String did; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type, 'did': did}; + + @override + String toString() => 'MentionFacetFeature($did)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MentionFacetFeature && + runtimeType == other.runtimeType && + did == other.did; + + @override + int get hashCode => did.hashCode; +} + +/// Bold text formatting +class BoldFacetFeature extends FacetFeature { + const BoldFacetFeature(); + + static const typeId = 'social.coves.richtext.facet#bold'; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type}; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BoldFacetFeature; + + @override + int get hashCode => typeId.hashCode; +} + +/// Italic text formatting +class ItalicFacetFeature extends FacetFeature { + const ItalicFacetFeature(); + + static const typeId = 'social.coves.richtext.facet#italic'; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type}; + + @override + bool operator ==(Object other) => + identical(this, other) || other is ItalicFacetFeature; + + @override + int get hashCode => typeId.hashCode; +} + +/// Strikethrough text formatting +class StrikethroughFacetFeature extends FacetFeature { + const StrikethroughFacetFeature(); + + static const typeId = 'social.coves.richtext.facet#strikethrough'; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type}; + + @override + bool operator ==(Object other) => + identical(this, other) || other is StrikethroughFacetFeature; + + @override + int get hashCode => typeId.hashCode; +} + +/// Hidden/spoiler text that requires user interaction to reveal +class SpoilerFacetFeature extends FacetFeature { + const SpoilerFacetFeature({this.reason}); + + static const typeId = 'social.coves.richtext.facet#spoiler'; + + /// Optional explanation of what's hidden + final String? reason; + + @override + String get type => typeId; + + @override + Map toJson() => { + r'$type': type, + if (reason != null) 'reason': reason, + }; + + @override + String toString() => 'SpoilerFacetFeature($reason)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpoilerFacetFeature && + runtimeType == other.runtimeType && + reason == other.reason; + + @override + int get hashCode => Object.hash(typeId, reason); +} + +/// Block-level quotation +/// +/// The range spans whole lines (excluding the trailing newline). Nested +/// quotes are disjoint ranges with increasing [level], never containment. +class BlockquoteFacetFeature extends FacetFeature { + const BlockquoteFacetFeature({this.level = 1}) + : assert(level >= 1 && level <= 6, 'level must be 1-6'); + + static const typeId = 'social.coves.richtext.facet#blockquote'; + + /// Quote nesting depth (1-6). Absent on the wire means 1. + final int level; + + @override + String get type => typeId; + + @override + Map toJson() => { + r'$type': type, + if (level != 1) 'level': level, + }; + + @override + String toString() => 'BlockquoteFacetFeature(level: $level)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BlockquoteFacetFeature && + runtimeType == other.runtimeType && + level == other.level; + + @override + int get hashCode => Object.hash(typeId, level); +} + +/// Section heading spanning a single whole line +class HeadingFacetFeature extends FacetFeature { + const HeadingFacetFeature({required this.level}) + : assert(level >= 1 && level <= 6, 'level must be 1-6'); + + static const typeId = 'social.coves.richtext.facet#heading'; + + /// Heading level, 1 (largest) through 6 + final int level; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type, 'level': level}; + + @override + String toString() => 'HeadingFacetFeature(level: $level)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HeadingFacetFeature && + runtimeType == other.runtimeType && + level == other.level; + + @override + int get hashCode => Object.hash(typeId, level); +} + +/// Inline code span rendered in monospace +class CodeFacetFeature extends FacetFeature { + const CodeFacetFeature(); + + static const typeId = 'social.coves.richtext.facet#code'; + + @override + String get type => typeId; + + @override + Map toJson() => {r'$type': type}; + + @override + bool operator ==(Object other) => + identical(this, other) || other is CodeFacetFeature; + + @override + int get hashCode => typeId.hashCode; +} + +/// Block of preformatted code rendered in monospace, whitespace preserved +class CodeBlockFacetFeature extends FacetFeature { + const CodeBlockFacetFeature({this.language}); + + static const typeId = 'social.coves.richtext.facet#codeBlock'; + + /// Optional language hint for syntax highlighting (e.g. 'go', 'python') + final String? language; + + @override + String get type => typeId; + + @override + Map toJson() => { + r'$type': type, + if (language != null) 'language': language, + }; + + @override + String toString() => 'CodeBlockFacetFeature($language)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CodeBlockFacetFeature && + runtimeType == other.runtimeType && + language == other.language; + + @override + int get hashCode => Object.hash(typeId, language); +} + /// Unknown facet feature for forward compatibility /// /// Preserves unknown feature types so they can be round-tripped @@ -156,7 +455,10 @@ class UnknownFacetFeature extends FacetFeature { final Map data; @override - String get type => data[r'$type'] as String? ?? 'unknown'; + String get type { + final rawType = data[r'$type']; + return rawType is String ? rawType : 'unknown'; + } @override Map toJson() => data; @@ -226,6 +528,22 @@ class RichTextFacet { bool get hasLink => features.any((feature) => feature is LinkFacetFeature); + /// The first block-level feature (blockquote, heading, codeBlock), if any + /// + /// Block features change layout rather than character style, so renderers + /// treat a facet carrying one as a block and apply any remaining features + /// inline within it (code blocks excepted: their text renders literally). + FacetFeature? get blockFeature { + for (final feature in features) { + if (feature is BlockquoteFacetFeature || + feature is HeadingFacetFeature || + feature is CodeBlockFacetFeature) { + return feature; + } + } + return null; + } + /// Get the link URI if this facet has a link feature String? get linkUri { for (final feature in features) { @@ -267,14 +585,19 @@ class RichTextFacet { } } +/// Client-side caps mirroring the backend sanitizer's MaxFacets and +/// MaxFeaturesPerFacet. Old pre-sanitizer federated records can exceed the +/// backend caps, so we enforce them here too to keep rendering bounded. +const int _maxFacets = 200; +const int _maxFeaturesPerFacet = 20; + /// 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. /// -/// Note: Parsing failures are logged in debug mode but return null to prevent -/// a single malformed facet from breaking the entire content. Users will see -/// plain text instead of rich links when facet parsing fails. +/// Note: Malformed facets are dropped individually (logged in debug mode) so +/// a single bad entry never strips rich text from the rest of the content. List? parseFacetsFromRecord(Object? record) { if (record == null || record is! Map) { return null; @@ -283,17 +606,36 @@ List? parseFacetsFromRecord(Object? record) { if (facets == null || facets is! List) { return null; } - try { - return List.unmodifiable( - facets - .whereType>() - .map(RichTextFacet.fromJson) - .toList(), - ); - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('⚠️ Facet parsing failed: $e'); + final parsed = []; + for (final entry in facets.whereType>()) { + if (parsed.length >= _maxFacets) { + if (kDebugMode) { + debugPrint( + '⚠️ Truncating facets to first $_maxFacets (backend MaxFacets cap)', + ); + } + break; } + final features = entry['features']; + if (features is List && features.length > _maxFeaturesPerFacet) { + if (kDebugMode) { + debugPrint( + '⚠️ Dropping facet with ${features.length} features ' + '(backend MaxFeaturesPerFacet cap: $_maxFeaturesPerFacet)', + ); + } + continue; + } + try { + parsed.add(RichTextFacet.fromJson(entry)); + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('⚠️ Skipping malformed facet: $e'); + } + } + } + if (parsed.isEmpty) { return null; } + return List.unmodifiable(parsed); } diff --git a/lib/providers/multi_feed_provider.dart b/lib/providers/multi_feed_provider.dart index 14ddda8..0f18c98 100644 --- a/lib/providers/multi_feed_provider.dart +++ b/lib/providers/multi_feed_provider.dart @@ -343,9 +343,21 @@ class MultiFeedProvider with ChangeNotifier { if (kDebugMode) { debugPrint('❌ Failed to fetch $feedName: $e'); } + } finally { + // Defense-in-depth: a non-Exception error (e.g. a TypeError from + // malformed JSON) bypasses the `on Exception` catch above and would + // otherwise leave isLoading/isLoadingMore stuck true forever. + // Guarantee the flags are cleared so the feed can retry. + final latestState = _feedStates[type]; + if (latestState != null && + (latestState.isLoading || latestState.isLoadingMore)) { + _feedStates[type] = latestState.copyWith( + isLoading: false, + isLoadingMore: false, + ); + } + notifyListeners(); } - - notifyListeners(); } /// Fetch timeline feed (authenticated) diff --git a/lib/widgets/rich_text_renderer.dart b/lib/widgets/rich_text_renderer.dart index efec4e0..dca28c6 100644 --- a/lib/widgets/rich_text_renderer.dart +++ b/lib/widgets/rich_text_renderer.dart @@ -1,21 +1,36 @@ +import 'dart:collection'; +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../constants/app_colors.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.) +/// A reusable widget for rendering text with rich text facets. +/// +/// Facets are advisory annotations over canonical plaintext: the text must +/// remain readable if every facet is ignored, and unknown feature types +/// degrade to plain text (the union is open). /// -/// 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. +/// Inline features (styled via merged text spans, overlaps compose): +/// - Links: primary color, underlined, tappable +/// - Mentions: primary color, tappable (profile or community by DID) +/// - Bold / italic / strikethrough +/// - Inline code: monospace with subtle background +/// - Spoilers: redacted until tapped /// -/// Supported facet types: -/// - Links (social.coves.richtext.facet#link): Blue, underlined, tappable +/// Block features (rendered as block layout when [maxLines] is null): +/// - Blockquotes: left bar per nesting level +/// - Headings: scaled/bold single line +/// - Code blocks: monospace card, whitespace preserved, horizontal scroll /// -/// If no facets are provided, renders plain text. +/// When [maxLines] is set (feed previews), everything renders in a single +/// Text.rich so ellipsis works; block features are approximated as character +/// styles instead of layout. class RichTextRenderer extends StatefulWidget { const RichTextRenderer({ required this.text, @@ -37,22 +52,75 @@ class RichTextRenderer extends StatefulWidget { final TextStyle? style; /// Maximum number of lines to display + /// + /// When set, block facets are approximated inline so the line limit and + /// [overflow] behave like a normal Text widget. final int? maxLines; /// How to handle text overflow final TextOverflow? overflow; - /// Optional custom style for links (overrides default blue underline) + /// Optional custom style for links + /// + /// When null, links get the default treatment: theme primary color plus + /// underline. When provided, this style REPLACES that whole default + /// treatment (no underline is added automatically). final TextStyle? linkStyle; @override State createState() => _RichTextRendererState(); } +/// A facet with its byte range resolved to Dart string (UTF-16) indices +class _ResolvedFacet { + _ResolvedFacet({ + required this.facet, + required this.charStart, + required this.charEnd, + }); + + final RichTextFacet facet; + final int charStart; + final int charEnd; +} + +/// A block-level facet with its range extended to whole-line boundaries +class _ResolvedBlock { + _ResolvedBlock({ + required this.feature, + required this.start, + required this.end, + }); + + final FacetFeature feature; + final int start; + final int end; +} + class _RichTextRendererState extends State { /// Track all gesture recognizers for proper disposal final List _recognizers = []; + /// Spoiler ranges ("charStart:charEnd") the user has revealed + final Set _revealedSpoilers = {}; + + /// Resolved facets/blocks cached across rebuilds (provider-driven feed + /// rebuilds must not redo the byte-to-char resolution work) + List<_ResolvedFacet>? _resolvedFacetsCache; + List<_ResolvedBlock>? _resolvedBlocksCache; + + @override + void didUpdateWidget(RichTextRenderer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text || + !listEquals(oldWidget.facets, widget.facets)) { + _resolvedFacetsCache = null; + _resolvedBlocksCache = null; + // Char-range keys are only valid relative to this text/facets pair + _revealedSpoilers.clear(); + } + } + @override void dispose() { for (final recognizer in _recognizers) { @@ -63,8 +131,18 @@ class _RichTextRendererState extends State { @override Widget build(BuildContext context) { + // Clear previous recognizers before rebuilding spans (must run before + // the plain-text early return, or recognizers from a prior faceted + // build would leak when the widget updates to a facet-less state) + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + // If no facets, render plain text - if (widget.facets == null || widget.facets!.isEmpty || widget.text.isEmpty) { + if (widget.facets == null || + widget.facets!.isEmpty || + widget.text.isEmpty) { return Text( widget.text, style: widget.style, @@ -73,133 +151,787 @@ class _RichTextRendererState extends State { ); } - // Clear previous recognizers before rebuilding spans - for (final recognizer in _recognizers) { - recognizer.dispose(); + final resolved = _resolvedFacetsCache ??= _resolveFacets(); + + // Compact mode: a single Text.rich so maxLines/ellipsis work + if (widget.maxLines != null) { + return Text.rich( + TextSpan( + children: _inlineSpans( + context, + 0, + widget.text.length, + resolved, + approximateBlocks: true, + ), + style: widget.style, + ), + maxLines: widget.maxLines, + overflow: widget.overflow, + ); } - _recognizers.clear(); - // Build rich text with facets - return Text.rich( - TextSpan( - children: _buildTextSpans(context), - style: widget.style, - ), - maxLines: widget.maxLines, - overflow: widget.overflow, + final blocks = _resolvedBlocksCache ??= _resolveBlocks(resolved); + + // No block structure: keep the single-Text.rich shape + if (blocks.isEmpty) { + return Text.rich( + TextSpan( + children: _inlineSpans(context, 0, widget.text.length, resolved), + style: widget.style, + ), + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } + + final children = _blockWidgets( + context, + 0, + widget.text.length, + blocks, + resolved, + widget.style, + ); + + if (children.length == 1) { + return children.first; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + children[i], + ], + ], ); } - /// Builds a list of TextSpan widgets from the text and facets + /// Converts facet byte ranges to char ranges, dropping invalid 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 = []; + /// The text is UTF-8 encoded once and every needed byte offset is resolved + /// in a single cumulative walk over the bytes (instead of re-encoding the + /// whole text per facet endpoint, which was O(facets x textLength)). + List<_ResolvedFacet> _resolveFacets() { final text = widget.text; + final bytes = utf8.encode(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)); + // Collect the byte offsets we need (sorted, deduplicated), skipping + // facets that are trivially invalid. + final offsets = SplayTreeSet(); + for (final facet in widget.facets!) { + final byteStart = facet.index.byteStart; + final byteEnd = facet.index.byteEnd; + if (byteStart < 0 || byteEnd <= byteStart || byteEnd > bytes.length) { + continue; // logged in the resolution loop below + } + offsets + ..add(byteStart) + ..add(byteEnd); + } - var currentPosition = 0; // Current position in the text (Dart string index) + final charIndexAt = + _charIndexForByteOffsets(bytes, offsets.toList(), text.length); - for (final facet in sortedFacets) { - // Extract byte indices from facet + final resolved = <_ResolvedFacet>[]; + for (final facet in widget.facets!) { 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)'); + 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); + // Strict: a facet extending past the text's UTF-8 length is malformed + // and is dropped, not clamped to the text end. + if (byteEnd > bytes.length) { + if (kDebugMode) { + debugPrint('RichTextRenderer: Skipping facet with byte range ' + '[$byteStart, $byteEnd) past text byte length ${bytes.length}'); + } + continue; + } + + // -1 means the offset splits a multi-byte UTF-8 sequence + final charStart = charIndexAt[byteStart] ?? -1; + final charEnd = charIndexAt[byteEnd] ?? -1; - // Skip if conversion failed or indices are out of bounds - if (charStart < 0 || charEnd < 0 || charStart >= text.length || charEnd > text.length) { + if (charStart < 0 || + charEnd < 0 || + charStart >= text.length || + charEnd <= charStart) { if (kDebugMode) { - debugPrint('RichTextRenderer: Skipping facet with out-of-bounds char indices ' - '[$charStart, $charEnd) for text length ${text.length}'); + 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) { + resolved.add( + _ResolvedFacet(facet: facet, charStart: charStart, charEnd: charEnd), + ); + } + + resolved.sort((a, b) => a.charStart.compareTo(b.charStart)); + return resolved; + } + + /// Resolves each byte offset in [offsets] (sorted ascending, all within + /// `0..bytes.length`) to its UTF-16 char index, in one walk over [bytes]. + /// + /// Offsets landing mid-way through a multi-byte UTF-8 sequence map to -1 + /// (matching FacetDetector.byteIndexToCharIndex's failure result); an + /// offset equal to `bytes.length` maps to [textLength]. + static Map _charIndexForByteOffsets( + List bytes, + List offsets, + int textLength, + ) { + final map = {}; + var oi = 0; + var byteIndex = 0; + var charIndex = 0; + + while (oi < offsets.length && byteIndex < bytes.length) { + while (oi < offsets.length && offsets[oi] == byteIndex) { + map[offsets[oi]] = charIndex; + oi++; + } + if (oi >= offsets.length) { + break; + } + + // Advance one code point using the UTF-8 lead byte + final lead = bytes[byteIndex]; + final seqLen = lead < 0x80 + ? 1 + : lead < 0xE0 + ? 2 + : lead < 0xF0 + ? 3 + : 4; + final next = byteIndex + seqLen; + + // Offsets inside the sequence would split a code point: unresolvable + while (oi < offsets.length && offsets[oi] < next) { + map[offsets[oi]] = -1; + oi++; + } + + // Code points >= U+10000 (4-byte sequences) are surrogate pairs in + // UTF-16 and count as 2 code units + charIndex += seqLen == 4 ? 2 : 1; + byteIndex = next; + } + + for (; oi < offsets.length; oi++) { + map[offsets[oi]] = offsets[oi] == bytes.length ? textLength : -1; + } + return map; + } + + /// Extracts block-level facets with ranges extended to line boundaries + /// + /// Per the lexicon, block ranges must span whole lines excluding the + /// trailing newline; readers extend malformed mid-line ranges outward. + List<_ResolvedBlock> _resolveBlocks(List<_ResolvedFacet> resolved) { + final text = widget.text; + final blocks = <_ResolvedBlock>[]; + + for (final rf in resolved) { + final feature = rf.facet.blockFeature; + if (feature == null) { + continue; + } + + // A sloppy writer may start the range on a newline; advance past + // leading newlines before snapping to line start so we don't swallow + // the previous line (symmetric with the trailing back-off below). + var rangeStart = rf.charStart; + while (rangeStart < rf.charEnd && text[rangeStart] == '\n') { + rangeStart++; + } + + final start = + rangeStart == 0 ? 0 : text.lastIndexOf('\n', rangeStart - 1) + 1; + + // A sloppy writer may include the trailing newline; back off before + // extending forward so we don't swallow the next line. + var end = rf.charEnd; + while (end > start && text[end - 1] == '\n') { + end--; + } + if (end < text.length && text[end] != '\n') { + final nl = text.indexOf('\n', end); + end = nl == -1 ? text.length : nl; + } + + if (end > start) { + blocks.add(_ResolvedBlock(feature: feature, start: start, end: end)); + } + } + + blocks.sort((a, b) { + final cmp = a.start.compareTo(b.start); + // Longer (containing) block first so containment recursion sees it + return cmp != 0 ? cmp : b.end.compareTo(a.end); + }); + return blocks; + } + + /// Lays out a range as a list of block widgets (paragraphs and blocks) + List _blockWidgets( + BuildContext context, + int rangeStart, + int rangeEnd, + List<_ResolvedBlock> blocks, + List<_ResolvedFacet> inlineFacets, + TextStyle? baseStyle, + ) { + final text = widget.text; + final widgets = []; + var pos = rangeStart; + var i = 0; + + while (i < blocks.length) { + final block = blocks[i]; + if (block.start < pos || block.end > rangeEnd) { + // Overlaps already-rendered content (or leaks out of this range) if (kDebugMode) { - debugPrint('RichTextRenderer: Skipping overlapping facet at char index ' - '$charStart (current position: $currentPosition)'); + debugPrint('RichTextRenderer: Skipping overlapping block facet at ' + 'char index ${block.start}'); } + i++; continue; } - // Add plain text before the facet (if any) - if (charStart > currentPosition) { - spans.add(TextSpan(text: text.substring(currentPosition, charStart))); + if (block.start > pos) { + _addParagraph( + context, widgets, pos, block.start, inlineFacets, baseStyle); + } + + final feature = block.feature; + if (feature is BlockquoteFacetFeature) { + // Consume blocks contained in this quote (cross-type nesting); + // quote-in-quote containment is disallowed by the lexicon and will + // simply render inside the outer quote's bars. + final inner = <_ResolvedBlock>[]; + var j = i + 1; + while (j < blocks.length && blocks[j].start < block.end) { + if (blocks[j].end <= block.end) { + inner.add(blocks[j]); + } else if (kDebugMode) { + // Straddles the quote boundary: dropped, same as other overlaps + debugPrint('RichTextRenderer: Skipping overlapping block facet ' + 'at char index ${blocks[j].start}'); + } + j++; + } + widgets.add( + _blockquoteWidget(context, block, inner, inlineFacets, baseStyle), + ); + i = j; + } else if (feature is HeadingFacetFeature) { + widgets.add( + _headingWidget(context, block, feature, inlineFacets, baseStyle), + ); + i++; + } else { + widgets.add( + _codeBlockWidget(context, block, feature as CodeBlockFacetFeature, + inlineFacets, baseStyle), + ); + i++; } - // Add the faceted text with appropriate styling/behavior - final facetText = text.substring(charStart, charEnd); - spans.add(_buildFacetSpan(context, facet, facetText)); + pos = block.end; + // Skip the newline separating this block from what follows + if (pos < rangeEnd && text[pos] == '\n') { + pos++; + } + } - currentPosition = charEnd; + if (pos < rangeEnd) { + _addParagraph(context, widgets, pos, rangeEnd, inlineFacets, baseStyle); } - // Add any remaining plain text after the last facet - if (currentPosition < text.length) { - spans.add(TextSpan(text: text.substring(currentPosition))); + return widgets; + } + + /// Adds a paragraph widget for a gap between blocks (if non-empty) + void _addParagraph( + BuildContext context, + List widgets, + int start, + int end, + List<_ResolvedFacet> inlineFacets, + TextStyle? baseStyle, + ) { + final text = widget.text; + var s = start; + var e = end; + while (s < e && text[s] == '\n') { + s++; } + while (e > s && text[e - 1] == '\n') { + e--; + } + if (s >= e) { + return; + } + + widgets.add( + Text.rich( + TextSpan( + children: _inlineSpans(context, s, e, inlineFacets), + style: baseStyle, + ), + ), + ); + } + + Widget _blockquoteWidget( + BuildContext context, + _ResolvedBlock block, + List<_ResolvedBlock> inner, + List<_ResolvedFacet> inlineFacets, + TextStyle? baseStyle, + ) { + final level = (block.feature as BlockquoteFacetFeature).level; + final quoteStyle = (baseStyle ?? const TextStyle()) + .merge(const TextStyle(color: AppColors.textSecondary)); + + final children = _blockWidgets( + context, + block.start, + block.end, + inner, + inlineFacets, + quoteStyle, + ); + + var child = children.length == 1 + ? children.first + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + children[i], + ], + ], + ); + + // One bar per nesting level, innermost closest to the text + for (var i = 0; i < level; i++) { + child = Container( + padding: const EdgeInsets.only(left: 10, top: 2, bottom: 2), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: AppColors.teal.withValues(alpha: 0.6), + width: 3, + ), + ), + ), + child: child, + ); + } + + return child; + } + + static const _headingScales = [1.55, 1.4, 1.25, 1.15, 1.05, 1.0]; + + Widget _headingWidget( + BuildContext context, + _ResolvedBlock block, + HeadingFacetFeature feature, + List<_ResolvedFacet> inlineFacets, + TextStyle? baseStyle, + ) { + final base = baseStyle ?? const TextStyle(); + final baseSize = base.fontSize ?? 14.0; + final headingStyle = base.copyWith( + fontSize: baseSize * _headingScales[feature.level - 1], + fontWeight: feature.level <= 2 ? FontWeight.w700 : FontWeight.w600, + height: 1.3, + ); + + return Text.rich( + TextSpan( + children: _inlineSpans(context, block.start, block.end, inlineFacets), + style: headingStyle, + ), + ); + } + + Widget _codeBlockWidget( + BuildContext context, + _ResolvedBlock block, + CodeBlockFacetFeature feature, + List<_ResolvedFacet> inlineFacets, + TextStyle? baseStyle, + ) { + final codeStyle = _monospace(baseStyle ?? const TextStyle()); + final language = feature.language; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.backgroundTertiary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (language != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + language, + style: _monospace(const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + )), + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text.rich( + // Code is literal (no inline facet styling), except spoilers: + // concealment beats literalness, so spoiler ranges are + // redacted here too instead of leaking in plain sight + TextSpan(children: _codeBlockSpans(block, inlineFacets)), + style: codeStyle, + softWrap: false, + ), + ), + ], + ), + ); + } + + /// Splits a code block's raw text into spans, redacting spoiler ranges + /// + /// Spoilers get the same treatment as inline runs (invisible glyphs, + /// solid background, screen-reader label, tap to reveal/hide), keyed by + /// the spoiler's full char range so reveal state is shared with any + /// inline rendering of the same facet. + List _codeBlockSpans( + _ResolvedBlock block, + List<_ResolvedFacet> inlineFacets, + ) { + final text = widget.text; + final spoilers = inlineFacets + .where((rf) => + rf.charStart < block.end && + rf.charEnd > block.start && + rf.facet.features.any((f) => f is SpoilerFacetFeature)) + .toList(); + + if (spoilers.isEmpty) { + return [TextSpan(text: text.substring(block.start, block.end))]; + } + + final spans = []; + var pos = block.start; + + for (final rf in spoilers) { + final segStart = rf.charStart.clamp(pos, block.end); + final segEnd = rf.charEnd.clamp(pos, block.end); + if (segEnd <= segStart) { + continue; // fully consumed by an earlier overlapping spoiler + } + if (segStart > pos) { + spans.add(TextSpan(text: text.substring(pos, segStart))); + } + + final key = '${rf.charStart}:${rf.charEnd}'; + final revealed = _revealedSpoilers.contains(key); + final tapRecognizer = TapGestureRecognizer() + ..onTap = () => setState(() { + if (!_revealedSpoilers.remove(key)) { + _revealedSpoilers.add(key); + } + }); + _recognizers.add(tapRecognizer); + + if (revealed) { + spans.add(TextSpan( + text: text.substring(segStart, segEnd), + style: TextStyle( + backgroundColor: + AppColors.backgroundTertiary.withValues(alpha: 0.5), + ), + recognizer: tapRecognizer, + )); + } else { + final reason = rf.facet.features + .whereType() + .first + .reason; + spans.add(TextSpan( + text: text.substring(segStart, segEnd), + style: const TextStyle( + color: Colors.transparent, + backgroundColor: AppColors.backgroundTertiary, + ), + recognizer: tapRecognizer, + semanticsLabel: reason != null + ? 'Spoiler: $reason. Tap to reveal.' + : 'Spoiler. Tap to reveal.', + )); + } + + pos = segEnd; + } + + if (pos < block.end) { + spans.add(TextSpan(text: text.substring(pos, block.end))); + } return spans; } - /// Builds a TextSpan for a faceted region of text + static TextStyle _monospace(TextStyle base) { + return base.copyWith( + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Courier New', 'Courier'], + ); + } + + /// Builds inline spans for [start, end), merging styles of overlapping + /// facets by splitting the range into runs at facet boundaries. /// - /// 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( + /// When [approximateBlocks] is true (compact previews), block features are + /// rendered as character styles instead of layout. + List _inlineSpans( + BuildContext context, + int start, + int end, + List<_ResolvedFacet> facets, { + bool approximateBlocks = false, + }) { + final text = widget.text; + + // Facets that intersect this range and carry renderable features + final active = facets.where((rf) { + if (rf.charEnd <= start || rf.charStart >= end) { + return false; + } + if (approximateBlocks) { + return rf.facet.features.isNotEmpty; + } + return rf.facet.features.any(_isInlineFeature); + }).toList(); + + if (active.isEmpty) { + return [TextSpan(text: text.substring(start, end))]; + } + + final boundaries = SplayTreeSet() + ..add(start) + ..add(end); + for (final rf in active) { + boundaries + ..add(rf.charStart.clamp(start, end)) + ..add(rf.charEnd.clamp(start, end)); + } + + final points = boundaries.toList(); + final spans = []; + + for (var i = 0; i < points.length - 1; i++) { + final runStart = points[i]; + final runEnd = points[i + 1]; + final covering = active + .where((rf) => rf.charStart <= runStart && rf.charEnd >= runEnd) + .toList(); + spans.add( + _runSpan(context, runStart, runEnd, covering, approximateBlocks), + ); + } + + return spans; + } + + static bool _isInlineFeature(FacetFeature feature) { + return feature is LinkFacetFeature || + feature is MentionFacetFeature || + feature is BoldFacetFeature || + feature is ItalicFacetFeature || + feature is StrikethroughFacetFeature || + feature is CodeFacetFeature || + feature is SpoilerFacetFeature; + } + + /// Builds the span for one run, merging all covering facets' features + InlineSpan _runSpan( + BuildContext context, + int runStart, + int runEnd, + List<_ResolvedFacet> covering, + bool approximateBlocks, + ) { + final runText = widget.text.substring(runStart, runEnd); + if (covering.isEmpty) { + return TextSpan(text: runText); + } + + var style = const TextStyle(); + final decorations = []; + VoidCallback? onTap; + _ResolvedFacet? spoiler; + SpoilerFacetFeature? spoilerFeature; + + for (final rf in covering) { + for (final feature in rf.facet.features) { + switch (feature) { + case BoldFacetFeature(): + style = style.copyWith(fontWeight: FontWeight.w700); + case ItalicFacetFeature(): + style = style.copyWith(fontStyle: FontStyle.italic); + case StrikethroughFacetFeature(): + decorations.add(TextDecoration.lineThrough); + case CodeFacetFeature(): + style = _monospace(style).copyWith( + backgroundColor: AppColors.backgroundTertiary, + ); + case LinkFacetFeature(uri: final uri): + if (uri.isNotEmpty) { + if (widget.linkStyle != null) { + style = style.merge(widget.linkStyle); + } else { + style = style.copyWith( + color: Theme.of(context).colorScheme.primary, + ); + decorations.add(TextDecoration.underline); + } + onTap ??= () { + UrlLauncher.launchExternalUrl(uri, context: context); + }; + } + case MentionFacetFeature(did: final did): + style = style.copyWith( color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - recognizer: recognizer, - ); + // Only set a weight when none is set yet, so an already-merged + // bold (w700) isn't clobbered down to w600 + fontWeight: style.fontWeight ?? FontWeight.w600, + ); + // Federation-supplied DID goes into a router path: only wire + // navigation for structurally valid DIDs (a value like + // "../login" would otherwise normalize into another route). + // Invalid DIDs render styled but not tappable. + if (_didPattern.hasMatch(did)) { + final mentionText = + widget.text.substring(rf.charStart, rf.charEnd); + onTap ??= () => _openMention(context, did, mentionText); + } + case SpoilerFacetFeature(): + if (spoiler == null) { + spoiler = rf; + spoilerFeature = feature; + } + case BlockquoteFacetFeature(): + if (approximateBlocks) { + style = style.copyWith( + fontStyle: FontStyle.italic, + // Muted color only when no earlier feature (link/mention) + // already set one + color: style.color ?? AppColors.textSecondary, + ); + } + case HeadingFacetFeature(): + if (approximateBlocks) { + style = style.copyWith(fontWeight: FontWeight.w700); + } + case CodeBlockFacetFeature(): + if (approximateBlocks) { + style = _monospace(style).copyWith( + backgroundColor: AppColors.backgroundTertiary, + ); + } + case UnknownFacetFeature(): + break; // Open union: unknown features render as plain text } } + } + + if (decorations.isNotEmpty) { + style = style.copyWith(decoration: TextDecoration.combine(decorations)); + } + + String? semanticsLabel; + if (spoiler != null) { + final key = '${spoiler.charStart}:${spoiler.charEnd}'; + if (_revealedSpoilers.contains(key)) { + style = style.copyWith( + backgroundColor: AppColors.backgroundTertiary.withValues(alpha: 0.5), + ); + // Revealed: onTap YIELDS to link/mention, so a revealed spoilered + // link launches (and consequently cannot be re-hidden) — deliberate + onTap ??= () => setState(() => _revealedSpoilers.remove(key)); + } else { + // Redacted: glyphs invisible, background solid; tap to reveal + style = style.copyWith( + color: Colors.transparent, + backgroundColor: AppColors.backgroundTertiary, + decoration: TextDecoration.none, + ); + // Screen readers must not speak the concealed text + final reason = spoilerFeature?.reason; + semanticsLabel = reason != null + ? 'Spoiler: $reason. Tap to reveal.' + : 'Spoiler. Tap to reveal.'; + // Hidden: onTap HARD-overrides link/mention so a concealed URL + // can't launch before the reader chooses to reveal it — deliberate + onTap = () => setState(() => _revealedSpoilers.add(key)); + } + } + GestureRecognizer? recognizer; + if (onTap != null) { + final tapRecognizer = TapGestureRecognizer()..onTap = onTap; + _recognizers.add(tapRecognizer); + recognizer = tapRecognizer; } - // No recognized features, render as plain text - return TextSpan(text: facetText); + return TextSpan( + text: runText, + style: style, + recognizer: recognizer, + semanticsLabel: semanticsLabel, + ); + } + + /// Structural DID shape (method + method-specific id); mentions with DIDs + /// that don't match are rendered styled but never made tappable, since the + /// DID is interpolated into a router path. + static final _didPattern = RegExp(r'^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$'); + + /// Navigates to the mentioned user or community + /// + /// The facet only carries a DID; the text prefix distinguishes users ('@') + /// from communities ('!'). Known limitation: a malformed facet range that + /// excludes the prefix character makes the heuristic misroute (community + /// mentions would open as profiles) — the facet itself carries no + /// user-vs-community discriminator to check against. + void _openMention(BuildContext context, String did, String mentionText) { + final route = + mentionText.startsWith('!') ? '/community/$did' : '/profile/$did'; + context.push(route); } } diff --git a/test/models/comment_test.dart b/test/models/comment_test.dart index f0e7807..b7b65a5 100644 --- a/test/models/comment_test.dart +++ b/test/models/comment_test.dart @@ -33,6 +33,60 @@ void main() { expect(response.comments[0].comment.content, 'Test comment'); }); + test('should skip malformed comments but keep valid ones', () { + final json = { + 'post': {'uri': 'at://test/post/123'}, + 'comments': [ + { + 'comment': { + 'uri': 'at://did:plc:test/comment/1', + 'cid': 'cid1', + 'record': {'content': 'Valid before'}, + 'createdAt': '2025-01-01T12:00:00Z', + 'indexedAt': '2025-01-01T12:00:00Z', + 'author': {'did': 'did:plc:author', 'handle': 'test.user'}, + 'post': {'uri': 'at://did:plc:test/post/123', 'cid': 'post-cid'}, + 'stats': {'upvotes': 1, 'downvotes': 0, 'score': 1}, + }, + 'hasMore': false, + }, + { + // Malformed: unparseable createdAt raises a FormatException + 'comment': { + 'uri': 'at://did:plc:test/comment/bad', + 'cid': 'cid-bad', + 'record': {'content': 'Bad date'}, + 'createdAt': 'not-a-date', + 'indexedAt': '2025-01-01T12:00:00Z', + 'author': {'did': 'did:plc:author', 'handle': 'test.user'}, + 'post': {'uri': 'at://did:plc:test/post/123', 'cid': 'post-cid'}, + 'stats': {'upvotes': 0, 'downvotes': 0, 'score': 0}, + }, + 'hasMore': false, + }, + { + 'comment': { + 'uri': 'at://did:plc:test/comment/2', + 'cid': 'cid2', + 'record': {'content': 'Valid after'}, + 'createdAt': '2025-01-01T12:00:00Z', + 'indexedAt': '2025-01-01T12:00:00Z', + 'author': {'did': 'did:plc:author', 'handle': 'test.user'}, + 'post': {'uri': 'at://did:plc:test/post/123', 'cid': 'post-cid'}, + 'stats': {'upvotes': 2, 'downvotes': 0, 'score': 2}, + }, + 'hasMore': false, + }, + ], + }; + + final response = CommentsResponse.fromJson(json); + + expect(response.comments.length, 2); + expect(response.comments[0].comment.uri, 'at://did:plc:test/comment/1'); + expect(response.comments[1].comment.uri, 'at://did:plc:test/comment/2'); + }); + test('should handle null comments array', () { final json = { 'post': {'uri': 'at://test/post/123'}, diff --git a/test/models/facet_test.dart b/test/models/facet_test.dart index 3c8e1f6..d695bcd 100644 --- a/test/models/facet_test.dart +++ b/test/models/facet_test.dart @@ -151,17 +151,17 @@ void main() { test('returns UnknownFacetFeature for unknown types', () { final json = { - r'$type': 'social.coves.richtext.facet#mention', - 'did': 'did:plc:abc123', + r'$type': 'social.coves.richtext.facet#futureFeature', + 'attr': 'value', }; final feature = FacetFeature.fromJson(json); expect(feature, isA()); - expect(feature.type, 'social.coves.richtext.facet#mention'); + expect(feature.type, 'social.coves.richtext.facet#futureFeature'); }); - test('returns UnknownFacetFeature when \$type is missing', () { + test(r'returns UnknownFacetFeature when $type is missing', () { final json = {'uri': 'https://example.com'}; final feature = FacetFeature.fromJson(json); @@ -170,7 +170,7 @@ void main() { expect(feature.type, 'unknown'); }); - test('returns UnknownFacetFeature when \$type is empty', () { + test(r'returns UnknownFacetFeature when $type is empty', () { final json = {r'$type': '', 'uri': 'https://example.com'}; final feature = FacetFeature.fromJson(json); @@ -178,55 +178,335 @@ void main() { expect(feature, isA()); }); - test('throws FormatException when LinkFacetFeature has missing uri', () { + test(r'returns UnknownFacetFeature when $type is a non-string int', () { + final json = {r'$type': 42, 'uri': 'https://example.com'}; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect(feature.type, 'unknown'); + }); + + test(r'returns UnknownFacetFeature when $type is a list', () { + final json = { + r'$type': ['x'], + }; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect(feature.type, 'unknown'); + }); + + test('degrades link with missing uri to UnknownFacetFeature', () { final json = {r'$type': 'social.coves.richtext.facet#link'}; - expect( - () => FacetFeature.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('uri'), - ), - ), - ); + expect(FacetFeature.fromJson(json), isA()); }); - test('throws FormatException when LinkFacetFeature has empty uri', () { + test('degrades link with empty uri to UnknownFacetFeature', () { final json = { r'$type': 'social.coves.richtext.facet#link', 'uri': '', }; - expect( - () => FacetFeature.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('uri'), - ), - ), - ); + expect(FacetFeature.fromJson(json), isA()); }); - test('throws FormatException when LinkFacetFeature has non-string uri', () { + test('degrades link with non-string uri to UnknownFacetFeature', () { final json = { r'$type': 'social.coves.richtext.facet#link', 'uri': 123, }; + expect(FacetFeature.fromJson(json), isA()); + }); + + test('parses MentionFacetFeature correctly', () { + final json = { + r'$type': 'social.coves.richtext.facet#mention', + 'did': 'did:plc:abc123', + }; + + final feature = FacetFeature.fromJson(json); + + expect(feature, isA()); + expect((feature as MentionFacetFeature).did, 'did:plc:abc123'); + }); + + test('degrades mention with missing did to UnknownFacetFeature', () { + final json = {r'$type': 'social.coves.richtext.facet#mention'}; + + expect(FacetFeature.fromJson(json), isA()); + }); + + test('parses simple formatting features', () { expect( - () => FacetFeature.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('uri'), - ), - ), + FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#bold', + }), + isA(), + ); + expect( + FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#italic', + }), + isA(), + ); + expect( + FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#strikethrough', + }), + isA(), + ); + expect( + FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#code', + }), + isA(), + ); + }); + + test('parses spoiler with and without reason', () { + final withReason = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#spoiler', + 'reason': 'ending', + }); + expect(withReason, isA()); + expect((withReason as SpoilerFacetFeature).reason, 'ending'); + + final withoutReason = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#spoiler', + }); + expect(withoutReason, isA()); + expect((withoutReason as SpoilerFacetFeature).reason, isNull); + }); + + test('parses blockquote with absent level as level 1', () { + final feature = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#blockquote', + }); + + expect(feature, isA()); + expect((feature as BlockquoteFacetFeature).level, 1); + }); + + test('parses blockquote level and clamps out-of-range values', () { + final level3 = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#blockquote', + 'level': 3, + }); + expect((level3 as BlockquoteFacetFeature).level, 3); + + final level9 = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#blockquote', + 'level': 9, + }); + expect((level9 as BlockquoteFacetFeature).level, 6); + }); + + test('degrades blockquote with non-int level to UnknownFacetFeature', () { + final json = { + r'$type': 'social.coves.richtext.facet#blockquote', + 'level': 'two', + }; + + expect(FacetFeature.fromJson(json), isA()); + }); + + test('parses heading level and clamps out-of-range values', () { + final feature = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#heading', + 'level': 2, + }); + + expect(feature, isA()); + expect((feature as HeadingFacetFeature).level, 2); + + final level9 = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#heading', + 'level': 9, + }); + expect((level9 as HeadingFacetFeature).level, 6); + }); + + test('degrades heading with missing level to UnknownFacetFeature', () { + final json = {r'$type': 'social.coves.richtext.facet#heading'}; + + expect(FacetFeature.fromJson(json), isA()); + }); + + test('parses codeBlock with and without language', () { + final withLang = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#codeBlock', + 'language': 'go', + }); + expect(withLang, isA()); + expect((withLang as CodeBlockFacetFeature).language, 'go'); + + final withoutLang = FacetFeature.fromJson({ + r'$type': 'social.coves.richtext.facet#codeBlock', + }); + expect(withoutLang, isA()); + expect((withoutLang as CodeBlockFacetFeature).language, isNull); + }); + + test('new features round-trip through JSON', () { + final features = [ + const MentionFacetFeature(did: 'did:plc:xyz'), + const BoldFacetFeature(), + const ItalicFacetFeature(), + const StrikethroughFacetFeature(), + const SpoilerFacetFeature(reason: 'plot'), + const BlockquoteFacetFeature(level: 2), + const HeadingFacetFeature(level: 1), + const CodeFacetFeature(), + const CodeBlockFacetFeature(language: 'python'), + ]; + + for (final original in features) { + final restored = FacetFeature.fromJson(original.toJson()); + expect(restored, original, reason: 'round-trip of ${original.type}'); + } + }); + }); + + group('RichTextFacet.blockFeature', () { + test('returns the first block-level feature', () { + const facet = RichTextFacet( + index: ByteSlice(byteStart: 0, byteEnd: 5), + features: [ + BoldFacetFeature(), + HeadingFacetFeature(level: 2), + ], + ); + + expect(facet.blockFeature, const HeadingFacetFeature(level: 2)); + }); + + test('returns null when only inline features present', () { + const facet = RichTextFacet( + index: ByteSlice(byteStart: 0, byteEnd: 5), + features: [ + BoldFacetFeature(), + LinkFacetFeature(uri: 'https://example.com'), + ], ); + + expect(facet.blockFeature, isNull); + }); + }); + + group('parseFacetsFromRecord', () { + test('drops malformed facets but keeps valid ones', () { + final record = { + 'facets': [ + { + // Malformed: missing byteStart + 'index': {'byteEnd': 5}, + 'features': [ + {r'$type': 'social.coves.richtext.facet#bold'}, + ], + }, + { + 'index': {'byteStart': 0, 'byteEnd': 5}, + 'features': [ + {r'$type': 'social.coves.richtext.facet#bold'}, + ], + }, + ], + }; + + final facets = parseFacetsFromRecord(record); + + expect(facets, isNotNull); + expect(facets!.length, 1); + expect(facets.first.features.first, const BoldFacetFeature()); + }); + + test('returns null when every facet is malformed', () { + final record = { + 'facets': [ + { + 'index': {'byteEnd': 5}, + 'features': >[], + }, + ], + }; + + expect(parseFacetsFromRecord(record), isNull); + }); + + test('truncates to the first 200 facets (backend MaxFacets cap)', () { + final record = { + 'facets': List.generate( + 201, + (i) => { + 'index': {'byteStart': i, 'byteEnd': i + 1}, + 'features': [ + {r'$type': 'social.coves.richtext.facet#bold'}, + ], + }, + ), + }; + + final facets = parseFacetsFromRecord(record); + + expect(facets, isNotNull); + expect(facets!.length, 200); + // First 200 survive: last kept facet is index 199 + expect(facets.last.index, const ByteSlice(byteStart: 199, byteEnd: 200)); + }); + + test('drops a facet with more than 20 features but keeps siblings', () { + final record = { + 'facets': [ + { + 'index': {'byteStart': 0, 'byteEnd': 5}, + 'features': List.generate( + 21, + (_) => {r'$type': 'social.coves.richtext.facet#bold'}, + ), + }, + { + 'index': {'byteStart': 10, 'byteEnd': 15}, + 'features': [ + {r'$type': 'social.coves.richtext.facet#italic'}, + ], + }, + ], + }; + + final facets = parseFacetsFromRecord(record); + + expect(facets, isNotNull); + expect(facets!.length, 1); + expect(facets.first.index, const ByteSlice(byteStart: 10, byteEnd: 15)); + expect(facets.first.features.first, const ItalicFacetFeature()); + }); + + test('drops a facet whose features is a non-list without nuking siblings', + () { + final record = { + 'facets': [ + { + 'index': {'byteStart': 0, 'byteEnd': 5}, + 'features': 'not-a-list', + }, + { + 'index': {'byteStart': 10, 'byteEnd': 15}, + 'features': [ + {r'$type': 'social.coves.richtext.facet#bold'}, + ], + }, + ], + }; + + final facets = parseFacetsFromRecord(record); + + expect(facets, isNotNull); + expect(facets!.length, 1); + expect(facets.first.index, const ByteSlice(byteStart: 10, byteEnd: 15)); }); }); @@ -238,7 +518,7 @@ void main() { expect(feature.type, 'social.coves.richtext.facet#link'); }); - test('toJson produces correct format with \$type field', () { + test(r'toJson produces correct format with $type field', () { const feature = LinkFacetFeature(uri: 'https://example.com/path?q=1'); final json = feature.toJson(); @@ -298,30 +578,36 @@ void main() { expect(json, data); }); - test('type property returns \$type from data', () { - final feature = UnknownFacetFeature(data: { + test(r'type property returns $type from data', () { + const 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'}); + test(r'type property returns "unknown" when $type is missing', () { + const feature = UnknownFacetFeature(data: {'foo': 'bar'}); + + expect(feature.type, 'unknown'); + }); + + test(r'type property returns "unknown" when $type is not a string', () { + const feature = UnknownFacetFeature(data: {r'$type': 42}); expect(feature.type, 'unknown'); }); test('equality works with same data', () { - final feature1 = UnknownFacetFeature(data: { + const feature1 = UnknownFacetFeature(data: { r'$type': 'test', 'value': 123, }); - final feature2 = UnknownFacetFeature(data: { + const feature2 = UnknownFacetFeature(data: { r'$type': 'test', 'value': 123, }); - final feature3 = UnknownFacetFeature(data: { + const feature3 = UnknownFacetFeature(data: { r'$type': 'test', 'value': 456, }); @@ -331,7 +617,7 @@ void main() { }); test('identical instances have equal hashCode', () { - final feature = UnknownFacetFeature(data: { + const feature = UnknownFacetFeature(data: { r'$type': 'test', 'value': 123, }); @@ -342,12 +628,14 @@ void main() { }); test('toString format', () { - final feature = UnknownFacetFeature(data: { - r'$type': 'social.coves.richtext.facet#mention', + const feature = UnknownFacetFeature(data: { + r'$type': 'social.coves.richtext.facet#future', }); expect( - feature.toString(), 'UnknownFacetFeature(social.coves.richtext.facet#mention)'); + feature.toString(), + 'UnknownFacetFeature(social.coves.richtext.facet#future)', + ); }); }); @@ -355,7 +643,7 @@ void main() { 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); + const facet = RichTextFacet(index: index, features: features); expect(facet.index, index); expect(facet.features.length, 1); @@ -401,7 +689,8 @@ void main() { expect(facet.features.length, 2); expect(facet.features[0], isA()); - expect(facet.features[1], isA()); + expect(facet.features[1], isA()); + expect((facet.features[1] as MentionFacetFeature).did, 'did:plc:abc'); }); test('fromJson throws on missing index', () { @@ -482,7 +771,7 @@ void main() { 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); + const facet = RichTextFacet(index: index, features: features); final json = facet.toJson(); @@ -495,7 +784,7 @@ void main() { 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); + const original = RichTextFacet(index: index, features: features); final json = original.toJson(); final restored = RichTextFacet.fromJson(json); @@ -508,7 +797,7 @@ void main() { 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); + const facet = RichTextFacet(index: index, features: features); expect(facet.hasLink, true); }); @@ -516,7 +805,7 @@ void main() { test('hasLink returns false when no LinkFacetFeature', () { const index = ByteSlice(byteStart: 0, byteEnd: 10); final features = [ - UnknownFacetFeature(data: {r'$type': 'mention'}), + const UnknownFacetFeature(data: {r'$type': 'mention'}), ]; final facet = RichTextFacet(index: index, features: features); @@ -525,8 +814,8 @@ void main() { test('hasLink returns false with empty features', () { const index = ByteSlice(byteStart: 0, byteEnd: 10); - const List features = []; - final facet = RichTextFacet(index: index, features: features); + const features = []; + const facet = RichTextFacet(index: index, features: features); expect(facet.hasLink, false); }); @@ -534,7 +823,7 @@ void main() { 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); + const facet = RichTextFacet(index: index, features: features); expect(facet.linkUri, 'https://example.com/page'); }); @@ -545,7 +834,7 @@ void main() { LinkFacetFeature(uri: 'https://first.com'), LinkFacetFeature(uri: 'https://second.com'), ]; - final facet = RichTextFacet(index: index, features: features); + const facet = RichTextFacet(index: index, features: features); expect(facet.linkUri, 'https://first.com'); }); @@ -553,7 +842,7 @@ void main() { test('linkUri returns null when no link', () { const index = ByteSlice(byteStart: 0, byteEnd: 10); final features = [ - UnknownFacetFeature(data: {r'$type': 'mention'}), + const UnknownFacetFeature(data: {r'$type': 'mention'}), ]; final facet = RichTextFacet(index: index, features: features); @@ -562,8 +851,8 @@ void main() { test('linkUri returns null with empty features', () { const index = ByteSlice(byteStart: 0, byteEnd: 10); - const List features = []; - final facet = RichTextFacet(index: index, features: features); + const features = []; + const facet = RichTextFacet(index: index, features: features); expect(facet.linkUri, null); }); @@ -571,10 +860,10 @@ void main() { 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), + const facet1 = RichTextFacet(index: index, features: features); + const facet2 = RichTextFacet(index: index, features: features); + const facet3 = RichTextFacet( + index: ByteSlice(byteStart: 0, byteEnd: 20), features: features, ); @@ -585,13 +874,13 @@ void main() { test('equality with different features', () { const index = ByteSlice(byteStart: 0, byteEnd: 10); - final facet1 = RichTextFacet( + const facet1 = RichTextFacet( index: index, - features: const [LinkFacetFeature(uri: 'https://example.com')], + features: [LinkFacetFeature(uri: 'https://example.com')], ); - final facet2 = RichTextFacet( + const facet2 = RichTextFacet( index: index, - features: const [LinkFacetFeature(uri: 'https://other.com')], + features: [LinkFacetFeature(uri: 'https://other.com')], ); expect(facet1, isNot(equals(facet2))); @@ -600,7 +889,7 @@ void main() { test('toString format', () { const index = ByteSlice(byteStart: 5, byteEnd: 15); const features = [LinkFacetFeature(uri: 'https://example.com')]; - final facet = RichTextFacet(index: index, features: features); + const facet = RichTextFacet(index: index, features: features); expect(facet.toString(), 'RichTextFacet(ByteSlice(5, 15), 1 features)'); }); diff --git a/test/widgets/rich_text_renderer_test.dart b/test/widgets/rich_text_renderer_test.dart index c7e1537..430ec98 100644 --- a/test/widgets/rich_text_renderer_test.dart +++ b/test/widgets/rich_text_renderer_test.dart @@ -5,6 +5,7 @@ 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:go_router/go_router.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../test_helpers/mock_url_launcher_platform.dart'; @@ -21,8 +22,8 @@ RichTextFacet _createLinkFacetFromText({ int occurrence = 0, }) { // Find the character indices - int charStart = fullText.indexOf(linkText); - for (int i = 0; i < occurrence && charStart != -1; i++) { + var charStart = fullText.indexOf(linkText); + for (var i = 0; i < occurrence && charStart != -1; i++) { charStart = fullText.indexOf(linkText, charStart + 1); } if (charStart == -1) { @@ -57,6 +58,41 @@ Widget _wrapInMaterialApp(Widget child) { return MaterialApp(home: Scaffold(body: child)); } +/// Helper to wrap widget in a real GoRouter harness so mention taps can be +/// asserted against actual navigation. Placeholder screens display the +/// resolved path parameter. +Widget _wrapInRouterApp(Widget child) { + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (context, state) => Scaffold(body: child)), + GoRoute( + path: '/profile/:actor', + builder: (context, state) => + Scaffold(body: Text('profile:${state.pathParameters['actor']}')), + ), + GoRoute( + path: '/community/:identifier', + builder: (context, state) => Scaffold( + body: Text('community:${state.pathParameters['identifier']}'), + ), + ), + ], + ); + return MaterialApp.router(routerConfig: router); +} + +/// Helper to find the TextSpan carrying exactly [text] among content spans +TextSpan _spanWithText(List spans, String text) { + return spans.whereType().firstWhere((s) => s.text == text); +} + +/// Matches the blockquote left-bar container used by the renderer +bool _hasLeftBar(Widget w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration! as BoxDecoration).border is Border && + ((w.decoration! as BoxDecoration).border! as Border).left.width == 3; + /// 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 @@ -349,7 +385,7 @@ void main() { }, ); - testWidgets('handles overlapping facets (processes first, skips overlap)', ( + testWidgets('overlapping facets split into runs, first link wins taps', ( tester, ) async { // Two facets that overlap @@ -382,12 +418,27 @@ void main() { 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); + // Runs split at facet boundaries: + // "Check ", "htt", "ps://exam" (both facets), "ple.com", " out" + expect(spans.length, 5); - final linkSpan = spans[1] as TextSpan; - expect(linkSpan.text, 'https://example.com'); + final linkText = [spans[1], spans[2], spans[3]] + .map((s) => (s as TextSpan).text) + .join(); + expect(linkText, 'https://example.com'); + + // Every link run is styled and tappable + for (final span in [spans[1], spans[2], spans[3]]) { + final textSpan = span as TextSpan; + expect(textSpan.style?.decoration, TextDecoration.underline); + expect(textSpan.recognizer, isA()); + } + + // In the overlap run the first (earlier-starting) facet wins the tap + final overlapSpan = spans[2] as TextSpan; + (overlapSpan.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pumpAndSettle(); + expect(mockPlatform.launchedUrls, contains('https://example.com')); }); testWidgets('handles facets with invalid byte range (skipped gracefully)', ( @@ -609,7 +660,7 @@ void main() { expect(linkSpan.recognizer, isA()); // Simulate tap on the recognizer - final recognizer = linkSpan.recognizer as TapGestureRecognizer; + final recognizer = linkSpan.recognizer! as TapGestureRecognizer; recognizer.onTap?.call(); // Allow async operations to complete @@ -648,7 +699,7 @@ void main() { // First link final firstLink = spans[1] as TextSpan; expect(firstLink.recognizer, isNotNull); - (firstLink.recognizer as TapGestureRecognizer).onTap?.call(); + (firstLink.recognizer! as TapGestureRecognizer).onTap?.call(); await tester.pumpAndSettle(); expect(mockPlatform.launchedUrls, contains('https://google.com')); @@ -656,7 +707,7 @@ void main() { // Second link final secondLink = spans[3] as TextSpan; expect(secondLink.recognizer, isNotNull); - (secondLink.recognizer as TapGestureRecognizer).onTap?.call(); + (secondLink.recognizer! as TapGestureRecognizer).onTap?.call(); await tester.pumpAndSettle(); expect(mockPlatform.launchedUrls, contains('https://apple.com')); @@ -889,7 +940,7 @@ void main() { byteEnd: _byteLen('Some text'), ), features: [ - UnknownFacetFeature(data: {r'$type': 'unknown.type'}), + const UnknownFacetFeature(data: {r'$type': 'unknown.type'}), ], ), ], @@ -1040,4 +1091,1085 @@ void main() { expect(spans.length, greaterThanOrEqualTo(5)); }); }); + + group('RichTextRenderer - Inline Formatting Facets', () { + testWidgets('bold facet renders with heavy font weight', (tester) async { + const text = 'some bold text'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'bold', + features: const [BoldFacetFeature()], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + expect(spans.length, 3); + final boldSpan = spans[1] as TextSpan; + expect(boldSpan.text, 'bold'); + expect(boldSpan.style?.fontWeight, FontWeight.w700); + }); + + testWidgets('overlapping bold and italic merge in the overlap run', ( + tester, + ) async { + const text = 'abcdef'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'abcd', + features: const [BoldFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'cdef', + features: const [ItalicFacetFeature()], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // "ab" bold, "cd" bold+italic, "ef" italic + expect(spans.length, 3); + + final bold = spans[0] as TextSpan; + expect(bold.text, 'ab'); + expect(bold.style?.fontWeight, FontWeight.w700); + expect(bold.style?.fontStyle, isNot(FontStyle.italic)); + + final both = spans[1] as TextSpan; + expect(both.text, 'cd'); + expect(both.style?.fontWeight, FontWeight.w700); + expect(both.style?.fontStyle, FontStyle.italic); + + final italic = spans[2] as TextSpan; + expect(italic.text, 'ef'); + expect(italic.style?.fontWeight, isNull); + expect(italic.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('strikethrough combines with link underline', (tester) async { + const text = 'a dead link here'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'dead link', + features: const [ + LinkFacetFeature(uri: 'https://example.com'), + StrikethroughFacetFeature(), + ], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + final span = spans[1] as TextSpan; + + expect(span.style?.decoration?.contains(TextDecoration.underline), true); + expect( + span.style?.decoration?.contains(TextDecoration.lineThrough), + true, + ); + }); + + testWidgets('inline code renders in monospace', (tester) async { + const text = 'run flutter test now'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'flutter test', + features: const [CodeFacetFeature()], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + final code = spans[1] as TextSpan; + + expect(code.text, 'flutter test'); + expect(code.style?.fontFamily, 'monospace'); + expect(code.style?.backgroundColor, isNotNull); + }); + + testWidgets('mention is styled and tappable', (tester) async { + const text = 'hey @alice.test look'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: '@alice.test', + features: const [MentionFacetFeature(did: 'did:plc:abc')], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + final mention = spans[1] as TextSpan; + + expect(mention.text, '@alice.test'); + expect(mention.style?.fontWeight, FontWeight.w600); + expect(mention.style?.color, isNotNull); + expect(mention.recognizer, isA()); + }); + }); + + group('RichTextRenderer - Spoiler Facets', () { + testWidgets('spoiler is redacted until tapped, then revealed', ( + tester, + ) async { + const text = 'the killer is Bob obviously'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'Bob', + features: const [SpoilerFacetFeature(reason: 'spoiler')], + ), + ], + ), + ), + ); + + var richText = tester.widget(find.byType(RichText)); + var spans = _getContentSpans(richText); + var spoilerSpan = spans[1] as TextSpan; + + // Hidden: glyphs transparent over a solid background + expect(spoilerSpan.style?.color, Colors.transparent); + expect(spoilerSpan.style?.backgroundColor, isNotNull); + expect(spoilerSpan.recognizer, isA()); + + // Tap to reveal + (spoilerSpan.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pump(); + + richText = tester.widget(find.byType(RichText)); + spans = _getContentSpans(richText); + spoilerSpan = spans[1] as TextSpan; + + expect(spoilerSpan.style?.color, isNot(Colors.transparent)); + + // Tap again to re-hide + (spoilerSpan.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pump(); + + richText = tester.widget(find.byType(RichText)); + spans = _getContentSpans(richText); + spoilerSpan = spans[1] as TextSpan; + expect(spoilerSpan.style?.color, Colors.transparent); + }); + }); + + group('RichTextRenderer - Block Facets', () { + testWidgets('heading renders as its own scaled, bold line', ( + tester, + ) async { + const text = 'Big Title\nBody text follows'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + style: const TextStyle(fontSize: 14), + facets: [ + _facetOver( + fullText: text, + span: 'Big Title', + features: const [HeadingFacetFeature(level: 1)], + ), + ], + ), + ), + ); + + // Block layout: heading and body are separate RichText widgets + final richTexts = tester.widgetList(find.byType(RichText)); + expect(richTexts.length, 2); + + // The root span carries the ambient default style; the heading style + // lives on the inner span + final root = richTexts.first.text as TextSpan; + final headingSpan = root.children!.first as TextSpan; + expect(headingSpan.style?.fontWeight, FontWeight.w700); + expect(headingSpan.style?.fontSize, greaterThan(14)); + + expect(find.textContaining('Body text follows'), findsOneWidget); + }); + + testWidgets('mid-line heading range extends to whole line', ( + tester, + ) async { + const text = 'Big Title\nBody'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + // Malformed range covering only "Big" - reader extends it + _facetOver( + fullText: text, + span: 'Big', + features: const [HeadingFacetFeature(level: 2)], + ), + ], + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + expect(richTexts.length, 2); + expect(richTexts.first.text.toPlainText(), 'Big Title'); + }); + + testWidgets('blockquote renders with a left bar per level', ( + tester, + ) async { + const text = 'quoted wisdom\nmy reply'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'quoted wisdom', + features: const [BlockquoteFacetFeature(level: 2)], + ), + ], + ), + ), + ); + + bool hasLeftBar(Widget w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration! as BoxDecoration).border is Border && + ((w.decoration! as BoxDecoration).border! as Border).left.width == 3; + + // Level 2 quote nests two bar containers + expect(find.byWidgetPredicate(hasLeftBar), findsNWidgets(2)); + expect(find.textContaining('quoted wisdom'), findsOneWidget); + expect(find.textContaining('my reply'), findsOneWidget); + }); + + testWidgets('code block renders monospace card with language label', ( + tester, + ) async { + const text = 'look:\nprint("hi")\ndone'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'print("hi")', + features: const [CodeBlockFacetFeature(language: 'python')], + ), + ], + ), + ), + ); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(find.text('python'), findsOneWidget); + + final codeText = tester.widget(find.text('print("hi")')); + expect(codeText.style?.fontFamily, 'monospace'); + expect(codeText.softWrap, false); + + expect(find.textContaining('look:'), findsOneWidget); + expect(find.textContaining('done'), findsOneWidget); + }); + + testWidgets('code block inside blockquote (cross-type nesting)', ( + tester, + ) async { + const text = 'they said:\ncode here\nend quote'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'they said:\ncode here\nend quote', + features: const [BlockquoteFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'code here', + features: const [CodeBlockFacetFeature()], + ), + ], + ), + ), + ); + + // The code block card renders inside the quote bar container + final scrollView = find.byType(SingleChildScrollView); + expect(scrollView, findsOneWidget); + expect( + find.ancestor( + of: scrollView, + matching: find.byWidgetPredicate( + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration! as BoxDecoration).border is Border, + ), + ), + findsWidgets, + ); + }); + + testWidgets('adjacent quote facets render as separate blocks', ( + tester, + ) async { + const text = 'first quote\nsecond quote'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'first quote', + features: const [BlockquoteFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'second quote', + features: const [BlockquoteFacetFeature(level: 2)], + ), + ], + ), + ), + ); + + // 1 bar for level 1 + 2 bars for level 2 + bool hasLeftBar(Widget w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration! as BoxDecoration).border is Border && + ((w.decoration! as BoxDecoration).border! as Border).left.width == 3; + expect(find.byWidgetPredicate(hasLeftBar), findsNWidgets(3)); + }); + + testWidgets('inline facets still work inside a heading', (tester) async { + const text = 'See https://example.com\nBody'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'See https://example.com', + features: const [HeadingFacetFeature(level: 3)], + ), + _createLinkFacetFromText( + fullText: text, + linkText: 'https://example.com', + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + expect(richTexts.length, 2); + + final headingSpans = _getContentSpans(richTexts.first); + final linkSpan = headingSpans[1] as TextSpan; + expect(linkSpan.text, 'https://example.com'); + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isA()); + }); + }); + + group('RichTextRenderer - Compact Mode (maxLines)', () { + testWidgets('block facets approximate inline so ellipsis works', ( + tester, + ) async { + const text = 'Big Title\nquoted line\ncode line\nplain end'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + maxLines: 5, + overflow: TextOverflow.ellipsis, + facets: [ + _facetOver( + fullText: text, + span: 'Big Title', + features: const [HeadingFacetFeature(level: 1)], + ), + _facetOver( + fullText: text, + span: 'quoted line', + features: const [BlockquoteFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'code line', + features: const [CodeBlockFacetFeature()], + ), + ], + ), + ), + ); + + // Everything stays in a single RichText so maxLines applies + final richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + expect(richText.maxLines, 5); + expect(richText.overflow, TextOverflow.ellipsis); + + final spans = _getContentSpans(richText); + final byText = { + for (final s in spans.whereType()) s.text: s, + }; + + expect(byText['Big Title']?.style?.fontWeight, FontWeight.w700); + expect(byText['quoted line']?.style?.fontStyle, FontStyle.italic); + expect(byText['code line']?.style?.fontFamily, 'monospace'); + }); + }); + + group('RichTextRenderer - Mention Navigation', () { + testWidgets('tapping a user mention navigates to the profile route', ( + tester, + ) async { + const text = 'hey @alice.test look'; + + await tester.pumpWidget( + _wrapInRouterApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: '@alice.test', + features: const [MentionFacetFeature(did: 'did:plc:abc')], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final mention = _spanWithText(_getContentSpans(richText), '@alice.test'); + expect(mention.recognizer, isA()); + + (mention.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pumpAndSettle(); + + expect(find.text('profile:did:plc:abc'), findsOneWidget); + }); + + testWidgets('tapping a community mention navigates to the community route', + (tester) async { + const text = 'join !books.coves.social today'; + + await tester.pumpWidget( + _wrapInRouterApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: '!books.coves.social', + features: const [MentionFacetFeature(did: 'did:plc:books123')], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final mention = + _spanWithText(_getContentSpans(richText), '!books.coves.social'); + expect(mention.recognizer, isA()); + + (mention.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pumpAndSettle(); + + expect(find.text('community:did:plc:books123'), findsOneWidget); + }); + + testWidgets('mention with malicious non-DID value is not tappable', ( + tester, + ) async { + const text = 'hey @evil.test look'; + + await tester.pumpWidget( + _wrapInRouterApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: '@evil.test', + features: const [MentionFacetFeature(did: '../login')], + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final mention = _spanWithText(_getContentSpans(richText), '@evil.test'); + + // Still styled as a mention, but with no recognizer navigation is + // impossible + expect(mention.style?.fontWeight, FontWeight.w600); + expect(mention.recognizer, isNull); + }); + }); + + group('RichTextRenderer - Block Facets with Emoji', () { + const text = '🎉 intro\nBig 👋 Title\ncode 🚀 here\nend'; + + List facets() => [ + _facetOver( + fullText: text, + span: 'Big 👋 Title', + features: const [HeadingFacetFeature(level: 2)], + ), + _facetOver( + fullText: text, + span: 'code 🚀 here', + features: const [CodeBlockFacetFeature()], + ), + ]; + + testWidgets('emoji before block ranges keeps block boundaries aligned', ( + tester, + ) async { + await tester.pumpWidget( + _wrapInMaterialApp(RichTextRenderer(text: text, facets: facets())), + ); + + final richTexts = + tester.widgetList(find.byType(RichText)).toList(); + final plainTexts = richTexts.map((rt) => rt.text.toPlainText()).toList(); + + // The heading line renders exactly, not shifted by multi-byte emoji + expect(plainTexts, contains('Big 👋 Title')); + final headingRt = richTexts[plainTexts.indexOf('Big 👋 Title')]; + final headingSpan = + (headingRt.text as TextSpan).children!.first as TextSpan; + expect(headingSpan.style?.fontWeight, FontWeight.w700); + + // The code block carries exactly its line + final codeText = tester.widget(find.text('code 🚀 here')); + expect(codeText.style?.fontFamily, 'monospace'); + + // Surrounding paragraphs intact + expect(plainTexts, contains('🎉 intro')); + expect(plainTexts, contains('end')); + }); + + testWidgets('compact mode approximates emoji block lines inline', ( + tester, + ) async { + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer(text: text, facets: facets(), maxLines: 4), + ), + ); + + // Single RichText so maxLines applies + expect(find.byType(RichText), findsOneWidget); + + final spans = + _getContentSpans(tester.widget(find.byType(RichText))); + final runTexts = + spans.whereType().map((s) => s.text).toList(); + + // No text lost or shifted by emoji byte offsets + expect(runTexts.join(), text); + + final byText = {for (final s in spans.whereType()) s.text: s}; + expect(byText['Big 👋 Title']?.style?.fontWeight, FontWeight.w700); + expect(byText['code 🚀 here']?.style?.fontFamily, 'monospace'); + }); + }); + + group('RichTextRenderer - Block Range Degradation', () { + testWidgets('facet with byteEnd past text byte length is dropped', ( + tester, + ) async { + const text = 'hello world'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _createLinkFacet( + byteStart: _byteLen('hello '), + byteEnd: _byteLen(text) + 5, + uri: 'https://example.com', + ), + ], + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final spans = _getContentSpans(richText); + + // Facet dropped entirely: one plain run, no link styling or tap + expect(spans.length, 1); + final span = spans[0] as TextSpan; + expect(span.text, text); + expect(span.style?.decoration, isNull); + expect(span.recognizer, isNull); + }); + + testWidgets('block range starting on a newline leaves prior line out', ( + tester, + ) async { + const text = 'hello\nquoted line'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + // Sloppy range starting AT the newline byte + RichTextFacet( + index: ByteSlice( + byteStart: _byteLen('hello'), + byteEnd: _byteLen(text), + ), + features: const [BlockquoteFacetFeature()], + ), + ], + ), + ), + ); + + expect(find.byWidgetPredicate(_hasLeftBar), findsOneWidget); + + final hello = find.textContaining('hello'); + expect(hello, findsOneWidget); + expect( + find.ancestor(of: hello, matching: find.byWidgetPredicate(_hasLeftBar)), + findsNothing, + ); + + final quoted = find.textContaining('quoted line'); + expect(quoted, findsOneWidget); + expect( + find.ancestor( + of: quoted, + matching: find.byWidgetPredicate(_hasLeftBar), + ), + findsOneWidget, + ); + }); + + testWidgets('code block straddling a quote boundary loses no text', ( + tester, + ) async { + const text = 'line one\nline two\nline three'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'line one\nline two', + features: const [BlockquoteFacetFeature()], + ), + // Starts inside line two, ends in line three: straddles the + // quote's end boundary + _facetOver( + fullText: text, + span: 'two\nline three', + features: const [CodeBlockFacetFeature()], + ), + ], + ), + ), + ); + + // Quote renders lines 1-2 inside its bar + expect(find.byWidgetPredicate(_hasLeftBar), findsOneWidget); + final quoted = find.textContaining('line one'); + expect(quoted, findsOneWidget); + expect( + find.ancestor( + of: quoted, + matching: find.byWidgetPredicate(_hasLeftBar), + ), + findsOneWidget, + ); + expect(find.textContaining('line two'), findsOneWidget); + + // Line three still appears (as a paragraph outside the bar) + final lineThree = find.textContaining('line three'); + expect(lineThree, findsOneWidget); + expect( + find.ancestor( + of: lineThree, + matching: find.byWidgetPredicate(_hasLeftBar), + ), + findsNothing, + ); + }); + + testWidgets('quote fully contained in a quote degrades without crash', ( + tester, + ) async { + const text = 'quoted outer\nquoted inner'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: text, + features: const [BlockquoteFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'quoted inner', + features: const [BlockquoteFacetFeature(level: 2)], + ), + ], + ), + ), + ); + + // Disallowed by the lexicon, so exact bar count is not pinned; the + // outer quote must render and no text may be lost + expect(find.byWidgetPredicate(_hasLeftBar), findsWidgets); + expect(find.textContaining('quoted outer'), findsOneWidget); + expect(find.textContaining('quoted inner'), findsOneWidget); + }); + + testWidgets('code block covering the entire text renders directly', ( + tester, + ) async { + const text = 'final x = 42;'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: text, + features: const [CodeBlockFacetFeature()], + ), + ], + ), + ), + ); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + final codeText = tester.widget(find.text(text)); + expect(codeText.style?.fontFamily, 'monospace'); + }); + }); + + group('RichTextRenderer - Spoiler Interaction', () { + testWidgets('hidden spoilered link reveals on tap instead of launching', ( + tester, + ) async { + const text = 'watch the trailer now'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'the trailer', + features: const [ + SpoilerFacetFeature(), + LinkFacetFeature(uri: 'https://example.com/trailer'), + ], + ), + ], + ), + ), + ); + + var spans = + _getContentSpans(tester.widget(find.byType(RichText))); + var span = _spanWithText(spans, 'the trailer'); + + // Hidden: redacted, labelled for screen readers + expect(span.style?.color, Colors.transparent); + expect(span.semanticsLabel, contains('Spoiler')); + + // Tap while hidden: reveals, must NOT launch the concealed URL + (span.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pumpAndSettle(); + expect(mockPlatform.launchedUrls, isEmpty); + + spans = + _getContentSpans(tester.widget(find.byType(RichText))); + span = _spanWithText(spans, 'the trailer'); + expect(span.style?.color, isNot(Colors.transparent)); + expect(span.semanticsLabel, isNull); + + // Tap while revealed: launches the link + (span.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pumpAndSettle(); + expect(mockPlatform.launchedUrls, contains('https://example.com/trailer')); + }); + + testWidgets('spoiler reason appears in the semantics label', ( + tester, + ) async { + const text = 'the killer is Bob obviously'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'Bob', + features: const [SpoilerFacetFeature(reason: 'ending')], + ), + ], + ), + ), + ); + + final spans = + _getContentSpans(tester.widget(find.byType(RichText))); + final span = _spanWithText(spans, 'Bob'); + expect(span.semanticsLabel, contains('ending')); + }); + + testWidgets('revealed spoiler state is cleared when facets change', ( + tester, + ) async { + const text = 'the killer is Bob obviously'; + + List spoilerFacets() => [ + _facetOver( + fullText: text, + span: 'Bob', + features: const [SpoilerFacetFeature()], + ), + ]; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer(text: text, facets: spoilerFacets()), + ), + ); + + var spans = + _getContentSpans(tester.widget(find.byType(RichText))); + var span = _spanWithText(spans, 'Bob'); + (span.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pump(); + + spans = + _getContentSpans(tester.widget(find.byType(RichText))); + span = _spanWithText(spans, 'Bob'); + expect(span.style?.color, isNot(Colors.transparent)); + + // Same text, DIFFERENT facets list: reveal state must reset + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + ...spoilerFacets(), + _facetOver( + fullText: text, + span: 'killer', + features: const [BoldFacetFeature()], + ), + ], + ), + ), + ); + + spans = + _getContentSpans(tester.widget(find.byType(RichText))); + span = _spanWithText(spans, 'Bob'); + expect(span.style?.color, Colors.transparent); + }); + + testWidgets('revealed spoiler survives a rebuild with identical facets', ( + tester, + ) async { + const text = 'the killer is Bob obviously'; + + List spoilerFacets() => [ + _facetOver( + fullText: text, + span: 'Bob', + features: const [SpoilerFacetFeature()], + ), + ]; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer(text: text, facets: spoilerFacets()), + ), + ); + + var spans = + _getContentSpans(tester.widget(find.byType(RichText))); + var span = _spanWithText(spans, 'Bob'); + (span.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pump(); + + // Rebuild with a freshly-constructed but EQUAL facets list + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer(text: text, facets: spoilerFacets()), + ), + ); + + spans = + _getContentSpans(tester.widget(find.byType(RichText))); + span = _spanWithText(spans, 'Bob'); + expect(span.style?.color, isNot(Colors.transparent)); + }); + + testWidgets('spoiler inside a code block is redacted until tapped', ( + tester, + ) async { + const text = 'intro\nlet pass = hunter2\nend'; + + await tester.pumpWidget( + _wrapInMaterialApp( + RichTextRenderer( + text: text, + facets: [ + _facetOver( + fullText: text, + span: 'let pass = hunter2', + features: const [CodeBlockFacetFeature()], + ), + _facetOver( + fullText: text, + span: 'hunter2', + features: const [SpoilerFacetFeature()], + ), + ], + ), + ), + ); + + final codeRichText = find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byType(RichText), + ); + + var spans = _getContentSpans(tester.widget(codeRichText)); + var span = _spanWithText(spans, 'hunter2'); + expect(span.style?.color, Colors.transparent); + expect(span.semanticsLabel, contains('Spoiler')); + expect(span.recognizer, isA()); + + (span.recognizer! as TapGestureRecognizer).onTap?.call(); + await tester.pump(); + + spans = _getContentSpans(tester.widget(codeRichText)); + span = _spanWithText(spans, 'hunter2'); + expect(span.style?.color, isNot(Colors.transparent)); + expect(span.semanticsLabel, isNull); + }); + }); +} + +/// Helper to create a facet with arbitrary features over a substring +RichTextFacet _facetOver({ + required String fullText, + required String span, + required List features, +}) { + final charStart = fullText.indexOf(span); + if (charStart == -1) { + throw ArgumentError('span "$span" not found in fullText'); + } + final charEnd = charStart + span.length; + + return RichTextFacet( + index: ByteSlice( + byteStart: _byteLen(fullText.substring(0, charStart)), + byteEnd: _byteLen(fullText.substring(0, charEnd)), + ), + features: features, + ); }