+5
-31
lib/providers/profile_provider.dart
+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
+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
+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
+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(