Main coves client
1// Rich text facet models for Coves
2//
3// Facets represent structured metadata about text segments, such as links,
4// mentions, or hashtags. They use byte indices (UTF-8) rather than character
5// indices (UTF-16) to ensure cross-platform compatibility with the backend.
6
7import 'package:flutter/foundation.dart';
8
9/// Byte range for a text segment
10///
11/// Uses UTF-8 byte offsets, not UTF-16 character positions.
12/// This is crucial for proper alignment with the backend, especially
13/// when text contains emoji or other multi-byte characters.
14class ByteSlice {
15 const ByteSlice({
16 required this.byteStart,
17 required this.byteEnd,
18 }) : assert(byteStart >= 0, 'byteStart must be non-negative'),
19 assert(byteEnd >= byteStart, 'byteEnd must be >= byteStart');
20
21 factory ByteSlice.fromJson(Map<String, dynamic> json) {
22 final start = json['byteStart'];
23 final end = json['byteEnd'];
24
25 if (start == null || start is! int) {
26 throw const FormatException(
27 'ByteSlice: Required field "byteStart" is missing or invalid',
28 );
29 }
30
31 if (end == null || end is! int) {
32 throw const FormatException(
33 'ByteSlice: Required field "byteEnd" is missing or invalid',
34 );
35 }
36
37 if (start < 0 || end < 0 || end < start) {
38 throw FormatException(
39 'ByteSlice: Invalid byte range [$start, $end)',
40 );
41 }
42
43 return ByteSlice(
44 byteStart: start,
45 byteEnd: end,
46 );
47 }
48
49 /// Start byte position (inclusive)
50 final int byteStart;
51
52 /// End byte position (exclusive)
53 final int byteEnd;
54
55 /// Convert to JSON
56 Map<String, dynamic> toJson() {
57 return {
58 'byteStart': byteStart,
59 'byteEnd': byteEnd,
60 };
61 }
62
63 @override
64 String toString() => 'ByteSlice($byteStart, $byteEnd)';
65
66 @override
67 bool operator ==(Object other) =>
68 identical(this, other) ||
69 other is ByteSlice &&
70 runtimeType == other.runtimeType &&
71 byteStart == other.byteStart &&
72 byteEnd == other.byteEnd;
73
74 @override
75 int get hashCode => Object.hash(byteStart, byteEnd);
76}
77
78/// Base class for facet features
79///
80/// A facet feature describes the semantic meaning of a text segment,
81/// such as a link, mention, or hashtag.
82sealed class FacetFeature {
83 const FacetFeature();
84
85 /// The type identifier for this feature (e.g., "social.coves.richtext.facet#link")
86 String get type;
87
88 /// Convert to JSON
89 Map<String, dynamic> toJson();
90
91 /// Create a FacetFeature from JSON
92 factory FacetFeature.fromJson(Map<String, dynamic> json) {
93 final type = json[r'$type'] as String?;
94
95 if (type == null || type.isEmpty) {
96 return UnknownFacetFeature(data: json);
97 }
98
99 switch (type) {
100 case 'social.coves.richtext.facet#link':
101 final uri = json['uri'];
102 if (uri == null || uri is! String || uri.isEmpty) {
103 throw const FormatException(
104 'LinkFacetFeature: Required field "uri" is missing or invalid',
105 );
106 }
107 return LinkFacetFeature(uri: uri);
108
109 default:
110 // Unknown feature type - preserve for forward compatibility
111 return UnknownFacetFeature(data: json);
112 }
113 }
114}
115
116/// Link facet feature
117class LinkFacetFeature extends FacetFeature {
118 const LinkFacetFeature({required this.uri});
119
120 /// The URI/URL this link points to
121 final String uri;
122
123 @override
124 String get type => 'social.coves.richtext.facet#link';
125
126 @override
127 Map<String, dynamic> toJson() {
128 return {
129 r'$type': type,
130 'uri': uri,
131 };
132 }
133
134 @override
135 String toString() => 'LinkFacetFeature($uri)';
136
137 @override
138 bool operator ==(Object other) =>
139 identical(this, other) ||
140 other is LinkFacetFeature &&
141 runtimeType == other.runtimeType &&
142 uri == other.uri;
143
144 @override
145 int get hashCode => uri.hashCode;
146}
147
148/// Unknown facet feature for forward compatibility
149///
150/// Preserves unknown feature types so they can be round-tripped
151/// through the client without data loss.
152class UnknownFacetFeature extends FacetFeature {
153 const UnknownFacetFeature({required this.data});
154
155 /// Raw JSON data
156 final Map<String, dynamic> data;
157
158 @override
159 String get type => data[r'$type'] as String? ?? 'unknown';
160
161 @override
162 Map<String, dynamic> toJson() => data;
163
164 @override
165 String toString() => 'UnknownFacetFeature($type)';
166
167 @override
168 bool operator ==(Object other) =>
169 identical(this, other) ||
170 other is UnknownFacetFeature &&
171 runtimeType == other.runtimeType &&
172 _mapEquals(data, other.data);
173
174 @override
175 int get hashCode => Object.hashAll(data.entries);
176
177 static bool _mapEquals(Map<String, dynamic> a, Map<String, dynamic> b) {
178 if (a.length != b.length) return false;
179 for (final key in a.keys) {
180 if (!b.containsKey(key) || a[key] != b[key]) return false;
181 }
182 return true;
183 }
184}
185
186/// A rich text facet - metadata about a text segment
187class RichTextFacet {
188 const RichTextFacet({
189 required this.index,
190 required this.features,
191 });
192
193 factory RichTextFacet.fromJson(Map<String, dynamic> json) {
194 final indexData = json['index'];
195 if (indexData == null || indexData is! Map<String, dynamic>) {
196 throw const FormatException(
197 'RichTextFacet: Required field "index" is missing or invalid',
198 );
199 }
200
201 final featuresData = json['features'];
202 if (featuresData == null || featuresData is! List) {
203 throw const FormatException(
204 'RichTextFacet: Required field "features" is missing or invalid',
205 );
206 }
207
208 return RichTextFacet(
209 index: ByteSlice.fromJson(indexData),
210 features: List.unmodifiable(
211 featuresData
212 .whereType<Map<String, dynamic>>()
213 .map(FacetFeature.fromJson)
214 .toList(),
215 ),
216 );
217 }
218
219 /// The byte range this facet applies to
220 final ByteSlice index;
221
222 /// The semantic features of this text segment
223 final List<FacetFeature> features;
224
225 /// Check if this facet contains a link feature
226 bool get hasLink =>
227 features.any((feature) => feature is LinkFacetFeature);
228
229 /// Get the link URI if this facet has a link feature
230 String? get linkUri {
231 for (final feature in features) {
232 if (feature is LinkFacetFeature) {
233 return feature.uri;
234 }
235 }
236 return null;
237 }
238
239 /// Convert to JSON
240 Map<String, dynamic> toJson() {
241 return {
242 'index': index.toJson(),
243 'features': features.map((f) => f.toJson()).toList(),
244 };
245 }
246
247 @override
248 String toString() => 'RichTextFacet($index, ${features.length} features)';
249
250 @override
251 bool operator ==(Object other) =>
252 identical(this, other) ||
253 other is RichTextFacet &&
254 runtimeType == other.runtimeType &&
255 index == other.index &&
256 _listEquals(features, other.features);
257
258 @override
259 int get hashCode => Object.hash(index, Object.hashAll(features));
260
261 static bool _listEquals(List<FacetFeature> a, List<FacetFeature> b) {
262 if (a.length != b.length) return false;
263 for (var i = 0; i < a.length; i++) {
264 if (a[i] != b[i]) return false;
265 }
266 return true;
267 }
268}
269
270/// Parse facets from a record's 'facets' field
271///
272/// Backend returns facets inside `record['facets']` rather than at the top level.
273/// This helper safely extracts and parses them, returning null if missing/invalid.
274///
275/// Note: Parsing failures are logged in debug mode but return null to prevent
276/// a single malformed facet from breaking the entire content. Users will see
277/// plain text instead of rich links when facet parsing fails.
278List<RichTextFacet>? parseFacetsFromRecord(Object? record) {
279 if (record == null || record is! Map<String, dynamic>) {
280 return null;
281 }
282 final facets = record['facets'];
283 if (facets == null || facets is! List) {
284 return null;
285 }
286 try {
287 return List.unmodifiable(
288 facets
289 .whereType<Map<String, dynamic>>()
290 .map(RichTextFacet.fromJson)
291 .toList(),
292 );
293 } on Exception catch (e) {
294 if (kDebugMode) {
295 debugPrint('⚠️ Facet parsing failed: $e');
296 }
297 return null;
298 }
299}