diff --git a/CHANGELOG.md b/CHANGELOG.md index dabc565..f25cef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # CHANGELOG +## v1.0.0 (Alpha 10) + +### Added + +- Similar posts on thread pages, using shared public-like relationships with + moderation-filtered AppView hydration. +- Mentions tab on authenticated profile pages, including pagination, duplicate + filtering, and lightweight mention ranking. +- Provider-aware account settings for Bluesky and BlackSky. +- BlackSky AI preference controls backed by `community.lexicon.preference.ai` repo + records. +- Thread reply sort preference in account settings. +- Font size setting for content text. +- Scrollable, bounded alt-text panel for full-screen image and video viewers. +- Opaque OAuth token support and OAuth session restoration after app restart. +- Shared test fixtures, router/widget harnesses, and network fixtures. + +### Changed + +- Profile Context now uses the nested `/profile/:actor/context` route, with + compatibility redirects +- Thread pages have an explicit back button with authenticated and public-route + fallbacks. +- Public post navigation pushes onto the stack for better back behavior. +- Settings has broader English localization coverage and narrower state selection + during rendering. +- Normal content text defaults to 16px, with compact and nested post text scaled from + the configured content size. +- Feed layout naming now uses Comfortable/Compact +- Session identity and recovery helpers are shared across the app's lifecycle + +### Fixed + +- Pending OAuth state is persisted and restored so app restarts during login no longer + strand callback handling. +- Notification Screen crash +- Long media alt text is no longer truncated in full-screen viewers. + ## v1.0.0 (Alpha 9) ### Added @@ -80,7 +118,26 @@ and website ## v1.0.0 (Alpha 5) -TODO +### Added + +- Account switcher access from the login screen. +- Settings access while signed out, including public routes for logs, about, + legal pages, and developer tools (AT Explorer). +- Troubleshooting actions for clearing local cache and resetting sign-in data. +- Persistent OAuth client IDs for session records. + +### Changed + +- More comfortable composer layout with improved image attachment handling. +- Auth refresh failures are now non-destructive +- Push notification registration, notification reads, profile writes, compose uploads, + and scheduled posts use improved auth recovery paths. + +### Fixed + +- OAuth callback duplicate exchange loops. +- Image/blob uploading from compose. +- Failed refreshes incorrectly clearing current sessions. ## v1.0.0 (Alpha 4) @@ -94,7 +151,8 @@ TODO #### 2026-05-04 -- Fixed background auth-expiry recovery by adding unauthorized retry + session refresh/recovery paths across feed/thread/conversation reads. +- Fixed background auth-expiry recovery by adding unauthorized retry + session + refresh/recovery paths across feed/thread/conversation reads. - Fixed account-switch reliability. ## v1.0.0 (Alpha 3) diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index 2aab1e9..75cbe9e 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -934,7 +935,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Debug-only: next network request returns Unauthorized to test token refresh'; @override - String get messageManageSemanticSearchSubtitle => 'Manage semantic search from Bookmarks & Likes -> Search'; + String get messageManageSemanticSearchSubtitle => 'Manage searching your bookmarks & saved posts.'; @override String get messageLogsSubtitle => 'View app log files'; diff --git a/lib/core/l10n/intl_en.arb b/lib/core/l10n/intl_en.arb index 405e556..11eeab3 100644 --- a/lib/core/l10n/intl_en.arb +++ b/lib/core/l10n/intl_en.arb @@ -1311,7 +1311,7 @@ "@messageForceNextXrpc401Subtitle": { "description": "Developer setting subtitle for forced 401" }, - "messageManageSemanticSearchSubtitle": "Manage semantic search from Bookmarks & Likes -> Search", + "messageManageSemanticSearchSubtitle": "Manage searching your bookmarks & saved posts.", "@messageManageSemanticSearchSubtitle": { "description": "Settings subtitle for semantic search management" }, diff --git a/lib/features/public/presentation/public_navigation.dart b/lib/features/public/presentation/public_navigation.dart index 5cac167..b77ad51 100644 --- a/lib/features/public/presentation/public_navigation.dart +++ b/lib/features/public/presentation/public_navigation.dart @@ -16,20 +16,21 @@ class PublicProviderScope extends InheritedWidget { bool updateShouldNotify(covariant PublicProviderScope oldWidget) => oldWidget.providerKey != providerKey; } -void navigateToPublicFeed(BuildContext context, GeneratorView feed, PublicProviderContext providerContext) { - final uri = Uri( - path: '/feed', - queryParameters: {'uri': feed.uri.toString(), 'provider': providerContext.providerKey}, +Future navigateToPublicFeed(BuildContext context, GeneratorView feed, PublicProviderContext providerContext) { + return context.push( + Uri( + path: '/feed', + queryParameters: {'uri': feed.uri.toString(), 'provider': providerContext.providerKey}, + ).toString(), ); - context.go(uri.toString()); } void navigateToPublicProfile(BuildContext context, String actor, PublicProviderContext providerContext) { - final uri = providerContext.appendTo(Uri(path: '/profile/$actor')); - context.go(uri.toString()); + context.go(providerContext.appendTo(Uri(path: '/profile/$actor')).toString()); } Future navigateToPublicPost(BuildContext context, String postUri, PublicProviderContext providerContext) { - final uri = Uri(path: '/post', queryParameters: {'uri': postUri, 'provider': providerContext.providerKey}); - return context.push(uri.toString()); + return context.push( + Uri(path: '/post', queryParameters: {'uri': postUri, 'provider': providerContext.providerKey}).toString(), + ); } diff --git a/lib/features/settings/presentation/settings_account_screen.dart b/lib/features/settings/presentation/settings_account_screen.dart index 413d1c7..6864366 100644 --- a/lib/features/settings/presentation/settings_account_screen.dart +++ b/lib/features/settings/presentation/settings_account_screen.dart @@ -21,7 +21,7 @@ class SettingsAccountScreen extends StatelessWidget { supportsBlackskyAiPreferences: providerKey == AppViewProviders.blackskyKey, )..loadPreferences(), child: Scaffold( - appBar: AppBar(title: Text('$providerName settings')), + appBar: AppBar(title: Text('$providerName Settings')), body: AccountFeedDisplayPreferences(providerDisplayName: providerName), ), ); diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index a13dde5..d58055e 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -330,7 +330,7 @@ class SettingsScreen extends StatelessWidget { ); Widget _fontSizeOption(BuildContext context, AppFontSize fontSize) => Text( - context.l10n.formatFontSizeOption(_fontSizeLabel(context, fontSize), fontSize.value.toInt()), + _fontSizeLabel(context, fontSize), textAlign: TextAlign.right, style: AppTypography.content( context.fontTheme.contentFontFamily, diff --git a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart index 26675f4..21c3cfe 100644 --- a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart +++ b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart @@ -41,7 +41,7 @@ class AccountFeedDisplayPreferences extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), child: Text( - '$providerDisplayName settings', + '$providerDisplayName Settings', style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), @@ -219,7 +219,7 @@ class _BlackskyAiPreferenceTile extends StatelessWidget { ); static String _labelFor(BlackskyAiPreferenceValue value) => switch (value) { - BlackskyAiPreferenceValue.unset => 'Unset', + BlackskyAiPreferenceValue.unset => 'Not Set', BlackskyAiPreferenceValue.allow => 'Allow', BlackskyAiPreferenceValue.deny => 'Deny', }; @@ -228,37 +228,47 @@ class _BlackskyAiPreferenceTile extends StatelessWidget { class _ThreadSortTile extends StatelessWidget { const _ThreadSortTile({required this.value, required this.enabled, required this.onChanged}); - static const String _defaultSort = 'default'; - - /// TODO: this could be an enum - static const List _options = [_defaultSort, 'oldest', 'newest', 'most-likes', 'random', 'hotness']; - final ThreadViewPrefSort? value; final bool enabled; final ValueChanged onChanged; @override - Widget build(BuildContext context) => SettingsDropdownTile( + Widget build(BuildContext context) => SettingsDropdownTile<_ThreadSortOption>( title: 'Thread reply sort', subtitle: 'Choose the default order for replies in post threads.', - value: _sortValue(value), - options: _options, - labelBuilder: _labelFor, - onChanged: enabled ? (sort) => onChanged(sort == _defaultSort ? null : ThreadViewPrefSort.valueOf(sort)) : null, + value: _ThreadSortOption.fromPreference(value), + options: _ThreadSortOption.values, + labelBuilder: (option) => option.label, + onChanged: enabled ? (option) => onChanged(option?.preferenceValue) : null, ); +} - static String _sortValue(ThreadViewPrefSort? sort) => sort?.toJson() ?? _defaultSort; +enum _ThreadSortOption { + defaultSort(null, 'Default'), + oldest(KnownThreadViewPrefSort.oldest, 'Oldest first'), + newest(KnownThreadViewPrefSort.newest, 'Newest first'), + mostLikes(KnownThreadViewPrefSort.mostLikes, 'Most likes'), + random(KnownThreadViewPrefSort.random, 'Random'), + hotness(KnownThreadViewPrefSort.hotness, 'Hotness'); - /// TODO: this could be a getter on the enum made from [_options] - static String _labelFor(String sort) => switch (sort) { - _defaultSort => 'Default', - 'oldest' => 'Oldest first', - 'newest' => 'Newest first', - 'most-likes' => 'Most likes', - 'random' => 'Random', - 'hotness' => 'Hotness', - _ => sort, - }; + const _ThreadSortOption(this.knownValue, this.label); + + final KnownThreadViewPrefSort? knownValue; + final String label; + + ThreadViewPrefSort? get preferenceValue => + knownValue == null ? null : ThreadViewPrefSort.knownValue(data: knownValue!); + + static _ThreadSortOption fromPreference(ThreadViewPrefSort? sort) { + final knownValue = sort?.knownValue; + if (knownValue == null) { + return _ThreadSortOption.defaultSort; + } + return _ThreadSortOption.values.firstWhere( + (option) => option.knownValue == knownValue, + orElse: () => _ThreadSortOption.defaultSort, + ); + } } class _ReplyLikeThresholdTile extends StatelessWidget { diff --git a/lib/features/typeahead/data/typeahead_repository.dart b/lib/features/typeahead/data/typeahead_repository.dart index c5ff86c..bf56685 100644 --- a/lib/features/typeahead/data/typeahead_repository.dart +++ b/lib/features/typeahead/data/typeahead_repository.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io' as io; -import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:http/http.dart' as http; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; @@ -113,25 +112,7 @@ class TypeaheadRepository { } Future> _searchBluesky({required String query, required int limit}) async { - final authRecovery = _authRecovery; - if (authRecovery == null) { - return _searchBlueskyPublicHttp(query: query, limit: limit); - } - final headers = await _moderationService?.headersForRequest(); - - final response = await authRecovery.run( - (client) => client.actor.searchActorsTypeahead( - q: query, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint(_searchActorsTypeaheadEndpoint, headers), - ), - ); - - final results = (response.data.actors as List) - .whereType() - .map(TypeaheadResult.fromProfileViewBasic) - .toList(growable: false); - return _applyModeration(results); + return _searchBlueskyPublicHttp(query: query, limit: limit); } Future> _searchBlueskyPublicHttp({required String query, required int limit}) async { diff --git a/test/features/public/presentation/public_navigation_test.dart b/test/features/public/presentation/public_navigation_test.dart index f94ad96..b397b41 100644 --- a/test/features/public/presentation/public_navigation_test.dart +++ b/test/features/public/presentation/public_navigation_test.dart @@ -40,12 +40,14 @@ void main() { await pumpTestRouterApp(tester, router); - navigateToPublicFeed(buttonContext, _feed(), context); + final feedNavigation = navigateToPublicFeed(buttonContext, _feed(), context); await tester.pumpAndSettle(); expect(find.textContaining('provider=blacksky'), findsOneWidget); expect(find.textContaining('/feed?'), findsOneWidget); - expect(router.canPop(), isFalse); + expect(router.canPop(), isTrue); + router.pop(); + await feedNavigation; router.go('/'); await tester.pumpAndSettle(); final postNavigation = navigateToPublicPost(buttonContext, 'at://did:plc:alice/app.bsky.feed.post/abc', context); diff --git a/test/features/settings/presentation/account_feed_display_preferences_test.dart b/test/features/settings/presentation/account_feed_display_preferences_test.dart index 62fa636..6df017a 100644 --- a/test/features/settings/presentation/account_feed_display_preferences_test.dart +++ b/test/features/settings/presentation/account_feed_display_preferences_test.dart @@ -40,7 +40,7 @@ void main() { ), ); - expect(find.text('BlackSky settings'), findsOneWidget); + expect(find.text('BlackSky Settings'), findsOneWidget); expect(find.textContaining('app.bsky.actor.getPreferences'), findsOneWidget); await tester.drag(find.byType(ListView), const Offset(0, -1200)); @@ -85,16 +85,14 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, -1600)); await tester.pumpAndSettle(); - expect(find.text('BLACKSKY AI PREFERENCES'), findsOneWidget); expect(find.text('Training'), findsOneWidget); - expect(find.text('Unset'), findsAtLeastNWidgets(1)); + expect(find.text('Not Set'), findsAtLeastNWidgets(1)); - await tester.tap(find.text('Unset').last); + await tester.tap(find.text('Not Set').last); await tester.pumpAndSettle(); await tester.tap(find.text('Deny').last); await tester.pumpAndSettle(); - verify( () => cubit.setBlackskyAiPreference(BlackskyAiPreferenceCategory.embedding, BlackskyAiPreferenceValue.deny), ).called(1); diff --git a/test/features/settings/presentation/search_settings_test.dart b/test/features/settings/presentation/search_settings_test.dart index 3aa63a6..163e912 100644 --- a/test/features/settings/presentation/search_settings_test.dart +++ b/test/features/settings/presentation/search_settings_test.dart @@ -112,7 +112,7 @@ void main() { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('Semantic Search'), 300); - expect(find.text('Manage semantic search from Bookmarks & Likes -> Search'), findsOneWidget); + expect(find.text('Manage searching your bookmarks & saved posts.'), findsOneWidget); }); testWidgets('does not show semantic search controls in settings', (tester) async { diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 0b434aa..ea891d3 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -207,7 +207,8 @@ void main() { expect(find.text('Lora'), findsOneWidget); expect(find.text('Google Sans'), findsWidgets); expect(find.text('Google Sans Code'), findsOneWidget); - expect(find.text('Normal (16)'), findsOneWidget); + expect(find.text('Normal'), findsOneWidget); + expect(find.text('Normal (16)'), findsNothing); await tester.scrollUntilVisible(find.text('Feed Layout'), 300); await tester.pumpAndSettle(); @@ -245,9 +246,9 @@ void main() { final fontSizeDropdown = tester.widget>(fontSizeFinder); expect(fontSizeDropdown.alignment, AlignmentDirectional.centerEnd); final fontSizeItems = fontSizeDropdown.selectedItemBuilder!(tester.element(fontSizeFinder)); - _expectRightAlignedSelectedFontLabel(fontSizeItems[0], 'Small (14)'); - _expectRightAlignedSelectedFontLabel(fontSizeItems[1], 'Normal (16)'); - _expectRightAlignedSelectedFontLabel(fontSizeItems[2], 'Large (18)'); + _expectRightAlignedSelectedFontLabel(fontSizeItems[0], 'Small'); + _expectRightAlignedSelectedFontLabel(fontSizeItems[1], 'Normal'); + _expectRightAlignedSelectedFontLabel(fontSizeItems[2], 'Large'); fontSizeDropdown.onChanged?.call(AppFontSize.large); final codeDropdown = tester.widget>(codeFinder); diff --git a/test/features/typeahead/data/typeahead_repository_test.dart b/test/features/typeahead/data/typeahead_repository_test.dart index 11f9d9a..71acd0f 100644 --- a/test/features/typeahead/data/typeahead_repository_test.dart +++ b/test/features/typeahead/data/typeahead_repository_test.dart @@ -29,14 +29,22 @@ void main() { }); group('TypeaheadRepository', () { - test('bluesky provider delegates to SDK and applies moderation filtering', () async { - final actorService = _FakeActorService() - ..searchActorsResult = const _FakeActorsData( - actors: [ - ProfileViewBasic(did: 'did:plc:keep', handle: 'keep.bsky.social'), - ProfileViewBasic(did: 'did:plc:hide', handle: 'hide.bsky.social'), - ], + test('bluesky provider uses public HTTP endpoint and applies moderation filtering', () async { + Uri? requestedUri; + Map? requestHeaders; + final client = _CallbackClient((request) async { + requestedUri = request.url; + requestHeaders = request.headers; + return http.Response( + jsonEncode({ + 'actors': [ + {'did': 'did:plc:keep', 'handle': 'keep.bsky.social'}, + {'did': 'did:plc:hide', 'handle': 'hide.bsky.social'}, + ], + }), + 200, ); + }); when( () => moderationService.shouldFilterProfileBasicInList( @@ -45,18 +53,21 @@ void main() { ).thenReturn(true); final repository = TypeaheadRepository( - bluesky: _fakeBlueskyClient(actor: actorService), + bluesky: _fakeBlueskyClient(actor: _FakeActorService()), provider: TypeaheadRepository.blueskyProvider, moderationService: moderationService, + httpClient: client, ); final results = await repository.search(query: 'keep', limit: 5); expect(results.map((actor) => actor.did).toList(), ['did:plc:keep']); - expect(actorService.lastQuery, 'keep'); - expect(actorService.lastLimit, 5); - expect(actorService.lastHeaders?['x-test'], 'moderation'); - expect(actorService.lastHeaders?['atproto-proxy'], isNull); + expect(requestedUri?.host, 'public.api.bsky.app'); + expect(requestedUri?.path, '/xrpc/app.bsky.actor.searchActorsTypeahead'); + expect(requestedUri?.queryParameters['q'], 'keep'); + expect(requestedUri?.queryParameters['limit'], '5'); + expect(requestHeaders?['x-test'], 'moderation'); + expect(requestHeaders?['atproto-proxy'], isNull); }); test('community provider makes HTTP request, parses JSON, and applies local moderation', () async { @@ -128,7 +139,19 @@ void main() { actors: [ProfileViewBasic(did: 'did:plc:fallback', handle: 'fallback.bsky.social')], ); - final client = _CallbackClient((_) async => http.Response('upstream unavailable', 503)); + final client = _CallbackClient((request) async { + if (request.url.host == 'typeahead.waow.tech') { + return http.Response('upstream unavailable', 503); + } + return http.Response( + jsonEncode({ + 'actors': [ + {'did': 'did:plc:fallback', 'handle': 'fallback.bsky.social'}, + ], + }), + 200, + ); + }); final repository = TypeaheadRepository( bluesky: _fakeBlueskyClient(actor: actorService), @@ -140,8 +163,7 @@ void main() { final results = await repository.search(query: 'fallback', limit: 8); expect(results.map((actor) => actor.did).toList(), ['did:plc:fallback']); - expect(actorService.lastQuery, 'fallback'); - expect(actorService.lastLimit, 8); + expect(actorService.lastQuery, isNull); }); test('provider resolver picks up runtime provider changes without recreating repository', () async { @@ -152,12 +174,22 @@ void main() { ..searchActorsResult = const _FakeActorsData( actors: [ProfileViewBasic(did: 'did:plc:bluesky', handle: 'bluesky.bsky.social')], ); - final client = _CallbackClient((_) async { - communityCalls += 1; + final client = _CallbackClient((request) async { + if (request.url.host == 'typeahead.waow.tech') { + communityCalls += 1; + return http.Response( + jsonEncode({ + 'actors': [ + {'did': 'did:plc:community', 'handle': 'community.bsky.social'}, + ], + }), + 200, + ); + } return http.Response( jsonEncode({ 'actors': [ - {'did': 'did:plc:community', 'handle': 'community.bsky.social'}, + {'did': 'did:plc:bluesky', 'handle': 'bluesky.bsky.social'}, ], }), 200, @@ -181,8 +213,7 @@ void main() { final blueskyResults = await repository.search(query: 'second', limit: 4); expect(blueskyResults.map((actor) => actor.did).toList(), ['did:plc:bluesky']); expect(communityCalls, 1); - expect(actorService.lastQuery, 'second'); - expect(actorService.lastLimit, 4); + expect(actorService.lastQuery, isNull); }); test('community fallback does not trigger when no Bluesky session/client exists', () async {