fix: refactor facet processing into FacetUtils for reuse in FacetedText and FacetedTextField, fix issue where links weren't rendering properly

Changed files
+247 -169
lib
+5 -31
lib/providers/profile_provider.dart
··· 22 22 return _fetchProfile(did); 23 23 } 24 24 25 - // @TODO: Facets don't always render correctly. 26 - List<Map<String, dynamic>>? _filterValidFacets( 27 - List<Map<String, dynamic>>? computedFacets, 28 - String desc, 29 - ) { 30 - if (computedFacets == null) return null; 31 - return computedFacets.where((facet) { 32 - final index = facet['index']; 33 - if (index is Map) { 34 - final start = index['byteStart'] ?? 0; 35 - final end = index['byteEnd'] ?? 0; 36 - return start is int && end is int && start >= 0 && end > start && end <= desc.length; 37 - } 38 - final start = facet['index'] ?? facet['offset'] ?? 0; 39 - final end = facet['end']; 40 - final length = facet['length']; 41 - if (end is int && start is int) { 42 - return start >= 0 && end > start && end <= desc.length; 43 - } else if (length is int && start is int) { 44 - return start >= 0 && length > 0 && start + length <= desc.length; 45 - } 46 - return false; 47 - }).toList(); 48 - } 49 - 50 - // Extract facet computation and filtering for reuse 51 - Future<List<Map<String, dynamic>>?> computeAndFilterFacets(String? description) async { 25 + // Extract facets 26 + Future<List<Map<String, dynamic>>?> _extractFacets(String? description) async { 52 27 final desc = description ?? ''; 53 28 if (desc.isEmpty) return null; 54 29 try { 55 30 final blueskyText = BlueskyText(desc); 56 31 final entities = blueskyText.entities; 57 - final computedFacets = await entities.toFacets(); 58 - return _filterValidFacets(computedFacets, desc); 32 + return entities.toFacets(); 59 33 } catch (_) { 60 34 return null; 61 35 } ··· 66 40 final galleries = await apiService.fetchActorGalleries(did: did); 67 41 final favs = await apiService.getActorFavs(did: did); 68 42 if (profile != null) { 69 - final facets = await computeAndFilterFacets(profile.description); 43 + final facets = await _extractFacets(profile.description); 70 44 return ProfileWithGalleries( 71 45 profile: profile.copyWith(descriptionFacets: facets), 72 46 galleries: galleries, ··· 108 82 final updated = await apiService.fetchProfile(did: did); 109 83 if (updated != null) { 110 84 final galleries = await apiService.fetchActorGalleries(did: did); 111 - final facets = await computeAndFilterFacets(updated.description); 85 + final facets = await _extractFacets(updated.description); 112 86 ref.read(galleryCacheProvider.notifier).setGalleriesForActor(did, galleries); 113 87 state = AsyncValue.data( 114 88 ProfileWithGalleries(
+216
lib/utils/facet_utils.dart
··· 1 + import 'package:flutter/gestures.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + class FacetRange { 5 + final int start; 6 + final int end; 7 + final String? type; 8 + final Map<String, dynamic> data; 9 + 10 + FacetRange({required this.start, required this.end, required this.type, required this.data}); 11 + } 12 + 13 + class ProcessedSpan { 14 + final int start; 15 + final int end; 16 + final TextSpan span; 17 + 18 + ProcessedSpan({required this.start, required this.end, required this.span}); 19 + } 20 + 21 + class FacetUtils { 22 + /// Processes facets and returns a list of TextSpans with proper highlighting 23 + static List<TextSpan> processFacets({ 24 + required String text, 25 + required List<Map<String, dynamic>>? facets, 26 + required TextStyle? defaultStyle, 27 + required TextStyle? linkStyle, 28 + void Function(String did)? onMentionTap, 29 + void Function(String url)? onLinkTap, 30 + void Function(String tag)? onTagTap, 31 + }) { 32 + if (facets == null || facets.isEmpty) { 33 + return [TextSpan(text: text, style: defaultStyle)]; 34 + } 35 + 36 + // Build a list of all ranges (start, end, type, data) 37 + final List<FacetRange> ranges = facets.map((facet) { 38 + final feature = facet['features']?[0] ?? {}; 39 + final type = feature['\$type'] ?? feature['type']; 40 + return FacetRange( 41 + start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 42 + end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 43 + type: type, 44 + data: feature, 45 + ); 46 + }).toList(); 47 + 48 + // Sort ranges by the length of their display text (longest first) to avoid overlap issues 49 + ranges.sort((a, b) { 50 + int aLength = a.end - a.start; 51 + int bLength = b.end - b.start; 52 + 53 + // For links, use the display text length 54 + if (a.type?.contains('link') == true && a.data['uri'] != null) { 55 + String displayText = a.data['uri'] as String; 56 + displayText = _extractDisplayTextFromUri(displayText); 57 + aLength = displayText.length; 58 + } 59 + 60 + if (b.type?.contains('link') == true && b.data['uri'] != null) { 61 + String displayText = b.data['uri'] as String; 62 + displayText = _extractDisplayTextFromUri(displayText); 63 + bLength = displayText.length; 64 + } 65 + 66 + // Sort by length descending, then by start position ascending 67 + final lengthComparison = bLength.compareTo(aLength); 68 + return lengthComparison != 0 ? lengthComparison : a.start.compareTo(b.start); 69 + }); 70 + 71 + final List<ProcessedSpan> processedSpans = <ProcessedSpan>[]; 72 + final Set<int> usedPositions = <int>{}; // Track which character positions are already used 73 + 74 + for (final range in ranges) { 75 + // For links, we need to find the actual text in the original text 76 + // since the facet positions might be based on the full URL with protocol 77 + String? actualContent; 78 + int actualStart = range.start; 79 + int actualEnd = range.end; 80 + 81 + if (range.type?.contains('link') == true && range.data['uri'] != null) { 82 + final uri = range.data['uri'] as String; 83 + final displayText = _extractDisplayTextFromUri(uri); 84 + 85 + // Find all occurrences of this text and pick the one that doesn't overlap with used positions 86 + int searchIndex = 0; 87 + bool foundValidMatch = false; 88 + 89 + while (!foundValidMatch) { 90 + final globalIndex = text.indexOf(displayText, searchIndex); 91 + if (globalIndex == -1) break; 92 + 93 + // Check if this range overlaps with any used positions 94 + bool overlaps = false; 95 + for (int i = globalIndex; i < globalIndex + displayText.length; i++) { 96 + if (usedPositions.contains(i)) { 97 + overlaps = true; 98 + break; 99 + } 100 + } 101 + 102 + if (!overlaps) { 103 + actualStart = globalIndex; 104 + actualEnd = globalIndex + displayText.length; 105 + actualContent = displayText; 106 + foundValidMatch = true; 107 + 108 + // Mark these positions as used 109 + for (int i = actualStart; i < actualEnd; i++) { 110 + usedPositions.add(i); 111 + } 112 + } else { 113 + searchIndex = globalIndex + 1; 114 + } 115 + } 116 + } 117 + 118 + // Handle other facet types that might have similar issues 119 + if (actualContent == null) { 120 + // Verify the range is within bounds 121 + if (range.start >= 0 && range.end <= text.length && range.start < range.end) { 122 + actualContent = text.substring(range.start, range.end); 123 + actualStart = range.start; 124 + actualEnd = range.end; 125 + 126 + // Check if this overlaps with used positions 127 + bool overlaps = false; 128 + for (int i = actualStart; i < actualEnd; i++) { 129 + if (usedPositions.contains(i)) { 130 + overlaps = true; 131 + break; 132 + } 133 + } 134 + 135 + if (!overlaps) { 136 + // Mark these positions as used 137 + for (int i = actualStart; i < actualEnd; i++) { 138 + usedPositions.add(i); 139 + } 140 + } else { 141 + // Skip overlapping ranges 142 + actualContent = null; 143 + } 144 + } else { 145 + // Skip invalid ranges 146 + continue; 147 + } 148 + } 149 + 150 + if (actualContent != null) { 151 + TextSpan span; 152 + if (range.type?.contains('mention') == true && range.data['did'] != null) { 153 + span = TextSpan( 154 + text: actualContent, 155 + style: linkStyle, 156 + recognizer: TapGestureRecognizer() 157 + ..onTap = onMentionTap != null ? () => onMentionTap(range.data['did']) : null, 158 + ); 159 + } else if (range.type?.contains('link') == true && range.data['uri'] != null) { 160 + span = TextSpan( 161 + text: actualContent, 162 + style: linkStyle, 163 + recognizer: TapGestureRecognizer() 164 + ..onTap = onLinkTap != null ? () => onLinkTap(range.data['uri']) : null, 165 + ); 166 + } else if (range.type?.contains('tag') == true && range.data['tag'] != null) { 167 + span = TextSpan( 168 + text: '#${range.data['tag']}', 169 + style: linkStyle, 170 + recognizer: TapGestureRecognizer() 171 + ..onTap = onTagTap != null ? () => onTagTap(range.data['tag']) : null, 172 + ); 173 + } else { 174 + span = TextSpan(text: actualContent, style: defaultStyle); 175 + } 176 + 177 + processedSpans.add(ProcessedSpan(start: actualStart, end: actualEnd, span: span)); 178 + } 179 + } 180 + 181 + // Sort processed spans by position and build final spans list 182 + processedSpans.sort((a, b) => a.start.compareTo(b.start)); 183 + int pos = 0; 184 + final spans = <TextSpan>[]; 185 + 186 + for (final processedSpan in processedSpans) { 187 + if (processedSpan.start > pos) { 188 + spans.add(TextSpan(text: text.substring(pos, processedSpan.start), style: defaultStyle)); 189 + } 190 + spans.add(processedSpan.span); 191 + pos = processedSpan.end; 192 + } 193 + 194 + if (pos < text.length) { 195 + spans.add(TextSpan(text: text.substring(pos), style: defaultStyle)); 196 + } 197 + 198 + return spans; 199 + } 200 + 201 + /// Extracts the display text from a URI (removes protocol but keeps subdomain, removes path) 202 + static String _extractDisplayTextFromUri(String uri) { 203 + String displayText = uri; 204 + if (uri.startsWith('https://')) { 205 + displayText = uri.substring(8); 206 + } else if (uri.startsWith('http://')) { 207 + displayText = uri.substring(7); 208 + } 209 + // Remove path but keep subdomain (everything before the first slash after protocol) 210 + final slashIndex = displayText.indexOf('/'); 211 + if (slashIndex != -1) { 212 + displayText = displayText.substring(0, slashIndex); 213 + } 214 + return displayText; 215 + } 216 + }
+14 -63
lib/widgets/faceted_text.dart
··· 1 - import 'package:flutter/gestures.dart'; 2 1 import 'package:flutter/material.dart'; 2 + 3 + import '../utils/facet_utils.dart'; 3 4 4 5 class FacetedText extends StatelessWidget { 5 6 final String text; ··· 32 33 fontWeight: FontWeight.w600, 33 34 decoration: TextDecoration.underline, 34 35 ); 36 + 35 37 if (facets == null || facets!.isEmpty) { 36 38 return Text(text, style: defaultStyle); 37 39 } 38 - // Build a list of all ranges (start, end, type, data) 39 - final List<_FacetRange> ranges = facets!.map((facet) { 40 - final feature = facet['features']?[0] ?? {}; 41 - final type = feature['\$type'] ?? feature['type']; 42 - return _FacetRange( 43 - start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 44 - end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 45 - type: type, 46 - data: feature, 47 - ); 48 - }).toList(); 49 - ranges.sort((a, b) => a.start.compareTo(b.start)); 50 - int pos = 0; 51 - final spans = <TextSpan>[]; 52 - for (final range in ranges) { 53 - if (range.start > pos) { 54 - spans.add(TextSpan(text: text.substring(pos, range.start), style: defaultStyle)); 55 - } 56 - final content = text.substring(range.start, range.end); 57 - if (range.type?.contains('mention') == true && range.data['did'] != null) { 58 - spans.add( 59 - TextSpan( 60 - text: content, 61 - style: defaultLinkStyle, 62 - recognizer: TapGestureRecognizer() 63 - ..onTap = onMentionTap != null ? () => onMentionTap!(range.data['did']) : null, 64 - ), 65 - ); 66 - } else if (range.type?.contains('link') == true && range.data['uri'] != null) { 67 - spans.add( 68 - TextSpan( 69 - text: content, 70 - style: defaultLinkStyle, 71 - recognizer: TapGestureRecognizer() 72 - ..onTap = onLinkTap != null ? () => onLinkTap!(range.data['uri']) : null, 73 - ), 74 - ); 75 - } else if (range.type?.contains('tag') == true && range.data['tag'] != null) { 76 - spans.add( 77 - TextSpan( 78 - text: '#${range.data['tag']}', 79 - style: defaultLinkStyle, 80 - recognizer: TapGestureRecognizer() 81 - ..onTap = onTagTap != null ? () => onTagTap!(range.data['tag']) : null, 82 - ), 83 - ); 84 - } else { 85 - spans.add(TextSpan(text: content, style: defaultStyle)); 86 - } 87 - pos = range.end; 88 - } 89 - if (pos < text.length) { 90 - spans.add(TextSpan(text: text.substring(pos), style: defaultStyle)); 91 - } 40 + 41 + final spans = FacetUtils.processFacets( 42 + text: text, 43 + facets: facets, 44 + defaultStyle: defaultStyle, 45 + linkStyle: defaultLinkStyle, 46 + onMentionTap: onMentionTap, 47 + onLinkTap: onLinkTap, 48 + onTagTap: onTagTap, 49 + ); 50 + 92 51 return RichText(text: TextSpan(children: spans)); 93 52 } 94 53 } 95 - 96 - class _FacetRange { 97 - final int start; 98 - final int end; 99 - final String? type; 100 - final Map<String, dynamic> data; 101 - _FacetRange({required this.start, required this.end, required this.type, required this.data}); 102 - }
+12 -75
lib/widgets/faceted_text_field.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:bluesky_text/bluesky_text.dart'; 4 - import 'package:flutter/gestures.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 6 8 7 import '../models/profile.dart'; 9 8 import '../providers/actor_search_provider.dart'; 10 - 11 - class _FacetRange { 12 - final int start; 13 - final int end; 14 - final String? type; 15 - final Map<String, dynamic> data; 16 - _FacetRange({required this.start, required this.end, required this.type, required this.data}); 17 - } 9 + import '../utils/facet_utils.dart'; 18 10 19 11 class FacetedTextField extends ConsumerStatefulWidget { 20 12 final String? label; ··· 409 401 final theme = Theme.of(context); 410 402 final text = widget.controller.text; 411 403 final baseStyle = theme.textTheme.bodyMedium?.copyWith(fontSize: 15); 404 + final linkStyle = baseStyle?.copyWith(color: theme.colorScheme.primary); 412 405 413 - final List<InlineSpan> spans = []; 414 - final List<_FacetRange> ranges = _parsedFacets.map((facet) { 415 - final feature = facet['features']?[0] ?? {}; 416 - final type = feature['\$type'] ?? feature['type']; 417 - final data = Map<String, dynamic>.from(feature); 418 - if (type?.contains('link') == true || type == 'app.bsky.richtext.facet#link') { 419 - data['uri'] = feature['uri'] ?? facet['uri']; 420 - } 421 - if (type?.contains('tag') == true || type == 'app.bsky.richtext.facet#tag') { 422 - data['tag'] = feature['tag'] ?? facet['tag']; 423 - } 424 - return _FacetRange( 425 - start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 426 - end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 427 - type: type, 428 - data: data, 429 - ); 430 - }).toList(); 431 - ranges.sort((a, b) => a.start.compareTo(b.start)); 432 - int pos = 0; 433 - final textLength = text.length; 434 - for (final range in ranges) { 435 - final safeStart = range.start.clamp(0, textLength); 436 - final safeEnd = range.end.clamp(0, textLength); 437 - if (safeStart > pos) { 438 - spans.add(TextSpan(text: text.substring(pos, safeStart), style: baseStyle)); 439 - } 440 - if (safeEnd > safeStart) { 441 - final content = text.substring(safeStart, safeEnd); 442 - if ((range.type?.contains('mention') == true || 443 - range.type == 'app.bsky.richtext.facet#mention') && 444 - range.data['did'] != null) { 445 - spans.add( 446 - TextSpan( 447 - text: content, 448 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 449 - recognizer: TapGestureRecognizer()..onTap = () => _onMentionTap(range.data['did']), 450 - ), 451 - ); 452 - } else if ((range.type?.contains('link') == true || 453 - range.type == 'app.bsky.richtext.facet#link') && 454 - range.data['uri'] != null) { 455 - spans.add( 456 - TextSpan( 457 - text: content, 458 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 459 - ), 460 - ); 461 - } else if ((range.type?.contains('tag') == true || 462 - range.type == 'app.bsky.richtext.facet#tag') && 463 - range.data['tag'] != null) { 464 - spans.add( 465 - TextSpan( 466 - text: content, 467 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 468 - ), 469 - ); 470 - } else { 471 - spans.add(TextSpan(text: content, style: baseStyle)); 472 - } 473 - } 474 - pos = safeEnd; 475 - } 476 - if (pos < text.length) { 477 - spans.add(TextSpan(text: text.substring(pos), style: baseStyle)); 478 - } 406 + // Use the same facet processing logic as FacetedText 407 + final spans = FacetUtils.processFacets( 408 + text: text, 409 + facets: _parsedFacets, 410 + defaultStyle: baseStyle, 411 + linkStyle: linkStyle, 412 + onMentionTap: _onMentionTap, 413 + onLinkTap: null, // No link tap in text field 414 + onTagTap: null, // No tag tap in text field 415 + ); 479 416 return LayoutBuilder( 480 417 builder: (context, constraints) { 481 418 return SizedBox(