♻️ Mixed accounts search

This commit is contained in:
2026-02-02 22:15:19 +08:00
parent 583902ad52
commit f6f1c99da7
3 changed files with 121 additions and 41 deletions

View File

@@ -1652,5 +1652,6 @@
"dashboardCardActivityColumnDescription": "Check In, Fortune Graph & Fortune", "dashboardCardActivityColumnDescription": "Check In, Fortune Graph & Fortune",
"dashboardCardPostsColumnDescription": "Featured Posts", "dashboardCardPostsColumnDescription": "Featured Posts",
"dashboardCardSocialColumnDescription": "Friends & Notifications", "dashboardCardSocialColumnDescription": "Friends & Notifications",
"dashboardCardChatsColumnDescription": "Recent Chats" "dashboardCardChatsColumnDescription": "Recent Chats",
"searchAccountsHint": "Search across the Solar Network and fediverse network."
} }

View File

@@ -236,13 +236,11 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) { builder: (context, state) {
final initialTab = state.uri.queryParameters['tab']; final initialTab = state.uri.queryParameters['tab'];
SearchTab tab; SearchTab tab;
if (initialTab == 'realms') { tab = switch (initialTab) {
tab = SearchTab.realms; 'realms' => SearchTab.realms,
} else if (initialTab == 'fediverse') { 'accounts' => SearchTab.accounts,
tab = SearchTab.fediverse; _ => SearchTab.posts,
} else { };
tab = SearchTab.posts;
}
return UniversalSearchScreen(initialTab: tab); return UniversalSearchScreen(initialTab: tab);
}, },
), ),

View File

