From ad6ccafaa39d5955e0950558411763834bd87d2c Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Mon, 6 Apr 2026 21:39:35 -0400 Subject: [PATCH] feat: links in bluesky crossposts --- .claude/settings.local.json | 8 +++ .../templates/image_review_page_template.dart | 3 +- .../data/adapters/bsky/feed_adapter.dart | 4 +- .../repositories/feed_repository_impl.dart | 65 ++++++----------- .../core/utils/bluesky_crosspost_text.dart | 71 +++++++++++++++++++ .../providers/video_upload_provider.dart | 25 ++++++- .../utils/bluesky_crosspost_text_test.dart | 64 +++++++++++++++++ 7 files changed, 191 insertions(+), 49 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 lib/src/core/utils/bluesky_crosspost_text.dart create mode 100644 test/src/core/utils/bluesky_crosspost_text_test.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..a301e65c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)", + "Bash(echo \"Exit: $?\")" + ] + } +} diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index 24f79ee3..e42115b6 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -473,7 +473,8 @@ class _CrossPostSection extends StatelessWidget { Expanded( child: Text( 'Bluesky supports a maximum of 4 images. ' - 'Your Bluesky post will link to the Spark post instead.', + 'Your crosspost will include the first 4 and link to the ' + 'full Spark post.', style: AppTypography.textSmallMedium.copyWith( color: AppColors.rajah500, ), diff --git a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart index 38e23b07..c4383d50 100644 --- a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart +++ b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_embed_images.dart'; import 'package:bluesky/app_bsky_feed_defs.dart' as bsky_defs; @@ -471,7 +473,7 @@ class BskyFeedAdapter { return RichtextFacet( index: RichtextFacetByteSlice( byteStart: byteStart, - byteEnd: byteStart + linkUrl.length, + byteEnd: byteStart + utf8.encode(linkUrl).length, ), features: [ URichtextFacetFeatures.richtextFacetLink( diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index 59e91006..5a59c201 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -19,6 +19,7 @@ import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.d import 'package:spark/src/core/network/atproto/data/models/models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/logging/logger.dart'; import 'package:spark/src/core/utils/share_urls.dart'; @@ -1332,16 +1333,23 @@ class FeedRepositoryImpl implements FeedRepository { const maxBskyImages = 4; - // Use adapter to convert Spark images to Bluesky images - final allBskyImages = bskyFeedAdapter.convertImages(sparkImages); - final bskyImages = allBskyImages.take(maxBskyImages).toList(); + final bskyImages = bskyFeedAdapter.convertImages( + sparkImages.take(maxBskyImages).toList(), + ); - // Determine if we need to add a link to the Spark post - String? linkUrl; final bskyFacets = []; + final linkUrl = buildSparkShareUrl(sparkPostData.uri.toString()); + final crosspostText = buildBlueskyCrosspostText( + text: text, + linkUrl: linkUrl, + ); // Convert Spark mention facets to Bluesky mention facets for (final facet in sparkFacets) { + if (facet.index.byteEnd > crosspostText.facetTextByteEnd) { + continue; + } + for (final feature in facet.features) { feature.map( mention: (m) { @@ -1362,24 +1370,15 @@ class FeedRepositoryImpl implements FeedRepository { } } - if (sparkImages.length > maxBskyImages) { - final sparkRkey = sparkPostData.uri.rkey; - final uriDid = sparkPostData.uri.hostname; - linkUrl = buildSparkShareUrl('$uriDid/$sparkRkey'); - } - - // Prepare text and facets for Bluesky post - final finalText = _prepareTextWithLink(text: text, linkUrl: linkUrl); - - if (linkUrl != null) { - final linkStart = text.isEmpty ? 0 : text.length; - bskyFacets.add( - bskyFeedAdapter.createLinkFacet(linkUrl: linkUrl, byteStart: linkStart), - ); - } + bskyFacets.add( + bskyFeedAdapter.createLinkFacet( + linkUrl: linkUrl, + byteStart: crosspostText.linkByteStart, + ), + ); final bskyPost = bskyFeedAdapter.createPostRecord( - text: finalText, + text: crosspostText.text, createdAt: DateTime.now().toUtc(), images: bskyImages, facets: bskyFacets.isNotEmpty ? bskyFacets : null, @@ -1395,30 +1394,6 @@ class FeedRepositoryImpl implements FeedRepository { return bskyResult; } - /// Prepare text for Bluesky post, handling link addition and truncation - String _prepareTextWithLink({required String text, String? linkUrl}) { - if (linkUrl == null) { - return text; - } - - if (text.isEmpty) { - return linkUrl; - } - - final linkWithNewlines = '\n\n$linkUrl'; - const maxTextLength = 300; - final availableTextLength = maxTextLength - linkWithNewlines.length; - - if (text.length <= availableTextLength) { - return '$text$linkWithNewlines'; - } else { - const ellipsis = '...'; - final croppedTextLength = availableTextLength - ellipsis.length; - final croppedText = text.substring(0, croppedTextLength); - return '$croppedText$ellipsis$linkWithNewlines'; - } - } - @override Future deletePost(AtUri postUri) async { _logger.d('Deleting post with URI: $postUri'); diff --git a/lib/src/core/utils/bluesky_crosspost_text.dart b/lib/src/core/utils/bluesky_crosspost_text.dart new file mode 100644 index 00000000..2d0fc0e7 --- /dev/null +++ b/lib/src/core/utils/bluesky_crosspost_text.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:math' as math; + +const _maxBlueskyPostLength = 300; +const _linkSeparator = '\n\n'; +const _ellipsis = '...'; + +class BlueskyCrosspostText { + const BlueskyCrosspostText({ + required this.text, + required this.facetTextByteEnd, + required this.linkByteStart, + }); + + final String text; + final int facetTextByteEnd; + final int linkByteStart; +} + +BlueskyCrosspostText buildBlueskyCrosspostText({ + required String text, + required String linkUrl, +}) { + if (text.isEmpty) { + return BlueskyCrosspostText( + text: linkUrl, + facetTextByteEnd: 0, + linkByteStart: 0, + ); + } + + final suffix = '$_linkSeparator$linkUrl'; + final availableTextLength = _maxBlueskyPostLength - suffix.length; + if (availableTextLength <= 0) { + return BlueskyCrosspostText( + text: linkUrl, + facetTextByteEnd: 0, + linkByteStart: 0, + ); + } + + final body = _buildCrosspostBody(text, availableTextLength); + final bodyText = body.text; + final linkPrefix = '$bodyText$_linkSeparator'; + + return BlueskyCrosspostText( + text: '$linkPrefix$linkUrl', + facetTextByteEnd: body.facetTextByteEnd, + linkByteStart: utf8.encode(linkPrefix).length, + ); +} + +({String text, int facetTextByteEnd}) _buildCrosspostBody( + String text, + int maxLength, +) { + if (text.length <= maxLength) { + return (text: text, facetTextByteEnd: utf8.encode(text).length); + } + + if (maxLength <= _ellipsis.length) { + return (text: _ellipsis.substring(0, maxLength), facetTextByteEnd: 0); + } + + final croppedTextLength = math.max(0, maxLength - _ellipsis.length); + final croppedText = text.substring(0, croppedTextLength); + return ( + text: '$croppedText$_ellipsis', + facetTextByteEnd: utf8.encode(croppedText).length, + ); +} diff --git a/lib/src/features/posting/providers/video_upload_provider.dart b/lib/src/features/posting/providers/video_upload_provider.dart index 9ea38f2e..847a812a 100644 --- a/lib/src/features/posting/providers/video_upload_provider.dart +++ b/lib/src/features/posting/providers/video_upload_provider.dart @@ -5,7 +5,9 @@ import 'package:get_it/get_it.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; +import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; +import 'package:spark/src/core/utils/share_urls.dart'; part 'video_upload_provider.g.dart'; @@ -187,8 +189,20 @@ Future _crosspostVideoToBlueSky( final logger = GetIt.I().getLogger('Crosspost Video') ..d('Crossposting video to Bluesky'); + final sprkRepository = GetIt.I(); + final sparkDid = sprkRepository.authRepository.did; + if (sparkDid == null) { + throw Exception('User session DID not available'); + } + final linkUrl = buildSparkShareUrl('$sparkDid/$rkey'); + final crosspostText = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); + final bskyFacets = []; for (final facet in sparkFacets) { + if (facet.index.byteEnd > crosspostText.facetTextByteEnd) { + continue; + } + for (final feature in facet.features) { feature.map( mention: (m) { @@ -217,9 +231,16 @@ Future _crosspostVideoToBlueSky( } } + bskyFacets.add( + _bskyFeedAdapter.createLinkFacet( + linkUrl: linkUrl, + byteStart: crosspostText.linkByteStart, + ), + ); + final bskyPostRecord = { r'$type': 'app.bsky.feed.post', - 'text': text, + 'text': crosspostText.text, if (bskyFacets.isNotEmpty) 'facets': bskyFacets.map((facet) => facet.toJson()).toList(), 'embed': { @@ -230,7 +251,7 @@ Future _crosspostVideoToBlueSky( 'createdAt': DateTime.now().toUtc().toIso8601String(), }; - final result = await GetIt.I().repo.createRecord( + final result = await sprkRepository.repo.createRecord( collection: 'app.bsky.feed.post', record: bskyPostRecord, rkey: rkey, diff --git a/test/src/core/utils/bluesky_crosspost_text_test.dart b/test/src/core/utils/bluesky_crosspost_text_test.dart new file mode 100644 index 00000000..be396622 --- /dev/null +++ b/test/src/core/utils/bluesky_crosspost_text_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; + +void main() { + group('buildBlueskyCrosspostText', () { + test('uses only the link when source text is empty', () { + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; + + final result = buildBlueskyCrosspostText(text: '', linkUrl: linkUrl); + + expect(result.text, linkUrl); + expect(result.facetTextByteEnd, 0); + expect(result.linkByteStart, 0); + }); + + test('adds the link after a paragraph break', () { + const text = 'hello spark'; + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; + + final result = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); + + expect(result.text, '$text\n\n$linkUrl'); + expect(result.facetTextByteEnd, utf8.encode(text).length); + expect(result.linkByteStart, utf8.encode('$text\n\n').length); + }); + + test('uses a byte offset for non-ascii text before the link', () { + const text = 'spark sun'; + const decoratedText = '$text \u2600'; + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; + + final result = buildBlueskyCrosspostText( + text: decoratedText, + linkUrl: linkUrl, + ); + + expect(result.text, '$decoratedText\n\n$linkUrl'); + expect(result.facetTextByteEnd, utf8.encode(decoratedText).length); + expect(result.linkByteStart, utf8.encode('$decoratedText\n\n').length); + expect(result.linkByteStart, isNot(result.text.indexOf(linkUrl))); + }); + + test('truncates long source text while preserving the link', () { + final text = 'a' * 400; + const linkUrl = 'https://sprk.so/post/did:plc:abc123/postkey'; + + final result = buildBlueskyCrosspostText(text: text, linkUrl: linkUrl); + + expect(result.text.length, 300); + expect(result.text, endsWith('\n\n$linkUrl')); + expect(result.text, contains('...')); + expect( + result.facetTextByteEnd, + utf8.encode(result.text.split('...').first).length, + ); + expect( + result.linkByteStart, + utf8.encode(result.text.split(linkUrl).first).length, + ); + }); + }); +} -- 2.51.2