[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: mentions

+734 -48
+12 -1
assets/icons/cancel.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="23" height="22" fill="none" viewBox="0 0 23 22"><mask id="b" fill="#fff"><path fill-rule="evenodd" d="M11.88 20.855c-5.443 0-9.855-4.412-9.855-9.855 0-5.442 4.412-9.854 9.855-9.854 5.442 0 9.854 4.412 9.854 9.854 0 5.443-4.412 9.855-9.854 9.855m3.398-11.956a.917.917 0 0 0-1.297-1.297L11.88 9.704 9.778 7.603A.917.917 0 1 0 8.48 8.899L10.583 11 8.48 13.102a.917.917 0 1 0 1.297 1.296l2.101-2.101 2.102 2.102a.917.917 0 0 0 1.297-1.297L13.176 11z" clip-rule="evenodd"/></mask><path fill="#fff" fill-opacity=".2" fill-rule="evenodd" stroke="url(#a)" stroke-width="2" d="M11.88 20.855c-5.443 0-9.855-4.412-9.855-9.855 0-5.442 4.412-9.854 9.855-9.854 5.442 0 9.854 4.412 9.854 9.854 0 5.443-4.412 9.855-9.854 9.855Zm3.398-11.956a.917.917 0 0 0-1.297-1.297L11.88 9.704 9.778 7.603A.917.917 0 1 0 8.48 8.899L10.583 11 8.48 13.102a.917.917 0 1 0 1.297 1.296l2.101-2.101 2.102 2.102a.917.917 0 0 0 1.297-1.297L13.176 11z" clip-rule="evenodd" mask="url(#b)"/><path fill="#fff" d="M13.982 7.603a.916.916 0 0 1 1.296 1.295l-2.102 2.103 2.102 2.101a.916.916 0 1 1-1.296 1.296l-2.103-2.101-2.101 2.101a.916.916 0 1 1-1.296-1.296l2.101-2.101-2.101-2.102a.917.917 0 0 1 1.296-1.296l2.101 2.101z"/><defs><linearGradient id="a" x1="3.799" x2="18.975" y1="3.117" y2="19.081" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".37" stop-color="#fff" stop-opacity=".15"/><stop offset=".721" stop-color="#fff" stop-opacity=".1"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient></defs></svg> 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="23" 4 + height="22" 5 + fill="none" 6 + viewBox="0 0 23 22" 7 + ><path 8 + stroke="#fff" 9 + stroke-linecap="round" 10 + stroke-width="2.5" 11 + d="M4.91 4.03 18.85 17.97M18.85 4.03 4.91 17.97" 12 + /></svg>
+4 -1
lib/src/core/design_system/components/atoms/tags/hashtag.dart
··· 46 46 InteractivePressable( 47 47 onTap: onDeleted, 48 48 borderRadius: BorderRadius.circular(22), 49 - child: AppIcons.cancel(size: 22), 49 + child: Padding( 50 + padding: EdgeInsets.all(3), 51 + child: AppIcons.cancel(size: 16), 52 + ), 50 53 ), 51 54 ], 52 55 ),
+33 -11
lib/src/core/design_system/templates/image_review_page_template.dart
··· 8 8 import 'package:spark/src/core/design_system/tokens/colors.dart'; 9 9 import 'package:spark/src/core/design_system/tokens/shapes.dart'; 10 10 import 'package:spark/src/core/design_system/tokens/typography.dart'; 11 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 12 + import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; 11 13 12 14 /// Design-only template for the Image Review flow. 13 15 /// ··· 28 30 required this.imagesCount, 29 31 required this.maxImages, 30 32 required this.onAddMore, 31 - required this.descriptionController, 32 33 required this.descriptionMaxChars, 33 34 required this.postLabel, 34 35 required this.onPost, ··· 36 37 required this.crossPostValue, 37 38 required this.onCrossPostChanged, 38 39 super.key, 40 + this.descriptionController, 41 + this.mentionController, 42 + this.onMentionsChanged, 39 43 this.showCrossPostWarning = false, 40 44 this.backgroundColor, 41 45 }); ··· 53 57 final int imagesCount; 54 58 final int maxImages; 55 59 final VoidCallback onAddMore; 56 - final TextEditingController descriptionController; 60 + final TextEditingController? descriptionController; 61 + final MentionController? mentionController; 62 + final ValueChanged<List<dynamic>>? onMentionsChanged; 57 63 final int descriptionMaxChars; 58 64 final bool crossPostValue; 59 65 final ValueChanged<bool> onCrossPostChanged; ··· 113 119 const SizedBox(height: 20), 114 120 _DescriptionSection( 115 121 controller: descriptionController, 122 + mentionController: mentionController, 123 + onMentionsChanged: onMentionsChanged, 116 124 maxChars: descriptionMaxChars, 117 125 ), 118 126 const SizedBox(height: 20), ··· 287 295 GestureDetector( 288 296 onTap: () => onRemoveImage(index), 289 297 child: Container( 290 - padding: const EdgeInsets.all(6), 298 + padding: const EdgeInsets.all(8), 291 299 decoration: BoxDecoration( 292 300 shape: BoxShape.circle, 293 301 color: Colors.black.withAlpha(100), ··· 296 304 ), 297 305 ), 298 306 child: AppIcons.cancel( 299 - size: 18, 307 + size: 14, 300 308 color: AppColors.greyWhite, 301 309 ), 302 310 ), ··· 339 347 } 340 348 341 349 class _DescriptionSection extends StatelessWidget { 342 - const _DescriptionSection({required this.controller, required this.maxChars}); 350 + const _DescriptionSection({ 351 + this.controller, 352 + this.mentionController, 353 + this.onMentionsChanged, 354 + required this.maxChars, 355 + }); 343 356 344 - final TextEditingController controller; 357 + final TextEditingController? controller; 358 + final MentionController? mentionController; 359 + final ValueChanged<List<dynamic>>? onMentionsChanged; 345 360 final int maxChars; 346 361 347 362 @override 348 363 Widget build(BuildContext context) { 349 - final count = controller.text.runes.length; 364 + final textController = mentionController?.textController ?? controller; 365 + final count = textController?.text.runes.length ?? 0; 350 366 return Column( 351 367 crossAxisAlignment: CrossAxisAlignment.start, 352 368 children: [ 353 - InputField.search( 354 - controller: controller, 355 - hintText: 'Add a description... (optional)', 356 - ), 369 + if (mentionController != null) 370 + MentionInputField( 371 + controller: mentionController!, 372 + onMentionsChanged: onMentionsChanged ?? (_) {}, 373 + ) 374 + else if (controller != null) 375 + InputField.search( 376 + controller: controller!, 377 + hintText: 'Add a description... (optional)', 378 + ), 357 379 const SizedBox(height: 8), 358 380 Align( 359 381 alignment: Alignment.centerRight,
+31 -9
lib/src/core/design_system/templates/video_review_page_template.dart
··· 5 5 import 'package:spark/src/core/design_system/tokens/colors.dart'; 6 6 import 'package:spark/src/core/design_system/tokens/shapes.dart'; 7 7 import 'package:spark/src/core/design_system/tokens/typography.dart'; 8 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 9 + import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; 8 10 9 11 /// Design-only template for the Video Review flow. 10 12 /// ··· 16 18 required this.onBack, 17 19 required this.videoPreview, 18 20 required this.onAltEdit, 19 - required this.descriptionController, 20 21 required this.descriptionMaxChars, 21 22 required this.postLabel, 22 23 required this.onPost, 23 24 required this.isPosting, 24 25 required this.crossPostValue, 25 26 required this.onCrossPostChanged, 27 + this.descriptionController, 28 + this.mentionController, 29 + this.onMentionsChanged, 26 30 this.showCrossPost = true, 27 31 this.aspectRatio = 1.0, 28 32 this.backgroundColor, ··· 34 38 onBack; // Kept for API symmetry; AppLeadingButton handles back internally. 35 39 final Widget videoPreview; 36 40 final VoidCallback onAltEdit; 37 - final TextEditingController descriptionController; 41 + final TextEditingController? descriptionController; 42 + final MentionController? mentionController; 43 + final ValueChanged<List<dynamic>>? onMentionsChanged; 38 44 final int descriptionMaxChars; 39 45 final bool crossPostValue; 40 46 final ValueChanged<bool> onCrossPostChanged; ··· 80 86 const SizedBox(height: 20), 81 87 _DescriptionSection( 82 88 controller: descriptionController, 89 + mentionController: mentionController, 90 + onMentionsChanged: onMentionsChanged, 83 91 maxChars: descriptionMaxChars, 84 92 ), 85 93 if (showCrossPost) ...[ ··· 192 200 } 193 201 194 202 class _DescriptionSection extends StatelessWidget { 195 - const _DescriptionSection({required this.controller, required this.maxChars}); 203 + const _DescriptionSection({ 204 + this.controller, 205 + this.mentionController, 206 + this.onMentionsChanged, 207 + required this.maxChars, 208 + }); 196 209 197 - final TextEditingController controller; 210 + final TextEditingController? controller; 211 + final MentionController? mentionController; 212 + final ValueChanged<List<dynamic>>? onMentionsChanged; 198 213 final int maxChars; 199 214 200 215 @override 201 216 Widget build(BuildContext context) { 202 - final count = controller.text.runes.length; 217 + final textController = mentionController?.textController ?? controller; 218 + final count = textController?.text.runes.length ?? 0; 203 219 return Column( 204 220 crossAxisAlignment: CrossAxisAlignment.start, 205 221 children: [ 206 - InputField.search( 207 - controller: controller, 208 - hintText: 'Add a description... (optional)', 209 - ), 222 + if (mentionController != null) 223 + MentionInputField( 224 + controller: mentionController!, 225 + onMentionsChanged: onMentionsChanged ?? (_) {}, 226 + ) 227 + else if (controller != null) 228 + InputField.search( 229 + controller: controller!, 230 + hintText: 'Add a description... (optional)', 231 + ), 210 232 const SizedBox(height: 8), 211 233 Align( 212 234 alignment: Alignment.centerRight,
+16
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 479 479 ); 480 480 } 481 481 482 + /// Create a mention facet for Bluesky posts 483 + RichtextFacet createMentionFacet({ 484 + required String did, 485 + required int byteStart, 486 + required int byteEnd, 487 + }) { 488 + return RichtextFacet( 489 + index: RichtextFacetByteSlice(byteStart: byteStart, byteEnd: byteEnd), 490 + features: [ 491 + URichtextFacetFeatures.richtextFacetMention( 492 + data: RichtextFacetMention(did: did), 493 + ), 494 + ], 495 + ); 496 + } 497 + 482 498 // =========================================================================== 483 499 // Bluesky Thread Conversion 484 500 // ===========================================================================
+4
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 140 140 /// [imageFiles] List of image files to attach 141 141 /// [altTexts] Map of file paths to alt texts 142 142 /// [crosspostToBsky] Whether to also post to Bluesky 143 + /// [facets] Optional list of facets for text formatting (mentions, links, etc.) 143 144 Future<RepoStrongRef> postImages( 144 145 String text, 145 146 List<XFile> imageFiles, 146 147 Map<String, String> altTexts, { 147 148 bool crosspostToBsky = false, 149 + List<Facet> facets = const [], 148 150 }); 149 151 150 152 /// Upload images to the server ··· 171 173 /// [tags] The tags of the video 172 174 /// [langs] The languages of the video 173 175 /// [selfLabels] The self labels of the video 176 + /// [facets] Optional list of facets for text formatting (mentions, links, etc.) 174 177 Future<RepoStrongRef> postVideo( 175 178 Blob blob, { 176 179 String text = '', ··· 178 181 List<String>? tags, 179 182 List<String>? langs, 180 183 List<SelfLabel>? selfLabels, 184 + List<Facet> facets = const [], 181 185 }); 182 186 183 187 /// Get the thread for a post
+34 -7
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 21 21 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 22 22 import 'package:spark/src/core/utils/logging/log_service.dart'; 23 23 import 'package:spark/src/core/utils/logging/logger.dart'; 24 + import 'package:spark/src/core/utils/share_urls.dart'; 24 25 25 26 /// Implementation of Feed-related API endpoints 26 27 class FeedRepositoryImpl implements FeedRepository { ··· 955 956 List<XFile> imageFiles, 956 957 Map<String, String> altTexts, { 957 958 bool crosspostToBsky = false, 959 + List<Facet> facets = const [], 958 960 }) async { 959 961 if (imageFiles.isEmpty) { 960 962 _logger.e('No images provided for image post'); ··· 978 980 979 981 // Create Sprk post 980 982 final record = PostRecord( 981 - caption: CaptionRef(text: text, facets: []), 983 + caption: CaptionRef(text: text, facets: facets), 982 984 media: Media.images(images: uploadedImageMaps), 983 985 createdAt: DateTime.now().toUtc(), 984 986 ); ··· 1000 1002 uploadedImageMaps, 1001 1003 result, 1002 1004 altTexts, 1005 + facets, 1003 1006 ); 1004 1007 finalResult = await _client.repo.editRecord( 1005 1008 uri: result.uri, ··· 1298 1301 List<Image> sparkImages, 1299 1302 RepoStrongRef sparkPostData, 1300 1303 Map<String, String> altTexts, 1304 + List<Facet> sparkFacets, 1301 1305 ) async { 1302 1306 _logger.d('Crossposting to Bluesky with ${sparkImages.length} images'); 1303 1307 ··· 1309 1313 1310 1314 // Determine if we need to add a link to the Spark post 1311 1315 String? linkUrl; 1312 - List<RichtextFacet>? facets; 1316 + final bskyFacets = <RichtextFacet>[]; 1317 + 1318 + // Convert Spark mention facets to Bluesky mention facets 1319 + for (final facet in sparkFacets) { 1320 + for (final feature in facet.features) { 1321 + feature.map( 1322 + mention: (m) { 1323 + bskyFacets.add( 1324 + bskyFeedAdapter.createMentionFacet( 1325 + did: m.did, 1326 + byteStart: facet.index.byteStart, 1327 + byteEnd: facet.index.byteEnd, 1328 + ), 1329 + ); 1330 + }, 1331 + link: (_) {}, 1332 + tag: (_) {}, 1333 + bskyMention: (_) {}, 1334 + bskyLink: (_) {}, 1335 + bskyTag: (_) {}, 1336 + ); 1337 + } 1338 + } 1313 1339 1314 1340 if (sparkImages.length > maxBskyImages) { 1315 1341 final sparkRkey = sparkPostData.uri.rkey; 1316 1342 final uriDid = sparkPostData.uri.hostname; 1317 - linkUrl = 'https://watch.sprk.so/?uri=$uriDid/$sparkRkey'; 1343 + linkUrl = buildSparkShareUrl('$uriDid/$sparkRkey'); 1318 1344 } 1319 1345 1320 1346 // Prepare text and facets for Bluesky post ··· 1322 1348 1323 1349 if (linkUrl != null) { 1324 1350 final linkStart = text.isEmpty ? 0 : text.length; 1325 - facets = [ 1351 + bskyFacets.add( 1326 1352 bskyFeedAdapter.createLinkFacet(linkUrl: linkUrl, byteStart: linkStart), 1327 - ]; 1353 + ); 1328 1354 } 1329 1355 1330 1356 final bskyPost = bskyFeedAdapter.createPostRecord( 1331 1357 text: finalText, 1332 1358 createdAt: DateTime.now().toUtc(), 1333 1359 images: bskyImages, 1334 - facets: facets, 1360 + facets: bskyFacets.isNotEmpty ? bskyFacets : null, 1335 1361 ); 1336 1362 1337 1363 final bskyResult = await _client.repo.createRecord( ··· 1390 1416 List<String>? tags, 1391 1417 List<String>? langs, 1392 1418 List<SelfLabel>? selfLabels, 1419 + List<Facet> facets = const [], 1393 1420 }) async { 1394 1421 _logger.d('Posting video with description: $text'); 1395 1422 1396 1423 final record = PostRecord( 1397 - caption: CaptionRef(text: text, facets: []), 1424 + caption: CaptionRef(text: text, facets: facets), 1398 1425 media: Media.video(video: blob, alt: alt), 1399 1426 createdAt: DateTime.now().toUtc(), 1400 1427 langs: langs,
+41
lib/src/core/utils/share_urls.dart
··· 1 + const _sparkShareHost = 'sprk.so'; 2 + const _legacySparkShareHost = 'watch.sprk.so'; 3 + 4 + String normalizeSparkPostUri(String postUri) { 5 + var normalizedPostUri = postUri.trim(); 6 + 7 + if (normalizedPostUri.startsWith('at://')) { 8 + normalizedPostUri = normalizedPostUri.substring(5); 9 + } 10 + 11 + normalizedPostUri = normalizedPostUri.replaceAll('so.sprk.feed.post/', ''); 12 + 13 + return normalizedPostUri; 14 + } 15 + 16 + String buildSparkShareUrl(String postUri) { 17 + final normalizedPostUri = normalizeSparkPostUri(postUri); 18 + 19 + return Uri.https(_sparkShareHost, '/watch', { 20 + 'uri': normalizedPostUri, 21 + }).toString(); 22 + } 23 + 24 + String? extractSparkPostUri(String url) { 25 + try { 26 + final uri = Uri.parse(url); 27 + final postUri = uri.queryParameters['uri']; 28 + final isSparkHost = uri.host == _sparkShareHost && uri.path == '/watch'; 29 + final isLegacySparkHost = uri.host == _legacySparkShareHost; 30 + 31 + if ((isSparkHost || isLegacySparkHost) && 32 + postUri != null && 33 + postUri.isNotEmpty) { 34 + return normalizeSparkPostUri(postUri); 35 + } 36 + } catch (_) { 37 + // Ignore malformed URLs. 38 + } 39 + 40 + return null; 41 + }
+44 -1
lib/src/core/utils/text_formatter.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:flutter/material.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 2 5 import 'package:spark/src/core/ui/foundation/colors.dart'; 3 6 import 'package:spark/src/core/ui/widgets/mentioned_text.dart'; 4 7 ··· 84 87 return urls; 85 88 } 86 89 87 - /// Builds text with clickable @mentions using the MentionedText widget 88 90 static Widget buildTextWithMentions( 89 91 BuildContext context, 90 92 String text, ··· 105 107 fontWeight: FontWeight.bold, 106 108 ), 107 109 ); 110 + } 111 + 112 + /// Converts a character index to a UTF-8 byte index 113 + static int charIndexToByteIndex(String text, int charIndex) { 114 + if (charIndex < 0) return 0; 115 + if (charIndex >= text.length) return utf8.encode(text).length; 116 + return utf8.encode(text.substring(0, charIndex)).length; 117 + } 118 + 119 + /// Converts a UTF-8 byte index to a character index 120 + static int byteIndexToCharIndex(String text, int byteIndex) { 121 + if (byteIndex <= 0) return 0; 122 + final bytes = utf8.encode(text); 123 + if (byteIndex >= bytes.length) return text.length; 124 + 125 + var charIndex = 0; 126 + var byteCount = 0; 127 + for (final rune in text.runes) { 128 + final character = String.fromCharCode(rune); 129 + final charBytes = utf8.encode(character); 130 + if (byteCount + charBytes.length > byteIndex) break; 131 + byteCount += charBytes.length; 132 + charIndex += character.length; 133 + } 134 + return charIndex; 135 + } 136 + 137 + /// Gets the UTF-8 byte length of a string 138 + static int byteLength(String text) => utf8.encode(text).length; 139 + 140 + /// Creates Spark Facet objects from a list of Mention objects 141 + static List<Facet> buildMentionFacets(List<dynamic> mentions) { 142 + return mentions.map((mention) { 143 + return Facet( 144 + index: FacetIndex( 145 + byteStart: mention.byteStart as int, 146 + byteEnd: mention.byteEnd as int, 147 + ), 148 + features: [FacetFeature.mention(did: mention.did as String)], 149 + ); 150 + }).toList(); 108 151 } 109 152 }
+2 -2
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 12 12 import 'package:spark/src/core/ui/widgets/options_panel.dart'; 13 13 import 'package:spark/src/core/ui/widgets/report_dialog.dart'; 14 14 import 'package:spark/src/core/utils/blocking_utils.dart'; 15 + import 'package:spark/src/core/utils/share_urls.dart'; 15 16 import 'package:spark/src/features/feed/providers/feed_action_controller.dart'; 16 17 import 'package:spark/src/features/feed/providers/feed_provider.dart'; 17 18 import 'package:spark/src/features/feed/providers/like_post.dart'; ··· 254 255 if (postUri.startsWith('at://')) { 255 256 postUri = postUri.substring(5); 256 257 } 257 - postUri = postUri.replaceAll('so.sprk.feed.post/', ''); 258 258 259 - return 'https://watch.sprk.so/?uri=$postUri'; 259 + return buildSparkShareUrl(postUri); 260 260 } 261 261 262 262 Future<void> _handleShareLongPress() async {
+2 -10
lib/src/features/messages/ui/widgets/messages_list.dart
··· 15 15 import 'package:spark/src/core/ui/widgets/image_content.dart'; 16 16 import 'package:spark/src/core/ui/widgets/video_content.dart'; 17 17 import 'package:spark/src/core/utils/logging/log_service.dart'; 18 + import 'package:spark/src/core/utils/share_urls.dart'; 18 19 import 'package:spark/src/features/messages/ui/widgets/message_bubble.dart'; 19 20 import 'package:url_launcher/url_launcher.dart'; 20 21 ··· 100 101 101 102 /// Checks if a URL is a sprk.so watch URL and extracts the post URI 102 103 String? extractSprkPostUri(String url) { 103 - try { 104 - final uri = Uri.parse(url); 105 - if (uri.host == 'watch.sprk.so' && 106 - uri.queryParameters.containsKey('uri')) { 107 - return uri.queryParameters['uri']; 108 - } 109 - } catch (e) { 110 - // Invalid URL 111 - } 112 - return null; 104 + return extractSparkPostUri(url); 113 105 } 114 106 115 107 Future<List<Widget>?> validateAndCreateEmbedsFromText(String text) async {
+44
lib/src/features/posting/models/mention.dart
··· 1 + class Mention { 2 + const Mention({ 3 + required this.handle, 4 + required this.did, 5 + required this.byteStart, 6 + required this.byteEnd, 7 + }); 8 + 9 + final String handle; 10 + final String did; 11 + final int byteStart; 12 + final int byteEnd; 13 + 14 + Mention copyWith({ 15 + String? handle, 16 + String? did, 17 + int? byteStart, 18 + int? byteEnd, 19 + }) { 20 + return Mention( 21 + handle: handle ?? this.handle, 22 + did: did ?? this.did, 23 + byteStart: byteStart ?? this.byteStart, 24 + byteEnd: byteEnd ?? this.byteEnd, 25 + ); 26 + } 27 + 28 + @override 29 + bool operator ==(Object other) { 30 + if (identical(this, other)) return true; 31 + return other is Mention && 32 + other.handle == handle && 33 + other.did == did && 34 + other.byteStart == byteStart && 35 + other.byteEnd == byteEnd; 36 + } 37 + 38 + @override 39 + int get hashCode => Object.hash(handle, did, byteStart, byteEnd); 40 + 41 + @override 42 + String toString() => 43 + 'Mention(handle: $handle, did: $did, byteStart: $byteStart, byteEnd: $byteEnd)'; 44 + }
+54
lib/src/features/posting/models/mention_controller.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 3 + import 'package:spark/src/core/utils/text_formatter.dart'; 4 + import 'package:spark/src/features/posting/models/mention.dart'; 5 + 6 + class MentionController extends ChangeNotifier { 7 + MentionController({String text = ''}) 8 + : _textController = TextEditingController(text: text); 9 + 10 + final TextEditingController _textController; 11 + final List<Mention> _mentions = []; 12 + 13 + TextEditingController get textController => _textController; 14 + String get text => _textController.text; 15 + List<Mention> get mentions => List.unmodifiable(_mentions); 16 + 17 + void addMention(Mention mention) { 18 + _mentions.add(mention); 19 + notifyListeners(); 20 + } 21 + 22 + void removeMention(Mention mention) { 23 + _mentions.remove(mention); 24 + notifyListeners(); 25 + } 26 + 27 + void clearMentions() { 28 + _mentions.clear(); 29 + notifyListeners(); 30 + } 31 + 32 + void replaceMentions(List<Mention> mentions) { 33 + _mentions 34 + ..clear() 35 + ..addAll(mentions); 36 + notifyListeners(); 37 + } 38 + 39 + void clear() { 40 + _textController.clear(); 41 + _mentions.clear(); 42 + notifyListeners(); 43 + } 44 + 45 + List<Facet> buildFacets() { 46 + return TextFormatter.buildMentionFacets(_mentions); 47 + } 48 + 49 + @override 50 + void dispose() { 51 + _textController.dispose(); 52 + super.dispose(); 53 + } 54 + }
+42 -1
lib/src/features/posting/providers/video_upload_provider.dart
··· 1 1 import 'package:atproto/core.dart'; 2 + import 'package:bluesky/app_bsky_richtext_facet.dart'; 2 3 import 'package:bluesky/com_atproto_repo_strongref.dart'; 3 4 import 'package:get_it/get_it.dart'; 4 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 6 import 'package:spark/src/core/network/atproto/atproto.dart'; 7 + import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 6 8 import 'package:spark/src/core/utils/logging/log_service.dart'; 7 9 8 10 part 'video_upload_provider.g.dart'; 11 + 12 + const _bskyFeedAdapter = BskyFeedAdapter(); 9 13 10 14 /// Process a video file and upload it to the video service 11 15 @riverpod ··· 41 45 String? videoPath, 42 46 bool crosspostToBsky = false, 43 47 RepoStrongRef? soundRef, 48 + List<Facet> facets = const [], 44 49 }) async { 45 50 final logger = GetIt.I<LogService>().getLogger('Posting Video'); 46 51 try { ··· 52 57 final postRecord = PostRecord( 53 58 caption: CaptionRef( 54 59 text: description.isNotEmpty ? description : '', 55 - facets: [], 60 + facets: facets, 56 61 ), 57 62 media: Media.video(video: blob, alt: altText), 58 63 createdAt: DateTime.now().toUtc(), ··· 74 79 blob, 75 80 altText, 76 81 result.uri.rkey, 82 + facets, 77 83 ); 78 84 finalResult = await GetIt.I<SprkRepository>().repo.editRecord( 79 85 uri: result.uri, ··· 101 107 bool crosspostToBsky = false, 102 108 bool storyMode = false, 103 109 RepoStrongRef? soundRef, 110 + List<Facet> facets = const [], 104 111 }) async { 105 112 final logger = GetIt.I<LogService>().getLogger('Process/Post Video') 106 113 ..d( ··· 157 164 videoPath: videoPath, 158 165 crosspostToBsky: crosspostToBsky, 159 166 soundRef: effectiveSoundRef, 167 + facets: facets, 160 168 ); 161 169 logger.i('Video flow complete (storyMode=false) success=${res != null}'); 162 170 return res; ··· 171 179 Blob blob, 172 180 String altText, 173 181 String rkey, 182 + List<Facet> sparkFacets, 174 183 ) async { 175 184 final logger = GetIt.I<LogService>().getLogger('Crosspost Video') 176 185 ..d('Crossposting video to Bluesky'); 177 186 187 + final bskyFacets = <RichtextFacet>[]; 188 + for (final facet in sparkFacets) { 189 + for (final feature in facet.features) { 190 + feature.map( 191 + mention: (m) { 192 + bskyFacets.add( 193 + _bskyFeedAdapter.createMentionFacet( 194 + did: m.did, 195 + byteStart: facet.index.byteStart, 196 + byteEnd: facet.index.byteEnd, 197 + ), 198 + ); 199 + }, 200 + link: (_) {}, 201 + tag: (_) {}, 202 + bskyMention: (m) { 203 + bskyFacets.add( 204 + _bskyFeedAdapter.createMentionFacet( 205 + did: m.did, 206 + byteStart: facet.index.byteStart, 207 + byteEnd: facet.index.byteEnd, 208 + ), 209 + ); 210 + }, 211 + bskyLink: (_) {}, 212 + bskyTag: (_) {}, 213 + ); 214 + } 215 + } 216 + 178 217 final bskyPostRecord = <String, dynamic>{ 179 218 r'$type': 'app.bsky.feed.post', 180 219 'text': text, 220 + if (bskyFacets.isNotEmpty) 221 + 'facets': bskyFacets.map((facet) => facet.toJson()).toList(), 181 222 'embed': { 182 223 r'$type': 'app.bsky.embed.video', 183 224 'video': blob.toJson(),
+9 -3
lib/src/features/posting/ui/pages/image_review_page.dart
··· 11 11 import 'package:spark/src/core/routing/app_router.dart'; 12 12 import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; 13 13 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 14 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 14 15 import 'package:spark/src/features/posting/providers/post_story.dart'; 15 16 import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; 16 17 ··· 29 30 } 30 31 31 32 class _ImageReviewPageState extends ConsumerState<ImageReviewPage> { 32 - final TextEditingController _descriptionController = TextEditingController(); 33 + final MentionController _descriptionController = MentionController(); 33 34 bool _isPosting = false; 34 35 int _currentPage = 0; 35 36 List<XFile> _imageFiles = []; ··· 63 64 super.initState(); 64 65 _imageFiles = List<XFile>.from(widget.imageFiles); 65 66 _feedRepository = GetIt.I<SprkRepository>().feed; 66 - _descriptionController.addListener(() { 67 + _descriptionController.textController.addListener(() { 67 68 if (mounted) setState(() {}); 68 69 }); 69 70 } ··· 115 116 try { 116 117 final crosspostEnabled = !widget.storyMode && _crosspostToBsky; 117 118 final description = _descriptionController.text; 119 + final facets = _descriptionController.buildFacets(); 118 120 RepoStrongRef result; 119 121 if (widget.storyMode) { 120 122 final uploadedImage = await _feedRepository.uploadImages( ··· 140 142 _imageFiles, 141 143 _altTexts, 142 144 crosspostToBsky: crosspostEnabled, 145 + facets: facets, 143 146 ); 144 147 } 145 148 return result; ··· 179 182 imagesCount: _imageFiles.length, 180 183 maxImages: _maxImages, 181 184 onAddMore: _pickMoreImages, 182 - descriptionController: _descriptionController, 185 + mentionController: _descriptionController, 186 + onMentionsChanged: (mentions) { 187 + // Mentions are automatically tracked in the controller 188 + }, 183 189 descriptionMaxChars: 300, 184 190 crossPostValue: _crosspostToBsky, 185 191 onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v),
+8 -2
lib/src/features/posting/ui/pages/video_review_page.dart
··· 10 10 import 'package:spark/src/core/routing/app_router.dart'; 11 11 import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; 12 12 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 13 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 13 14 import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; 14 15 import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; 15 16 import 'package:video_player/video_player.dart'; ··· 37 38 } 38 39 39 40 class _VideoReviewPageState extends ConsumerState<VideoReviewPage> { 40 - final TextEditingController _descriptionController = TextEditingController(); 41 + final MentionController _descriptionController = MentionController(); 41 42 bool _isPosting = false; 42 43 String _videoAltText = ''; 43 44 bool _crosspostToBsky = false; ··· 89 90 90 91 try { 91 92 final description = _descriptionController.text; 93 + final facets = _descriptionController.buildFacets(); 92 94 93 95 // Process and post the video with the video upload provider 94 96 final postRef = await ref.read( ··· 99 101 storyMode: widget.storyMode, 100 102 soundRef: widget.soundRef, 101 103 crosspostToBsky: !widget.storyMode && _crosspostToBsky, 104 + facets: facets, 102 105 ).future, 103 106 ); 104 107 ··· 153 156 ? const Center(child: CircularProgressIndicator()) 154 157 : VideoPlayer(_player!), 155 158 onAltEdit: _editAltText, 156 - descriptionController: _descriptionController, 159 + mentionController: _descriptionController, 160 + onMentionsChanged: (mentions) { 161 + // Mentions are automatically tracked in the controller 162 + }, 157 163 descriptionMaxChars: 300, 158 164 showCrossPost: !widget.storyMode, 159 165 crossPostValue: _crosspostToBsky,
+354
lib/src/features/posting/ui/widgets/mention_input_field.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; 4 + import 'package:spark/src/core/utils/text_formatter.dart'; 5 + import 'package:spark/src/features/posting/models/mention.dart'; 6 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 7 + import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 8 + 9 + class MentionInputField extends ConsumerStatefulWidget { 10 + const MentionInputField({ 11 + required this.controller, 12 + required this.onMentionsChanged, 13 + this.hintText = 'Add a description... (optional)', 14 + this.maxChars = 300, 15 + super.key, 16 + }); 17 + 18 + final MentionController controller; 19 + final ValueChanged<List<Mention>> onMentionsChanged; 20 + final String hintText; 21 + final int maxChars; 22 + 23 + @override 24 + ConsumerState<MentionInputField> createState() => _MentionInputFieldState(); 25 + } 26 + 27 + class _MentionInputFieldState extends ConsumerState<MentionInputField> { 28 + final FocusNode _focusNode = FocusNode(); 29 + bool _showSuggestions = false; 30 + int? _queryStartIndex; 31 + String _previousText = ''; 32 + 33 + @override 34 + void initState() { 35 + super.initState(); 36 + _previousText = widget.controller.text; 37 + widget.controller.textController.addListener(_onTextChanged); 38 + } 39 + 40 + @override 41 + void dispose() { 42 + widget.controller.textController.removeListener(_onTextChanged); 43 + _focusNode.dispose(); 44 + super.dispose(); 45 + } 46 + 47 + void _onTextChanged() { 48 + final text = widget.controller.text; 49 + if (text != _previousText) { 50 + _syncMentions(previousText: _previousText, currentText: text); 51 + _previousText = text; 52 + } 53 + 54 + final cursorPosition = widget.controller.textController.selection.start; 55 + if (cursorPosition >= 0) { 56 + _detectMentionQuery(text, cursorPosition); 57 + } 58 + } 59 + 60 + void _syncMentions({ 61 + required String previousText, 62 + required String currentText, 63 + }) { 64 + final validMentions = _recalculateMentions( 65 + previousText: previousText, 66 + currentText: currentText, 67 + ); 68 + if (_sameMentions(validMentions, widget.controller.mentions)) { 69 + return; 70 + } 71 + 72 + widget.controller.replaceMentions(validMentions); 73 + widget.onMentionsChanged(validMentions); 74 + } 75 + 76 + List<Mention> _recalculateMentions({ 77 + required String previousText, 78 + required String currentText, 79 + }) { 80 + if (widget.controller.mentions.isEmpty) { 81 + return const []; 82 + } 83 + 84 + final editRange = _computeEditRange(previousText, currentText); 85 + final validMentions = <Mention>[]; 86 + for (final mention in widget.controller.mentions) { 87 + final oldStart = TextFormatter.byteIndexToCharIndex( 88 + previousText, 89 + mention.byteStart, 90 + ); 91 + final mentionToken = '@${mention.handle}'; 92 + var newStart = oldStart; 93 + 94 + if (editRange != null) { 95 + if (editRange.oldEnd <= oldStart) { 96 + newStart += editRange.delta; 97 + } else { 98 + final oldEnd = TextFormatter.byteIndexToCharIndex( 99 + previousText, 100 + mention.byteEnd, 101 + ); 102 + if (editRange.start < oldEnd) { 103 + continue; 104 + } 105 + } 106 + } 107 + 108 + final newEnd = newStart + mentionToken.length; 109 + if (newStart < 0 || newEnd > currentText.length) { 110 + continue; 111 + } 112 + 113 + if (currentText.substring(newStart, newEnd) != mentionToken) { 114 + continue; 115 + } 116 + 117 + validMentions.add( 118 + mention.copyWith( 119 + byteStart: TextFormatter.charIndexToByteIndex(currentText, newStart), 120 + byteEnd: TextFormatter.charIndexToByteIndex(currentText, newEnd), 121 + ), 122 + ); 123 + } 124 + 125 + return validMentions; 126 + } 127 + 128 + ({int start, int oldEnd, int delta})? _computeEditRange( 129 + String previousText, 130 + String currentText, 131 + ) { 132 + if (previousText == currentText) { 133 + return null; 134 + } 135 + 136 + var prefixLength = 0; 137 + final maxPrefix = previousText.length < currentText.length 138 + ? previousText.length 139 + : currentText.length; 140 + while (prefixLength < maxPrefix && 141 + previousText.codeUnitAt(prefixLength) == 142 + currentText.codeUnitAt(prefixLength)) { 143 + prefixLength++; 144 + } 145 + 146 + var previousSuffixStart = previousText.length; 147 + var currentSuffixStart = currentText.length; 148 + while (previousSuffixStart > prefixLength && 149 + currentSuffixStart > prefixLength && 150 + previousText.codeUnitAt(previousSuffixStart - 1) == 151 + currentText.codeUnitAt(currentSuffixStart - 1)) { 152 + previousSuffixStart--; 153 + currentSuffixStart--; 154 + } 155 + 156 + return ( 157 + start: prefixLength, 158 + oldEnd: previousSuffixStart, 159 + delta: 160 + (currentSuffixStart - prefixLength) - 161 + (previousSuffixStart - prefixLength), 162 + ); 163 + } 164 + 165 + bool _sameMentions(List<Mention> next, List<Mention> current) { 166 + if (next.length != current.length) { 167 + return false; 168 + } 169 + 170 + for (var i = 0; i < next.length; i++) { 171 + if (next[i] != current[i]) { 172 + return false; 173 + } 174 + } 175 + 176 + return true; 177 + } 178 + 179 + void _detectMentionQuery(String text, int cursorPosition) { 180 + if (cursorPosition <= 0 || cursorPosition > text.length) { 181 + setState(() { 182 + _showSuggestions = false; 183 + _queryStartIndex = null; 184 + }); 185 + ref.read(actorTypeaheadProvider.notifier).clear(); 186 + return; 187 + } 188 + 189 + var atIndex = -1; 190 + for (var i = cursorPosition - 1; i >= 0; i--) { 191 + final char = text[i]; 192 + if (char == '@') { 193 + atIndex = i; 194 + break; 195 + } 196 + if (char == ' ' || char == '\n') break; 197 + } 198 + 199 + if (atIndex == -1) { 200 + setState(() { 201 + _showSuggestions = false; 202 + _queryStartIndex = null; 203 + }); 204 + ref.read(actorTypeaheadProvider.notifier).clear(); 205 + return; 206 + } 207 + 208 + if (atIndex > 0) { 209 + final prevChar = text[atIndex - 1]; 210 + if (prevChar != ' ' && prevChar != '\n') { 211 + setState(() { 212 + _showSuggestions = false; 213 + _queryStartIndex = null; 214 + }); 215 + ref.read(actorTypeaheadProvider.notifier).clear(); 216 + return; 217 + } 218 + } 219 + 220 + final query = text.substring(atIndex + 1, cursorPosition); 221 + setState(() { 222 + _showSuggestions = query.isNotEmpty; 223 + _queryStartIndex = atIndex; 224 + }); 225 + 226 + if (query.isNotEmpty) { 227 + ref.read(actorTypeaheadProvider.notifier).updateQuery(query); 228 + } 229 + } 230 + 231 + void _onSuggestionSelected(String handle, String did) { 232 + final startIndex = _queryStartIndex; 233 + if (startIndex == null) return; 234 + 235 + final textController = widget.controller.textController; 236 + final text = textController.text; 237 + final cursorPosition = textController.selection.start; 238 + final cursor = cursorPosition >= 0 ? cursorPosition : text.length; 239 + 240 + var endIndex = cursor; 241 + while (endIndex < text.length && 242 + text[endIndex] != ' ' && 243 + text[endIndex] != '\n') { 244 + endIndex++; 245 + } 246 + 247 + final mentionToken = '@$handle'; 248 + final mentionText = '$mentionToken '; 249 + final newText = text.replaceRange(startIndex, endIndex, mentionText); 250 + textController.text = newText; 251 + textController.selection = TextSelection.collapsed( 252 + offset: startIndex + mentionText.length, 253 + ); 254 + 255 + final byteStart = TextFormatter.charIndexToByteIndex(newText, startIndex); 256 + final byteEnd = TextFormatter.charIndexToByteIndex( 257 + newText, 258 + startIndex + mentionToken.length, 259 + ); 260 + 261 + final mention = Mention( 262 + handle: handle, 263 + did: did, 264 + byteStart: byteStart, 265 + byteEnd: byteEnd, 266 + ); 267 + 268 + final dedupedMentions = widget.controller.mentions 269 + .where( 270 + (existing) => 271 + existing.byteStart != mention.byteStart || 272 + existing.byteEnd != mention.byteEnd, 273 + ) 274 + .toList() 275 + ..add(mention); 276 + widget.controller.replaceMentions(dedupedMentions); 277 + widget.onMentionsChanged(widget.controller.mentions); 278 + 279 + ref.read(actorTypeaheadProvider.notifier).clear(); 280 + 281 + setState(() { 282 + _showSuggestions = false; 283 + _queryStartIndex = null; 284 + }); 285 + } 286 + 287 + @override 288 + Widget build(BuildContext context) { 289 + final theme = Theme.of(context); 290 + final typeaheadState = ref.watch(actorTypeaheadProvider); 291 + 292 + return Column( 293 + mainAxisSize: MainAxisSize.min, 294 + children: [ 295 + InputField.search( 296 + controller: widget.controller.textController, 297 + hintText: widget.hintText, 298 + ), 299 + if (_showSuggestions && typeaheadState.results.isNotEmpty) 300 + Container( 301 + constraints: const BoxConstraints(maxHeight: 200), 302 + decoration: BoxDecoration( 303 + color: theme.colorScheme.surface, 304 + border: Border( 305 + top: BorderSide( 306 + color: theme.colorScheme.outline.withAlpha(128), 307 + ), 308 + ), 309 + ), 310 + child: ListView.builder( 311 + shrinkWrap: true, 312 + padding: const EdgeInsets.symmetric(vertical: 8), 313 + itemCount: typeaheadState.results.length, 314 + itemBuilder: (context, index) { 315 + final actor = typeaheadState.results[index]; 316 + return ListTile( 317 + dense: true, 318 + leading: CircleAvatar( 319 + radius: 16, 320 + backgroundImage: actor.avatar != null 321 + ? NetworkImage(actor.avatar.toString()) 322 + : null, 323 + child: actor.avatar == null 324 + ? const Icon(Icons.person, size: 16) 325 + : null, 326 + ), 327 + title: Text( 328 + actor.displayName ?? actor.handle, 329 + style: theme.textTheme.bodyMedium, 330 + ), 331 + subtitle: Text( 332 + '@${actor.handle}', 333 + style: theme.textTheme.bodySmall?.copyWith( 334 + color: theme.colorScheme.onSurfaceVariant, 335 + ), 336 + ), 337 + onTap: () => _onSuggestionSelected(actor.handle, actor.did), 338 + ); 339 + }, 340 + ), 341 + ), 342 + if (_showSuggestions && typeaheadState.isLoading) 343 + const Padding( 344 + padding: EdgeInsets.all(16), 345 + child: SizedBox( 346 + width: 20, 347 + height: 20, 348 + child: CircularProgressIndicator(strokeWidth: 2), 349 + ), 350 + ), 351 + ], 352 + ); 353 + } 354 + }