[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: links in bluesky crossposts

+183 -49
+2 -1
lib/src/core/design_system/templates/image_review_page_template.dart
··· 473 473 Expanded( 474 474 child: Text( 475 475 'Bluesky supports a maximum of 4 images. ' 476 - 'Your Bluesky post will link to the Spark post instead.', 476 + 'Your crosspost will include the first 4 and link to the ' 477 + 'full Spark post.', 477 478 style: AppTypography.textSmallMedium.copyWith( 478 479 color: AppColors.rajah500, 479 480 ),
+3 -1
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:atproto_core/atproto_core.dart'; 2 4 import 'package:bluesky/app_bsky_embed_images.dart'; 3 5 import 'package:bluesky/app_bsky_feed_defs.dart' as bsky_defs; ··· 471 473 return RichtextFacet( 472 474 index: RichtextFacetByteSlice( 473 475 byteStart: byteStart, 474 - byteEnd: byteStart + linkUrl.length, 476 + byteEnd: byteStart + utf8.encode(linkUrl).length, 475 477 ), 476 478 features: [ 477 479 URichtextFacetFeatures.richtextFacetLink(
+20 -45
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 19 19 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 20 20 import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; 21 21 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 22 + import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; 22 23 import 'package:spark/src/core/utils/logging/log_service.dart'; 23 24 import 'package:spark/src/core/utils/logging/logger.dart'; 24 25 import 'package:spark/src/core/utils/share_urls.dart'; ··· 1332 1333 1333 1334 const maxBskyImages = 4; 1334 1335 1335 - // Use adapter to convert Spark images to Bluesky images 1336 - final allBskyImages = bskyFeedAdapter.convertImages(sparkImages); 1337 - final bskyImages = allBskyImages.take(maxBskyImages).toList(); 1336 + final bskyImages = bskyFeedAdapter.convertImages( 1337 + sparkImages.take(maxBskyImages).toList(), 1338 + ); 1338 1339 1339 - // Determine if we need to add a link to the Spark post 1340 - String? linkUrl; 1341 1340 final bskyFacets = <RichtextFacet>[]; 1341 + final linkUrl = buildSparkShareUrl(sparkPostData.uri.toString()); 1342 + final crosspostText = buildBlueskyCrosspostText( 1343 + text: text, 1344 + linkUrl: linkUrl, 1345 + ); 1342 1346 1343 1347 // Convert Spark mention facets to Bluesky mention facets 1344 1348 for (final facet in sparkFacets) { 1349 + if (facet.index.byteEnd > crosspostText.facetTextByteEnd) { 1350 + continue; 1351 + } 1352 + 1345 1353 for (final feature in facet.features) { 1346 1354 feature.map( 1347 1355 mention: (m) { ··· 1362 1370 } 1363 1371 } 1364 1372 1365 - if (sparkImages.length > maxBskyImages) { 1366 - final sparkRkey = sparkPostData.uri.rkey; 1367 - final uriDid = sparkPostData.uri.hostname; 1368 - linkUrl = buildSparkShareUrl('$uriDid/$sparkRkey'); 1369 - } 1370 - 1371 - // Prepare text and facets for Bluesky post 1372 - final finalText = _prepareTextWithLink(text: text, linkUrl: linkUrl); 1373 - 1374 - if (linkUrl != null) { 1375 - final linkStart = text.isEmpty ? 0 : text.length; 1376 - bskyFacets.add( 1377 - bskyFeedAdapter.createLinkFacet(linkUrl: linkUrl, byteStart: linkStart), 1378 - ); 1379 - } 1373 + bskyFacets.add( 1374 + bskyFeedAdapter.createLinkFacet( 1375 + linkUrl: linkUrl, 1376 + byteStart: crosspostText.linkByteStart, 1377 + ), 1378 + ); 1380 1379 1381 1380 final bskyPost = bskyFeedAdapter.createPostRecord( 1382 - text: finalText, 1381 + text: crosspostText.text, 1383 1382 createdAt: DateTime.now().toUtc(), 1384 1383 images: bskyImages, 1385 1384 facets: bskyFacets.isNotEmpty ? bskyFacets : null, ··· 1393 1392 1394 1393 _logger.i('Successfully crossposted to Bluesky: ${bskyResult.uri}'); 1395 1394 return bskyResult; 1396 - } 1397 - 1398 - /// Prepare text for Bluesky post, handling link addition and truncation 1399 - String _prepareTextWithLink({required String text, String? linkUrl}) { 1400 - if (linkUrl == null) { 1401 - return text; 1402 - } 1403 - 1404 - if (text.isEmpty) { 1405 - return linkUrl; 1406 - } 1407 - 1408 - final linkWithNewlines = '\n\n$linkUrl'; 1409 - const maxTextLength = 300; 1410 - final availableTextLength = maxTextLength - linkWithNewlines.length; 1411 - 1412 - if (text.length <= availableTextLength) { 1413 - return '$text$linkWithNewlines'; 1414 - } else { 1415 - const ellipsis = '...'; 1416 - final croppedTextLength = availableTextLength - ellipsis.length; 1417 - final croppedText = text.substring(0, croppedTextLength); 1418 - return '$croppedText$ellipsis$linkWithNewlines'; 1419 - } 1420 1395 } 1421 1396 1422 1397 @override
+71
lib/src/core/utils/bluesky_crosspost_text.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:math' as math; 3 + 4 + const _maxBlueskyPostLength = 300; 5 + const _linkSeparator = '\n\n'; 6 + const _ellipsis = '...'; 7 + 8 + class BlueskyCrosspostText { 9 + const BlueskyCrosspostText({ 10 + required this.text, 11 + required this.facetTextByteEnd, 12 + required this.linkByteStart, 13 + }); 14 + 15 + final String text; 16 + final int facetTextByteEnd; 17 + final int linkByteStart; 18 + } 19 + 20 + BlueskyCrosspostText buildBlueskyCrosspostText({ 21 + required String text, 22 + required String linkUrl, 23 + }) { 24 + if (text.isEmpty) { 25 + return BlueskyCrosspostText( 26 + text: linkUrl, 27 + facetTextByteEnd: 0, 28 + linkByteStart: 0, 29 + ); 30 + } 31 + 32 + final suffix = '$_linkSeparator$linkUrl'; 33 + final availableTextLength = _maxBlueskyPostLength - suffix.length; 34 + if (availableTextLength <= 0) { 35 + return BlueskyCrosspostText( 36 + text: linkUrl, 37 + facetTextByteEnd: 0, 38 + linkByteStart: 0, 39 + ); 40 + } 41 + 42 + final body = _buildCrosspostBody(text, availableTextLength); 43 + final bodyText = body.text; 44 + final linkPrefix = '$bodyText$_linkSeparator'; 45 + 46 + return BlueskyCrosspostText( 47 + text: '$linkPrefix$linkUrl', 48 + facetTextByteEnd: body.facetTextByteEnd, 49 + linkByteStart: utf8.encode(linkPrefix).length, 50 + ); 51 + } 52 + 53 + ({String text, int facetTextByteEnd}) _buildCrosspostBody( 54 + String text, 55 + int maxLength, 56 + ) { 57 + if (text.length <= maxLength) { 58 + return (text: text, facetTextByteEnd: utf8.encode(text).length); 59 + } 60 + 61 + if (maxLength <= _ellipsis.length) { 62 + return (text: _ellipsis.substring(0, maxLength), facetTextByteEnd: 0); 63 + } 64 + 65 + final croppedTextLength = math.max(0, maxLength - _ellipsis.length); 66 + final croppedText = text.substring(0, croppedTextLength); 67 + return ( 68 + text: '$croppedText$_ellipsis', 69 + facetTextByteEnd: utf8.encode(croppedText).length, 70 + ); 71 + }
+23 -2
lib/src/features/posting/providers/video_upload_provider.dart
··· 5 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 6 import 'package:spark/src/core/network/atproto/atproto.dart'; 7 7 import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 8 + import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; 8 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 10 + import 'package:spark/src/core/utils/share_urls.dart'; 9 11 10 12 part 'video_upload_provider.g.dart'; 11 13 ··· 187 189 final logger = GetIt.I<LogService>().getLogger('Crosspost Video') 188 190 ..d('Crossposting video to Bluesky'); 189 191 192 + final sprkRepository = GetIt.I<SprkRepository>(); 193 + final sparkDid = sprkRepository.authRepository.did; 194 + if (sparkDid == null) { 195 + throw Exception('User session DID not available'); 196 + } 197 + final linkUrl = buildSparkShareUrl('$sparkDid/$rkey'); 198 + final crosspostText = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); 199 + 190 200 final bskyFacets = <RichtextFacet>[]; 191 201 for (final facet in sparkFacets) { 202 + if (facet.index.byteEnd > crosspostText.facetTextByteEnd) { 203 + continue; 204 + } 205 + 192 206 for (final feature in facet.features) { 193 207 feature.map( 194 208 mention: (m) { ··· 217 231 } 218 232 } 219 233 234 + bskyFacets.add( 235 + _bskyFeedAdapter.createLinkFacet( 236 + linkUrl: linkUrl, 237 + byteStart: crosspostText.linkByteStart, 238 + ), 239 + ); 240 + 220 241 final bskyPostRecord = <String, dynamic>{ 221 242 r'$type': 'app.bsky.feed.post', 222 - 'text': text, 243 + 'text': crosspostText.text, 223 244 if (bskyFacets.isNotEmpty) 224 245 'facets': bskyFacets.map((facet) => facet.toJson()).toList(), 225 246 'embed': { ··· 230 251 'createdAt': DateTime.now().toUtc().toIso8601String(), 231 252 }; 232 253 233 - final result = await GetIt.I<SprkRepository>().repo.createRecord( 254 + final result = await sprkRepository.repo.createRecord( 234 255 collection: 'app.bsky.feed.post', 235 256 record: bskyPostRecord, 236 257 rkey: rkey,
+64
test/src/core/utils/bluesky_crosspost_text_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; 5 + 6 + void main() { 7 + group('buildBlueskyCrosspostText', () { 8 + test('uses only the link when source text is empty', () { 9 + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; 10 + 11 + final result = buildBlueskyCrosspostText(text: '', linkUrl: linkUrl); 12 + 13 + expect(result.text, linkUrl); 14 + expect(result.facetTextByteEnd, 0); 15 + expect(result.linkByteStart, 0); 16 + }); 17 + 18 + test('adds the link after a paragraph break', () { 19 + const text = 'hello spark'; 20 + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; 21 + 22 + final result = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); 23 + 24 + expect(result.text, '$text\n\n$linkUrl'); 25 + expect(result.facetTextByteEnd, utf8.encode(text).length); 26 + expect(result.linkByteStart, utf8.encode('$text\n\n').length); 27 + }); 28 + 29 + test('uses a byte offset for non-ascii text before the link', () { 30 + const text = 'spark sun'; 31 + const decoratedText = '$text \u2600'; 32 + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; 33 + 34 + final result = buildBlueskyCrosspostText( 35 + text: decoratedText, 36 + linkUrl: linkUrl, 37 + ); 38 + 39 + expect(result.text, '$decoratedText\n\n$linkUrl'); 40 + expect(result.facetTextByteEnd, utf8.encode(decoratedText).length); 41 + expect(result.linkByteStart, utf8.encode('$decoratedText\n\n').length); 42 + expect(result.linkByteStart, isNot(result.text.indexOf(linkUrl))); 43 + }); 44 + 45 + test('truncates long source text while preserving the link', () { 46 + final text = 'a' * 400; 47 + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; 48 + 49 + final result = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); 50 + 51 + expect(result.text.length, 300); 52 + expect(result.text, endsWith('\n\n$linkUrl')); 53 + expect(result.text, contains('...')); 54 + expect( 55 + result.facetTextByteEnd, 56 + utf8.encode(result.text.split('...').first).length, 57 + ); 58 + expect( 59 + result.linkByteStart, 60 + utf8.encode(result.text.split(linkUrl).first).length, 61 + ); 62 + }); 63 + }); 64 + }