@@ -5,10 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/models/activitypub.dart'; import 'package:island/models/activitypub.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/post/post_list.dart'; import 'package:island/pods/post/post_list.dart';
import 'package:island/services/activitypub_service.dart'; import 'package:island/services/activitypub_service.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/activitypub/actor_list_item.dart'; import 'package:island/widgets/activitypub/actor_list_item.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
@@ -22,7 +26,7 @@ import 'package:styled_widget/styled_widget.dart';
const kSearchPostListId = 'search'; const kSearchPostListId = 'search';
enum SearchTab { posts, fediverse, realms } enum SearchTab { posts, accounts, realms }
class UniversalSearchScreen extends HookConsumerWidget { class UniversalSearchScreen extends HookConsumerWidget {
final SearchTab initialTab; final SearchTab initialTab;
@@ -45,14 +49,18 @@ class UniversalSearchScreen extends HookConsumerWidget {
controller: tabController, controller: tabController,
tabs: [ tabs: [
Tab(text: 'posts'.tr()), Tab(text: 'posts'.tr()),
Tab(text: 'fediverseUsers'.tr()), Tab(text: 'accounts'.tr()),
Tab(text: 'realms'.tr()), Tab(text: 'realms'.tr()),
], ],
), ),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: tabController, controller: tabController,
children: [_PostsSearchTab(), _FediverseSearchTab(), _RealmsSearchTab()], children: [
_PostsSearchTab(),
_FediverseSearchTab(),
_RealmsSearchTab(),
],
), ),
), ),
], ],
@@ -103,11 +111,14 @@ class _RealmsSearchTab extends HookConsumerWidget {
if (debounceTimer?.isActive ?? false) { if (debounceTimer?.isActive ?? false) {
debounceTimer?.cancel(); debounceTimer?.cancel();
} }
debounceTimer = Timer(const Duration(milliseconds: 300), () { debounceTimer = Timer(
if (currentQuery.value != value) { const Duration(milliseconds: 300),
currentQuery.value = value; () {
} if (currentQuery.value != value) {
}); currentQuery.value = value;
}
},
);
}, },
), ),
), ),
@@ -423,7 +434,8 @@ class _FediverseSearchTab extends HookConsumerWidget {
final searchController = useTextEditingController(); final searchController = useTextEditingController();
final debounce = useMemoized(() => const Duration(milliseconds: 500)); final debounce = useMemoized(() => const Duration(milliseconds: 500));
final debounceTimer = useRef<Timer?>(null); final debounceTimer = useRef<Timer?>(null);
final searchResults = useState<List<SnActivityPubActor>>([]); final fediverseResults = useState<List<SnActivityPubActor>>([]);
final internalResults = useState<List<SnAccount>>([]);
final isSearching = useState(false); final isSearching = useState(false);
useEffect(() { useEffect(() {
@@ -435,15 +447,30 @@ class _FediverseSearchTab extends HookConsumerWidget {
Future<void> performSearch(String query) async { Future<void> performSearch(String query) async {
if (query.trim().isEmpty) { if (query.trim().isEmpty) {
searchResults.value = []; fediverseResults.value = [];
internalResults.value = [];
return; return;
} }
isSearching.value = true; isSearching.value = true;
try { try {
final service = ref.read(activityPubServiceProvider); // Search for fediverse users
final results = await service.searchUsers(query); final activityPubService = ref.read(activityPubServiceProvider);
searchResults.value = results; final fediverseFuture = activityPubService.searchUsers(query);
// Search for internal users
final internalFuture = ref.read(
searchAccountsProvider(query: query).future,
);
// Wait for both searches to complete
final [fediverseData, internalData] = await Future.wait([
fediverseFuture,
internalFuture,
]);
fediverseResults.value = fediverseData as List<SnActivityPubActor>;
internalResults.value = internalData as List<SnAccount>;
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
@@ -461,7 +488,7 @@ class _FediverseSearchTab extends HookConsumerWidget {
} }
void updateActorIsFollowing(String actorId, bool isFollowing) { void updateActorIsFollowing(String actorId, bool isFollowing) {
searchResults.value = searchResults.value fediverseResults.value = fediverseResults.value
.map( .map(
(a) => a.id == actorId ? a.copyWith(isFollowing: isFollowing) : a, (a) => a.id == actorId ? a.copyWith(isFollowing: isFollowing) : a,
) )
@@ -504,15 +531,23 @@ class _FediverseSearchTab extends HookConsumerWidget {
} }
} }
// Combine and display results - local users first
final allResults = [
...internalResults.value.map(
(account) => {'type': 'internal', 'data': account},
),
...fediverseResults.value.map(
(actor) => {'type': 'fediverse', 'data': actor},
),
];
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SearchBar( child: SearchBar(
controller: searchController, controller: searchController,
hintText: 'searchFediverseHint'.tr( hintText: 'searchAccountsHint'.tr(),
args: ['@username@instance.com'],
),
leading: const Icon(Symbols.search).padding(horizontal: 24), leading: const Icon(Symbols.search).padding(horizontal: 24),
onChanged: onSearchChanged, onChanged: onSearchChanged,
onSubmitted: (value) { onSubmitted: (value) {
@@ -524,7 +559,7 @@ class _FediverseSearchTab extends HookConsumerWidget {
Expanded( Expanded(
child: isSearching.value child: isSearching.value
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: searchResults.value.isEmpty : allResults.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -537,12 +572,12 @@ class _FediverseSearchTab extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
if (searchController.text.isEmpty) if (searchController.text.isEmpty)
Text( Text(
'searchFediverseEmpty'.tr(), 'searchUsersEmpty'.tr(),
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
) )
else else
Text( Text(
'searchFediverseNoResults'.tr(), 'searchUsersNoResults'.tr(),
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
], ],
@@ -552,22 +587,68 @@ class _FediverseSearchTab extends HookConsumerWidget {
onRefresh: () => performSearch(searchController.text), onRefresh: () => performSearch(searchController.text),
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: searchResults.value.length, itemCount: allResults.length,
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final actor = searchResults.value[index]; final result = allResults[index];
return Center( if (result['type'] == 'fediverse') {
child: ConstrainedBox( final actor = result['data'] as SnActivityPubActor;
constraints: const BoxConstraints(maxWidth: 560), return Center(
child: ApActorListItem( child: ConstrainedBox(
actor: actor, constraints: const BoxConstraints(maxWidth: 560),
isFollowing: actor.isFollowing ?? false, child: ApActorListItem(
isLoading: false, actor: actor,
onFollow: () => handleFollow(actor), isFollowing: actor.isFollowing ?? false,
onUnfollow: () => handleUnfollow(actor), isLoading: false,
onFollow: () => handleFollow(actor),
onUnfollow: () => handleUnfollow(actor),
),
), ),
), );
); } else {
final account = result['data'] as SnAccount;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListTile(
contentPadding: const EdgeInsets.only(
left: 16,
right: 12,
),
leading: Stack(
children: [
ProfilePictureWidget(
file: account.profile.picture,
),
],
),
title: AccountName(
account: account,
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Row(
children: [
Text('@${account.name}'),
if (account.profile.bio.isNotEmpty)
Expanded(
child: Text(
account.profile.bio,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall,
),
),
],
),
trailing: const SizedBox(
width: 88,
), // To align with ApActorListItem
),
),
);
}
}, },
), ),
), ),