mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

feat: central routing policy for proxy header

+336 -212
+47
lib/core/network/app_bsky_routing_policy.dart
··· 1 + enum AppBskyProxyMode { useProxy, bypassProxy } 2 + 3 + /// Central routing policy for explicit `atproto-proxy` header attachment on 4 + /// authenticated `app.bsky.*` requests. 5 + /// 6 + /// Policy intent: 7 + /// - Default to [AppBskyProxyMode.useProxy] so provider-routed reads continue 8 + /// to honor the selected AppView. 9 + /// - Explicitly bypass proxy for known account/PDS-oriented mutation paths and 10 + /// preference sync calls where forcing proxy has caused regressions. 11 + abstract final class AppBskyRoutingPolicy { 12 + /// Endpoint identifiers are lexicon IDs. 13 + /// 14 + /// Preferences: keep PDS-routed without explicit proxy. 15 + /// 16 + /// Graph record/private mutations: avoid explicit proxy. 17 + /// 18 + /// Feed/bookmark mutations: avoid explicit proxy. 19 + static const Map<String, AppBskyProxyMode> _policyByEndpoint = { 20 + 'app.bsky.actor.getPreferences': AppBskyProxyMode.bypassProxy, 21 + 'app.bsky.actor.putPreferences': AppBskyProxyMode.bypassProxy, 22 + 'app.bsky.graph.follow': AppBskyProxyMode.bypassProxy, 23 + 'app.bsky.graph.block': AppBskyProxyMode.bypassProxy, 24 + 'app.bsky.graph.muteActor': AppBskyProxyMode.bypassProxy, 25 + 'app.bsky.graph.unmuteActor': AppBskyProxyMode.bypassProxy, 26 + 'app.bsky.graph.listitem': AppBskyProxyMode.bypassProxy, 27 + 'app.bsky.graph.listblock': AppBskyProxyMode.bypassProxy, 28 + 'app.bsky.graph.muteActorList': AppBskyProxyMode.bypassProxy, 29 + 'app.bsky.graph.unmuteActorList': AppBskyProxyMode.bypassProxy, 30 + 'app.bsky.graph.starterpack': AppBskyProxyMode.bypassProxy, 31 + 'app.bsky.feed.like': AppBskyProxyMode.bypassProxy, 32 + 'app.bsky.feed.repost': AppBskyProxyMode.bypassProxy, 33 + 'app.bsky.feed.post': AppBskyProxyMode.bypassProxy, 34 + 'app.bsky.bookmark.createBookmark': AppBskyProxyMode.bypassProxy, 35 + 'app.bsky.bookmark.deleteBookmark': AppBskyProxyMode.bypassProxy, 36 + }; 37 + 38 + static AppBskyProxyMode modeForEndpoint(String endpointId) { 39 + final normalized = endpointId.trim(); 40 + if (normalized.isEmpty) { 41 + return AppBskyProxyMode.useProxy; 42 + } 43 + return _policyByEndpoint[normalized] ?? AppBskyProxyMode.useProxy; 44 + } 45 + 46 + static bool shouldUseProxy(String endpointId) => modeForEndpoint(endpointId) == AppBskyProxyMode.useProxy; 47 + }
+20 -2
lib/core/network/app_view_request_context.dart
··· 1 + import 'package:lazurite/core/network/app_bsky_routing_policy.dart'; 1 2 import 'package:lazurite/core/network/app_view_provider.dart'; 2 3 import 'package:lazurite/core/network/app_view_router.dart'; 3 4 ··· 17 18 return merged; 18 19 } 19 20 21 + /// App headers for the given lexicon endpoint using the centralized AppView proxy policy map. 22 + Map<String, String> appBskyHeadersForEndpoint(String endpointId, [Map<String, String>? baseHeaders]) { 23 + if (AppBskyRoutingPolicy.shouldUseProxy(endpointId)) { 24 + return appBskyHeaders(baseHeaders); 25 + } 26 + return appBskyHeadersWithoutProxy(baseHeaders); 27 + } 28 + 29 + /// App headers with any AppView proxy override removed. 30 + /// 31 + /// Use this for endpoints that should remain PDS-routed and not be forced 32 + /// through an explicit AppView DID proxy. 33 + Map<String, String> appBskyHeadersWithoutProxy([Map<String, String>? baseHeaders]) { 34 + final headers = <String, String>{...?baseHeaders}; 35 + headers.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 36 + return headers; 37 + } 38 + 20 39 String resolveProviderKey() { 21 40 final resolver = _appViewProviderResolver; 22 41 if (resolver == null) { ··· 26 45 } 27 46 28 47 AppViewRouter _routerForCurrentProvider() { 29 - final provider = AppViewProviders.descriptorForSetting(resolveProviderKey()); 30 - return AppViewRouter(provider: provider); 48 + return AppViewRouter(provider: AppViewProviders.descriptorForSetting(resolveProviderKey())); 31 49 } 32 50 }
+3 -3
lib/features/feed/cubit/feed_preferences_cubit.dart
··· 263 263 generatorViews.addAll(chunkViews); 264 264 continue; 265 265 } catch (_) { 266 - // Fall back to per-feed hydration for this chunk so one bad URI (or 267 - // oversized/invalid batch response) does not suppress all remaining 268 - // metadata. 266 + log.d( 267 + 'FeedPreferencesCubit: Batch hydration failed for ${chunk.length} generators, falling back to individual fetches for $_accountDid', 268 + ); 269 269 } 270 270 271 271 for (final feedUri in chunk) {
+3 -2
lib/features/feed/cubit/post_action_cubit.dart
··· 19 19 this.isDeleted = false, 20 20 this.error, 21 21 }); 22 - // Sentinel used by copyWith to distinguish "keep existing value" 23 - // from an explicit null assignment for nullable fields. 22 + 23 + /// Sentinel used by copyWith to distinguish "keep existing value" from 24 + /// an explicit null assignment for nullable fields. 24 25 static const Object _unset = Object(); 25 26 26 27 final String postUri;
+6 -36
lib/features/feed/data/feed_repository.dart
··· 1 1 import 'dart:convert'; 2 2 3 - import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:atproto_core/atproto_core.dart' show AtUri; 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; ··· 109 109 } 110 110 111 111 Future<PreferencesResult> getPreferences() async { 112 - final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); 113 - try { 114 - final response = await _bluesky.actor.getPreferences($headers: headers); 115 - return PreferencesResult(preferences: response.data.preferences); 116 - } on XRPCException catch (error) { 117 - if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 118 - final response = await _bluesky.actor.getPreferences($headers: _withoutAppViewProxyHeader(headers)); 119 - return PreferencesResult(preferences: response.data.preferences); 120 - } 121 - rethrow; 122 - } 112 + final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 113 + final response = await _bluesky.actor.getPreferences($headers: headers); 114 + return PreferencesResult(preferences: response.data.preferences); 123 115 } 124 116 125 117 Future<void> putPreferences({required List<UPreferences> preferences}) async { 126 - final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); 127 - try { 128 - await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 129 - } on XRPCException catch (error) { 130 - if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 131 - await _bluesky.actor.putPreferences(preferences: preferences, $headers: _withoutAppViewProxyHeader(headers)); 132 - return; 133 - } 134 - rethrow; 135 - } 136 - } 137 - 138 - bool _shouldRetryPreferencesWithoutProxy({required XRPCException error, required Map<String, String> headers}) { 139 - return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; 140 - } 141 - 142 - bool _hasAppViewProxyHeader(Map<String, String> headers) { 143 - return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); 144 - } 145 - 146 - Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 147 - final copy = Map<String, String>.from(headers); 148 - copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 149 - return copy; 118 + final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 119 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 150 120 } 151 121 152 122 Future<List<GeneratorView>> getSuggestedFeeds({String? cursor, int limit = 50}) async {
+7 -7
lib/features/feed/data/post_action_repository.dart
··· 21 21 final response = await _bluesky.feed.like.create( 22 22 subject: RepoStrongRef(cid: cid, uri: uri), 23 23 createdAt: DateTime.now(), 24 - $headers: _appViewContext.appBskyHeaders(), 24 + $headers: _appViewContext.appBskyHeadersWithoutProxy(), 25 25 ); 26 26 27 27 return response.data.uri.toString(); ··· 29 29 30 30 Future<void> unlikePost({required String likeUri}) async { 31 31 final rkey = _extractRkey(likeUri); 32 - await _bluesky.feed.like.delete(rkey: rkey, $headers: _appViewContext.appBskyHeaders()); 32 + await _bluesky.feed.like.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 33 33 } 34 34 35 35 Future<String> repostPost({required AtUri uri, required String cid}) async { 36 36 final response = await _bluesky.feed.repost.create( 37 37 subject: RepoStrongRef(cid: cid, uri: uri), 38 38 createdAt: DateTime.now(), 39 - $headers: _appViewContext.appBskyHeaders(), 39 + $headers: _appViewContext.appBskyHeadersWithoutProxy(), 40 40 ); 41 41 42 42 return response.data.uri.toString(); ··· 44 44 45 45 Future<void> unrepostPost({required String repostUri}) async { 46 46 final rkey = _extractRkey(repostUri); 47 - await _bluesky.feed.repost.delete(rkey: rkey, $headers: _appViewContext.appBskyHeaders()); 47 + await _bluesky.feed.repost.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 48 48 } 49 49 50 50 Future<void> deletePost({required String postUri}) async { 51 51 final rkey = _extractRkey(postUri); 52 - await _bluesky.feed.post.delete(rkey: rkey, $headers: _appViewContext.appBskyHeaders()); 52 + await _bluesky.feed.post.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 53 53 } 54 54 55 55 Future<void> createBookmark({required AtUri uri, required String cid}) async { 56 - await _bluesky.bookmark.createBookmark(uri: uri, cid: cid, $headers: _appViewContext.appBskyHeaders()); 56 + await _bluesky.bookmark.createBookmark(uri: uri, cid: cid, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 57 57 } 58 58 59 59 Future<void> deleteBookmark({required AtUri uri}) async { 60 - await _bluesky.bookmark.deleteBookmark(uri: uri, $headers: _appViewContext.appBskyHeaders()); 60 + await _bluesky.bookmark.deleteBookmark(uri: uri, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 61 61 } 62 62 63 63 Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async {
+2 -2
lib/features/feed/presentation/post_thread_screen.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 - import 'package:lazurite/core/theme/theme_extensions.dart'; 4 3 5 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 5 import 'package:bluesky/app_bsky_feed_post.dart'; ··· 12 11 import 'package:intl/intl.dart'; 13 12 import 'package:lazurite/core/network/app_view_provider.dart'; 14 13 import 'package:lazurite/core/network/app_view_web_links.dart'; 14 + import 'package:lazurite/core/theme/theme_extensions.dart'; 15 15 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 16 16 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 17 17 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; ··· 960 960 ); 961 961 } 962 962 963 + /// Editing is currently exposed through the thread-screen overflow menu for owner posts. 963 964 Future<void> _onEdit(BuildContext context) async { 964 965 final post = thread.post; 965 966 final record = Map<String, dynamic>.from(post.record); 966 967 967 - // Editing is currently exposed through the thread-screen overflow menu for owner posts. 968 968 final result = await context.push( 969 969 '/compose', 970 970 extra: ComposeRouteArgs(
+6 -6
lib/features/lists/data/list_repository.dart
··· 103 103 list: listUri, 104 104 subject: subjectDid, 105 105 createdAt: DateTime.now(), 106 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 106 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 107 107 ); 108 108 109 109 return response.data.uri.toString(); ··· 112 112 Future<void> removeListItem({required AtUri listItemUri}) async { 113 113 await _bluesky.graph.listitem.delete( 114 114 rkey: listItemUri.rkey, 115 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 115 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 116 116 ); 117 117 } 118 118 119 119 Future<void> muteList({required AtUri listUri}) async { 120 120 await _bluesky.graph.muteActorList( 121 121 list: listUri, 122 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 122 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 123 123 ); 124 124 } 125 125 126 126 Future<void> unmuteList({required AtUri listUri}) async { 127 127 await _bluesky.graph.unmuteActorList( 128 128 list: listUri, 129 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 129 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 130 130 ); 131 131 } 132 132 ··· 134 134 final response = await _bluesky.graph.listblock.create( 135 135 subject: listUri, 136 136 createdAt: DateTime.now(), 137 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 137 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 138 138 ); 139 139 140 140 return response.data.uri.toString(); ··· 143 143 Future<void> unblockList({required AtUri blockUri}) async { 144 144 await _bluesky.graph.listblock.delete( 145 145 rkey: blockUri.rkey, 146 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 146 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 147 147 ); 148 148 } 149 149
+5 -47
lib/features/moderation/data/moderation_service.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 3 4 - import 'package:atproto_core/atproto_core.dart' as atp_core; 5 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 5 import 'package:bluesky/app_bsky_actor_getpreferences.dart'; 7 6 import 'package:bluesky/app_bsky_feed_defs.dart'; ··· 329 328 return _preferences; 330 329 } 331 330 332 - final headers = _appViewContext.appBskyHeaders(); 331 + final headers = _appViewContext.appBskyHeadersWithoutProxy(); 333 332 try { 334 333 final prefsResponse = await _bluesky.actor.getPreferences($headers: headers); 335 334 final preferences = prefsResponse.data.preferences; 336 335 await _cachePreferences(preferences); 337 336 return preferences; 338 - } on atp_core.XRPCException catch (error) { 339 - if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 340 - final fallbackHeaders = _withoutAppViewProxyHeader(headers); 341 - log.w( 342 - 'ModerationService: getPreferences returned ${error.response.status.code} with AppView proxy; ' 343 - 'retrying without atproto-proxy.', 344 - ); 345 - final prefsResponse = await _bluesky.actor.getPreferences($headers: fallbackHeaders); 346 - final preferences = prefsResponse.data.preferences; 347 - await _cachePreferences(preferences); 348 - return preferences; 349 - } 350 - rethrow; 351 337 } catch (error) { 352 338 final cached = await _loadCachedPreferences(); 353 339 if (cached != null) { ··· 361 347 362 348 Future<void> _putAndRefresh(List<UPreferences> preferences) async { 363 349 final moderationPrefs = _toModerationPrefs(preferences); 364 - final headers = _buildHeadersForPrefs(moderationPrefs); 365 - try { 366 - await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 367 - } on atp_core.XRPCException catch (error) { 368 - if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 369 - final fallbackHeaders = _withoutAppViewProxyHeader(headers); 370 - log.w( 371 - 'ModerationService: putPreferences returned ${error.response.status.code} with AppView proxy; ' 372 - 'retrying without atproto-proxy.', 373 - ); 374 - await _bluesky.actor.putPreferences(preferences: preferences, $headers: fallbackHeaders); 375 - } else { 376 - rethrow; 377 - } 378 - } 350 + final headers = _appViewContext.appBskyHeadersWithoutProxy( 351 + _buildLabelerHeaders(moderationPrefs.labelers.map((labeler) => labeler.did)), 352 + ); 353 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 379 354 await updatePreferences(preferences: preferences); 380 355 } 381 356 ··· 575 550 576 551 Map<String, String> _buildHeadersForPrefs(bsky_moderation.ModerationPrefs prefs) { 577 552 return _appViewContext.appBskyHeaders(_buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did))); 578 - } 579 - 580 - bool _shouldRetryPreferencesWithoutProxy({ 581 - required atp_core.XRPCException error, 582 - required Map<String, String> headers, 583 - }) { 584 - return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; 585 - } 586 - 587 - bool _hasAppViewProxyHeader(Map<String, String> headers) { 588 - return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); 589 - } 590 - 591 - Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 592 - final copy = Map<String, String>.from(headers); 593 - copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 594 - return copy; 595 553 } 596 554 597 555 String? get _preferencesCacheKey {
+6 -6
lib/features/profile/data/profile_action_repository.dart
··· 24 24 final response = await _bluesky.graph.follow.create( 25 25 subject: did, 26 26 createdAt: DateTime.now(), 27 - $headers: _appViewContext.appBskyHeaders(), 27 + $headers: _appViewContext.appBskyHeadersWithoutProxy(), 28 28 ); 29 29 30 30 return response.data.uri.toString(); ··· 32 32 33 33 Future<void> unfollowActor({required String followUri}) async { 34 34 final rkey = _extractRkey(followUri); 35 - await _bluesky.graph.follow.delete(rkey: rkey, $headers: _appViewContext.appBskyHeaders()); 35 + await _bluesky.graph.follow.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 36 36 } 37 37 38 38 Future<void> muteActor({required String did}) async { 39 - await _bluesky.graph.muteActor(actor: did, $headers: _appViewContext.appBskyHeaders()); 39 + await _bluesky.graph.muteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 40 40 } 41 41 42 42 Future<void> unmuteActor({required String did}) async { 43 - await _bluesky.graph.unmuteActor(actor: did, $headers: _appViewContext.appBskyHeaders()); 43 + await _bluesky.graph.unmuteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 44 44 } 45 45 46 46 Future<String> blockActor({required String did}) async { 47 47 final response = await _bluesky.graph.block.create( 48 48 subject: did, 49 49 createdAt: DateTime.now(), 50 - $headers: _appViewContext.appBskyHeaders(), 50 + $headers: _appViewContext.appBskyHeadersWithoutProxy(), 51 51 ); 52 52 53 53 return response.data.uri.toString(); ··· 55 55 56 56 Future<void> unblockActor({required String blockUri}) async { 57 57 final rkey = _extractRkey(blockUri); 58 - await _bluesky.graph.block.delete(rkey: rkey, $headers: _appViewContext.appBskyHeaders()); 58 + await _bluesky.graph.block.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); 59 59 } 60 60 61 61 Future<String> reportPost({
+169 -87
lib/features/profile/presentation/profile_screen.dart
··· 70 70 71 71 static const _baseTabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'STARTER PACKS']; 72 72 static const _suggestedTabLabel = 'SUGGESTED'; 73 + static const _coverRefreshTriggerDistance = 72.0; 73 74 74 75 late TabController _tabController; 76 + final ScrollController _profileScrollController = ScrollController(); 77 + final GlobalKey<RefreshIndicatorState> _profileRefreshKey = GlobalKey<RefreshIndicatorState>(); 75 78 late bool _showSuggestedTab; 76 79 double _coverScrollOffset = 0; 80 + int? _headerTrackingPointer; 81 + double _headerPullDistance = 0; 82 + bool _headerRefreshInFlight = false; 77 83 78 84 @override 79 85 void initState() { ··· 96 102 @override 97 103 void dispose() { 98 104 _tabController.dispose(); 105 + _profileScrollController.dispose(); 99 106 super.dispose(); 100 107 } 101 108 ··· 122 129 return profile.did != context.read<AuthBloc>().state.tokens?.did; 123 130 } 124 131 132 + /// Updates tab controller and visibility state to show or hide the Suggested tab based on the current profile. 133 + /// 134 + /// We dispose after rebuild so widgets still referencing the previous 135 + /// controller do not observe a torn-down animation during this frame. 125 136 void _setSuggestedTabVisibility(bool show) { 126 137 if (_showSuggestedTab == show) { 127 138 return; ··· 134 145 _tabController = TabController(length: _tabLabels.length, vsync: this, initialIndex: nextIndex); 135 146 setState(() {}); 136 147 137 - // Dispose after rebuild so widgets still referencing the previous 138 - // controller do not observe a torn-down animation during this frame. 139 148 WidgetsBinding.instance.addPostFrameCallback((_) { 140 149 previousController.dispose(); 141 150 }); ··· 152 161 await Future<void>.delayed(const Duration(milliseconds: 250)); 153 162 } 154 163 164 + bool get _isAtTop => !_profileScrollController.hasClients || _profileScrollController.position.pixels <= 0.5; 165 + 166 + void _onHeaderPointerDown(PointerDownEvent event) { 167 + _headerTrackingPointer = event.pointer; 168 + _headerPullDistance = 0; 169 + } 170 + 171 + Future<void> _onHeaderPointerMove(PointerMoveEvent event) async { 172 + if (_headerTrackingPointer != event.pointer || _headerRefreshInFlight) { 173 + return; 174 + } 175 + 176 + if (!_isAtTop) { 177 + _headerPullDistance = 0; 178 + return; 179 + } 180 + 181 + if (event.delta.dy <= 0) { 182 + return; 183 + } 184 + 185 + _headerPullDistance += event.delta.dy; 186 + if (_headerPullDistance < _coverRefreshTriggerDistance) { 187 + return; 188 + } 189 + 190 + _headerPullDistance = 0; 191 + _headerRefreshInFlight = true; 192 + await (_profileRefreshKey.currentState?.show() ?? _refresh()); 193 + _headerRefreshInFlight = false; 194 + } 195 + 196 + void _onHeaderPointerUp(PointerUpEvent event) { 197 + if (_headerTrackingPointer != event.pointer) { 198 + return; 199 + } 200 + _headerTrackingPointer = null; 201 + _headerPullDistance = 0; 202 + } 203 + 204 + void _onHeaderPointerCancel(PointerCancelEvent event) { 205 + if (_headerTrackingPointer != event.pointer) { 206 + return; 207 + } 208 + _headerTrackingPointer = null; 209 + _headerPullDistance = 0; 210 + } 211 + 212 + Widget _buildProfileHeaderRefreshZone({required Widget child, Key? key}) { 213 + return Listener( 214 + key: key, 215 + onPointerDown: _onHeaderPointerDown, 216 + onPointerMove: _onHeaderPointerMove, 217 + onPointerUp: _onHeaderPointerUp, 218 + onPointerCancel: _onHeaderPointerCancel, 219 + child: child, 220 + ); 221 + } 222 + 155 223 @override 156 224 Widget build(BuildContext context) { 157 225 return AppScreenEntrance( ··· 186 254 } 187 255 return false; 188 256 }, 189 - child: NestedScrollView( 190 - headerSliverBuilder: (context, innerBoxIsScrolled) { 191 - return [ 192 - SliverAppBar( 193 - floating: true, 194 - pinned: true, 195 - snap: true, 196 - title: Text(_appBarTitle(profile)), 197 - leading: widget.showBackButton 198 - ? IconButton( 199 - icon: const Icon(Icons.arrow_back), 200 - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 201 - ) 202 - : const AppShellMenuButton(), 203 - actions: [ 204 - if (profile != null && isOwnProfile) 257 + child: RefreshIndicator( 258 + key: _profileRefreshKey, 259 + onRefresh: _refresh, 260 + notificationPredicate: (notification) => 261 + notification.depth == 0 && notification.metrics.axis == Axis.vertical, 262 + child: NestedScrollView( 263 + controller: _profileScrollController, 264 + physics: const AlwaysScrollableScrollPhysics(), 265 + headerSliverBuilder: (context, innerBoxIsScrolled) { 266 + return [ 267 + SliverAppBar( 268 + floating: true, 269 + pinned: true, 270 + snap: true, 271 + title: Text(_appBarTitle(profile)), 272 + leading: widget.showBackButton 273 + ? IconButton( 274 + icon: const Icon(Icons.arrow_back), 275 + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 276 + ) 277 + : const AppShellMenuButton(), 278 + actions: [ 279 + if (profile != null && isOwnProfile) 280 + IconButton( 281 + key: const Key('profile_more_button'), 282 + icon: const Icon(Icons.more_vert), 283 + onPressed: () => _showOwnProfileMoreOptions(context, profile), 284 + ), 205 285 IconButton( 206 - key: const Key('profile_more_button'), 207 - icon: const Icon(Icons.more_vert), 208 - onPressed: () => _showOwnProfileMoreOptions(context, profile), 286 + icon: const Icon(Icons.settings_outlined), 287 + onPressed: () => context.go('/settings'), 209 288 ), 210 - IconButton( 211 - icon: const Icon(Icons.settings_outlined), 212 - onPressed: () => context.go('/settings'), 213 - ), 214 - ], 215 - ), 216 - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 217 - SliverToBoxAdapter( 218 - child: switch (profileState.status) { 219 - ProfileStatus.loading => const Padding( 220 - padding: AppInsets.allLg, 221 - child: Center(child: CircularProgressIndicator()), 222 - ), 223 - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 224 - _ => _buildProfileSummary(context, profile, isOwnProfile), 225 - }, 226 - ), 227 - SliverPersistentHeader( 228 - pinned: true, 229 - delegate: SliverTabBarDelegate( 230 - TabBar( 231 - controller: _tabController, 232 - tabs: [for (final label in _tabLabels) Tab(text: label)], 233 - onTap: (index) { 234 - if (index < _feedTabs.length) { 235 - _loadProfileAndFeed(filter: _feedTabs[index].filter); 236 - } 289 + ], 290 + ), 291 + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 292 + SliverToBoxAdapter( 293 + child: _buildProfileHeaderRefreshZone( 294 + key: const ValueKey('profile_header_details_refresh_zone'), 295 + child: switch (profileState.status) { 296 + ProfileStatus.loading => const Padding( 297 + padding: AppInsets.allLg, 298 + child: Center(child: CircularProgressIndicator()), 299 + ), 300 + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 301 + _ => _buildProfileSummary(context, profile, isOwnProfile), 237 302 }, 238 - isScrollable: true, 239 - tabAlignment: TabAlignment.start, 240 - labelStyle: const TextStyle( 241 - fontSize: 11, 242 - fontWeight: FontWeight.w700, 243 - letterSpacing: 2.2, 244 - ), 245 - unselectedLabelStyle: const TextStyle( 246 - fontSize: 11, 247 - fontWeight: FontWeight.w700, 248 - letterSpacing: 2.2, 303 + ), 304 + ), 305 + SliverPersistentHeader( 306 + pinned: true, 307 + delegate: SliverTabBarDelegate( 308 + TabBar( 309 + controller: _tabController, 310 + tabs: [for (final label in _tabLabels) Tab(text: label)], 311 + onTap: (index) { 312 + if (index < _feedTabs.length) { 313 + _loadProfileAndFeed(filter: _feedTabs[index].filter); 314 + } 315 + }, 316 + isScrollable: true, 317 + tabAlignment: TabAlignment.start, 318 + labelStyle: const TextStyle( 319 + fontSize: 11, 320 + fontWeight: FontWeight.w700, 321 + letterSpacing: 2.2, 322 + ), 323 + unselectedLabelStyle: const TextStyle( 324 + fontSize: 11, 325 + fontWeight: FontWeight.w700, 326 + letterSpacing: 2.2, 327 + ), 328 + indicatorWeight: 2, 249 329 ), 250 - indicatorWeight: 2, 251 330 ), 252 331 ), 253 - ), 254 - ]; 255 - }, 256 - body: TabBarView(controller: _tabController, children: tabChildren), 332 + ]; 333 + }, 334 + body: TabBarView(controller: _tabController, children: tabChildren), 335 + ), 257 336 ), 258 337 ); 259 338 }, ··· 298 377 coverContent = ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()); 299 378 } 300 379 301 - return SizedBox( 302 - height: coverHeight + avatarSize / 2, 303 - child: Stack( 304 - clipBehavior: Clip.none, 305 - children: [ 306 - Positioned( 307 - top: 0, 308 - left: 0, 309 - right: 0, 310 - height: coverHeight, 311 - child: Transform.translate( 312 - offset: Offset(0, -1.0 * (_coverScrollOffset * 0.5).clamp(0, coverHeight * 0.3).toDouble()), 313 - child: Container( 314 - decoration: BoxDecoration( 315 - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 380 + return _buildProfileHeaderRefreshZone( 381 + key: const ValueKey('profile_cover_refresh_zone'), 382 + child: SizedBox( 383 + height: coverHeight + avatarSize / 2, 384 + child: Stack( 385 + clipBehavior: Clip.none, 386 + children: [ 387 + Positioned( 388 + top: 0, 389 + left: 0, 390 + right: 0, 391 + height: coverHeight, 392 + child: Transform.translate( 393 + offset: Offset(0, -1.0 * (_coverScrollOffset * 0.5).clamp(0, coverHeight * 0.3).toDouble()), 394 + child: Container( 395 + decoration: BoxDecoration( 396 + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 397 + ), 398 + child: Opacity(opacity: 0.5, child: coverContent), 316 399 ), 317 - child: Opacity(opacity: 0.5, child: coverContent), 318 400 ), 319 401 ), 320 - ), 321 - Positioned( 322 - top: coverHeight - avatarSize / 2, 323 - left: 16, 324 - child: _buildSquareAvatar(context, profile, avatarSize), 325 - ), 326 - ], 402 + Positioned( 403 + top: coverHeight - avatarSize / 2, 404 + left: 16, 405 + child: _buildSquareAvatar(context, profile, avatarSize), 406 + ), 407 + ], 408 + ), 327 409 ), 328 410 ); 329 411 }
+6 -6
lib/features/starter_packs/data/starter_pack_repository.dart
··· 76 76 list: refListUri, 77 77 feeds: feeds.isEmpty ? null : feeds, 78 78 createdAt: DateTime.now(), 79 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 79 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 80 80 ); 81 81 82 82 return response.data.uri; ··· 98 98 list: referenceListUri, 99 99 feeds: feeds.isEmpty ? null : feeds, 100 100 createdAt: DateTime.now(), 101 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 101 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 102 102 ); 103 103 } 104 104 ··· 109 109 }) async { 110 110 await _bluesky.graph.starterpack.delete( 111 111 rkey: packUri.rkey, 112 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 112 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 113 113 ); 114 114 await _bluesky.atproto.repo.deleteRecord( 115 115 repo: userDid, ··· 123 123 list: listUri, 124 124 subject: subjectDid, 125 125 createdAt: DateTime.now(), 126 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 126 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 127 127 ); 128 128 129 129 return response.data.uri.toString(); ··· 132 132 Future<void> removeMember({required AtUri listItemUri}) async { 133 133 await _bluesky.graph.listitem.delete( 134 134 rkey: listItemUri.rkey, 135 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 135 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 136 136 ); 137 137 } 138 138 ··· 156 156 await _bluesky.graph.follow.create( 157 157 subject: item.subject.did as String, 158 158 createdAt: DateTime.now(), 159 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 159 + $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), 160 160 ); 161 161 count++; 162 162 } catch (_) {
+4 -2
lib/shared/presentation/helpers/navigation_helpers.dart
··· 1 1 import 'package:flutter/widgets.dart'; 2 2 import 'package:go_router/go_router.dart'; 3 3 4 + /// Profile navigation helper 5 + /// 6 + /// This avoids pushing a second shell stack from top-level routes like `/post`, 7 + /// that can duplicate navigator keys and trip a framework assertion. 4 8 Future<T?>? navigateToProfile<T>(BuildContext context, String actorDid) { 5 9 final router = GoRouter.maybeOf(context); 6 10 if (router == null) { ··· 10 14 final location = '/profile/view?actor=${Uri.encodeQueryComponent(actorDid)}'; 11 15 final currentPath = _currentPath(context); 12 16 13 - // Avoid pushing a second shell stack from top-level routes like `/post`. 14 - // That can duplicate navigator keys and trip a framework assertion. 15 17 if (!_isStatefulShellPath(currentPath)) { 16 18 router.go(location); 17 19 return null;
+6 -6
test/features/moderation/data/moderation_service_test.dart
··· 157 157 service.dispose(); 158 158 }); 159 159 160 - test('uses selected AppView proxy headers for preference reads and writes', () async { 160 + test('does not force AppView proxy headers for preference reads and writes', () async { 161 161 final actor = _FakeActorService(preferences: const []); 162 162 final service = ModerationService( 163 163 bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), ··· 168 168 ); 169 169 170 170 await service.ensureInitialized(); 171 - expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); 171 + expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], isNull); 172 172 173 173 await service.subscribeToLabeler(_customLabelerDid); 174 - expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); 174 + expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], isNull); 175 175 expect(actor.lastPutPreferencesHeaders?['atproto-accept-labelers'], contains(_customLabelerDid)); 176 176 177 177 service.dispose(); 178 178 }); 179 179 180 - test('retries preferences without AppView proxy when proxied request returns 404', () async { 180 + test('does not perform proxy-retry cycle for preferences', () async { 181 181 final actor = _FakeActorService( 182 182 preferences: const [], 183 183 errorOnProxyGetPreferences: _proxyNotSupported( ··· 198 198 ); 199 199 200 200 await service.ensureInitialized(); 201 - expect(actor.getPreferencesCallCount, 2); 201 + expect(actor.getPreferencesCallCount, 1); 202 202 expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], isNull); 203 203 204 204 await service.subscribeToLabeler(_customLabelerDid); 205 - expect(actor.putPreferencesCallCount, 2); 205 + expect(actor.putPreferencesCallCount, 1); 206 206 expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], isNull); 207 207 208 208 service.dispose();
+46
test/features/profile/presentation/profile_screen_test.dart
··· 408 408 409 409 expect(find.byKey(const ValueKey('profile_square_avatar')), findsOneWidget); 410 410 }); 411 + 412 + testWidgets('pulling from the cover area refreshes profile and feed when no banner exists', (tester) async { 413 + useLargeScreen(tester); 414 + await tester.pumpWidget(buildSubject()); 415 + 416 + await tester.drag(find.byKey(const ValueKey('profile_cover_refresh_zone')), const Offset(0, 320)); 417 + await tester.pumpAndSettle(); 418 + 419 + verify(() => profileBloc.add(const ProfileRefreshRequested())).called(1); 420 + verify(() => feedBloc.add(const FeedRefreshRequested())).called(1); 421 + }); 422 + 423 + testWidgets('pulling from the cover image refreshes profile and feed when banner exists', (tester) async { 424 + useLargeScreen(tester); 425 + const profileWithBanner = ProfileViewDetailed( 426 + did: 'did:plc:me', 427 + handle: 'me.bsky.social', 428 + displayName: 'River Tam', 429 + banner: 'https://example.com/banner.jpg', 430 + ); 431 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: profileWithBanner)); 432 + whenListen( 433 + profileBloc, 434 + const Stream<ProfileState>.empty(), 435 + initialState: const ProfileState.loaded(profile: profileWithBanner), 436 + ); 437 + 438 + await tester.pumpWidget(buildSubject()); 439 + 440 + await tester.drag(find.byKey(const ValueKey('profile_cover_refresh_zone')), const Offset(0, 320)); 441 + await tester.pumpAndSettle(); 442 + 443 + verify(() => profileBloc.add(const ProfileRefreshRequested())).called(1); 444 + verify(() => feedBloc.add(const FeedRefreshRequested())).called(1); 445 + }); 446 + 447 + testWidgets('pulling from profile header details area refreshes profile and feed', (tester) async { 448 + useLargeScreen(tester); 449 + await tester.pumpWidget(buildSubject()); 450 + 451 + await tester.drag(find.text('RIVER TAM'), const Offset(0, 320)); 452 + await tester.pumpAndSettle(); 453 + 454 + verify(() => profileBloc.add(const ProfileRefreshRequested())).called(1); 455 + verify(() => feedBloc.add(const FeedRefreshRequested())).called(1); 456 + }); 411 457 }); 412 458 413 459 group('Tab bar', () {