From 9326a5973149bc5133f54b25b8ea001395b560db Mon Sep 17 00:00:00 2001 From: Bretton <36870434+BrettM86@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:17:33 -0700 Subject: [PATCH] feat(richtext): render block-level facets with hardened federated-input handling Client-side support for the new block vocabulary in the social.coves.richtext.facet lexicon (blockquote, heading, code, codeBlock), unblocking the renderers-first step of the block-facet rollout: the tidepool bridge must not strip markdown markers until both renderers can display block structure. Facet model now parses all ten feature types (mention, bold, italic, strikethrough, spoiler, blockquote, heading, code, codeBlock, link) and follows the backend's degradation philosophy: unknown $types and known types with invalid attributes render as plain text instead of throwing, and parseFacetsFromRecord drops malformed facets individually so one bad entry never strips rich text from a record. RichTextRenderer rewritten around the lexicon's wire conventions: - Full mode segments text into real blocks: quote bars per nesting level, scaled headings, monospace code cards with language labels and horizontal scroll; malformed mid-line ranges extend to line boundaries (with leading/trailing-newline back-off); cross-type containment (codeBlock inside blockquote) recurses. - Compact mode (maxLines set, feed previews) keeps one Text.rich so ellipsis works, approximating block features as character styles. - Overlapping inline facets compose via style-run splitting instead of being dropped; earlier-starting facet wins taps in an overlap. - Spoilers redact until tapped, with semantics labels so screen readers do not speak concealed text (including inside code blocks). - Mentions navigate to profile/community by DID. Hardening from an 8-stream multi-model review (all Critical/Important findings fixed): - Non-string \$type no longer throws a TypeError past the on-Exception guards (previously a one-poisoned-record stuck-spinner feed DoS); _fetchFeed clears loading flags in a finally and CommentsResponse gained per-item fault isolation. - Mention DIDs validated against DID syntax before any tap handler, closing a verified /profile/../login route-traversal primitive. - Client-side caps mirror the backend sanitizer (200 facets, 20 features/facet) against pre-sanitizer federated records; byte-to-char resolution encodes the text once per resolve and is cached across rebuilds; oversized byteEnd drops the facet instead of styling to end-of-text. Tests: +76 cases across model and renderer suites, including a GoRouter mention-navigation harness, emoji + block-facet offsets, straddling and quote-in-quote degradation, and spoiler/link tap precedence. Full suite: 938 passing; flutter analyze introduces no new findings. Co-Authored-By: Claude Fable 5 --- lib/models/comment.dart | 24 +- lib/models/facet.dart | 400 ++++++- lib/providers/multi_feed_provider.dart | 16 +- lib/widgets/rich_text_renderer.dart | 914 ++++++++++++++-- test/models/comment_test.dart | 54 + test/models/facet_test.dart | 429 ++++++-- test/widgets/rich_text_renderer_test.dart | 1156 ++++++++++++++++++++- 7 files changed, 2781 insertions(+), 212 deletions(-) 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, + ); } -- 2.51.2