✨ Better fediverse content
This commit is contained in:
@@ -287,7 +287,7 @@ final class AccountDirectChatProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountDirectChatHash() => r'149ea3a3730672cfbbb8c16fe1f2caa0bb9f0e17';
|
||||
String _$accountDirectChatHash() => r'71bc9eed34a436a3743e8ef87f7aaae861fc5746';
|
||||
|
||||
final class AccountDirectChatFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<SnChatRoom?>, String> {
|
||||
|
||||
@@ -141,13 +141,13 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.linked_services),
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('searchFediverse').tr(),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('activitypubSearch');
|
||||
context.pushNamed('universalSearch');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
@@ -174,18 +174,6 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
context.pushNamed('postShuffle');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: Icon(Symbols.action_key),
|
||||
tooltip: 'search'.tr(),
|
||||
@@ -554,6 +542,18 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('universalSearch');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -578,18 +578,6 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
context.pushNamed('postShuffle');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: Icon(Symbols.action_key, color: foregroundColor),
|
||||
tooltip: 'search'.tr(),
|
||||
@@ -610,27 +598,14 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
|
||||
final notifier = ref.watch(activityListProvider.notifier);
|
||||
|
||||
final activityState = ref.watch(activityListProvider);
|
||||
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: ExtendedRefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: CustomScrollView(slivers: [SliverGap(8), bodyView]),
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
if (activityState.isLoading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: ExtendedRefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: CustomScrollView(slivers: [SliverGap(8), bodyView]),
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
484
lib/screens/search.dart
Normal file
484
lib/screens/search.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/activitypub.dart';
|
||||
import 'package:island/pods/post/post_list.dart';
|
||||
import 'package:island/services/activitypub_service.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/activitypub/actor_list_item.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/widgets/paging/pagination_list.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/post_item_skeleton.dart';
|
||||
import 'package:island/widgets/posts/post_filter.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const kSearchPostListId = 'search';
|
||||
|
||||
enum SearchTab { posts, fediverse }
|
||||
|
||||
class UniversalSearchScreen extends HookConsumerWidget {
|
||||
final SearchTab initialTab;
|
||||
|
||||
const UniversalSearchScreen({super.key, this.initialTab = SearchTab.posts});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabController = useTabController(
|
||||
initialLength: 2,
|
||||
initialIndex: initialTab.index,
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: Text('search'.tr()), elevation: 0),
|
||||
body: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(text: 'posts'.tr()),
|
||||
Tab(text: 'fediverseUsers'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [_PostsSearchTab(), _FediverseSearchTab()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostsSearchTab extends HookConsumerWidget {
|
||||
const _PostsSearchTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchController = useTextEditingController();
|
||||
final debounce = useMemoized(() => Duration(milliseconds: 500));
|
||||
final debounceTimer = useRef<Timer?>(null);
|
||||
final showFilters = useState(false);
|
||||
final pubNameController = useTextEditingController();
|
||||
final realmController = useTextEditingController();
|
||||
|
||||
final categoryTabController = useTabController(initialLength: 3);
|
||||
final queryState = useState(const PostListQuery());
|
||||
|
||||
final noti = ref.read(
|
||||
postListProvider(PostListQueryConfig(id: kSearchPostListId)).notifier,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
return () {
|
||||
searchController.dispose();
|
||||
pubNameController.dispose();
|
||||
realmController.dispose();
|
||||
debounceTimer.value?.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
void onSearchChanged(String query, {bool skipDebounce = false}) {
|
||||
queryState.value = queryState.value.copyWith(queryTerm: query);
|
||||
|
||||
if (skipDebounce) {
|
||||
noti.applyFilter(queryState.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
|
||||
debounceTimer.value = Timer(debounce, () {
|
||||
noti.applyFilter(queryState.value);
|
||||
});
|
||||
}
|
||||
|
||||
void toggleFilterDisplay() {
|
||||
showFilters.value = !showFilters.value;
|
||||
}
|
||||
|
||||
Widget buildFilterPanel() {
|
||||
return PostFilterWidget(
|
||||
categoryTabController: categoryTabController,
|
||||
initialQuery: queryState.value,
|
||||
onQueryChanged: (newQuery) {
|
||||
queryState.value = newQuery;
|
||||
noti.applyFilter(newQuery);
|
||||
},
|
||||
hideSearch: true,
|
||||
);
|
||||
}
|
||||
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final searchState = ref.watch(
|
||||
postListProvider(PostListQueryConfig(id: kSearchPostListId)),
|
||||
);
|
||||
|
||||
return isWideScreen(context)
|
||||
? Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: ExtendedRefreshIndicator(
|
||||
onRefresh: noti.refresh,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverGap(16),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: SearchBar(
|
||||
elevation: WidgetStateProperty.all(4),
|
||||
controller: searchController,
|
||||
hintText: 'search'.tr(),
|
||||
leading: const Icon(Icons.search),
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
),
|
||||
onChanged: onSearchChanged,
|
||||
onSubmitted: (value) {
|
||||
onSearchChanged(value, skipDebounce: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverGap(12),
|
||||
PaginationList(
|
||||
provider: postListProvider(
|
||||
PostListQueryConfig(id: kSearchPostListId),
|
||||
),
|
||||
notifier: postListProvider(
|
||||
PostListQueryConfig(id: kSearchPostListId),
|
||||
).notifier,
|
||||
isSliver: true,
|
||||
isRefreshable: false,
|
||||
footerSkeletonChild: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: const PostItemSkeleton(),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: PostActionableItem(
|
||||
item: post,
|
||||
borderRadius: 8,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (searchState.value?.items.isEmpty == true &&
|
||||
searchController.text.isNotEmpty &&
|
||||
!searchState.isLoading)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text('noResultsFound'.tr())),
|
||||
),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
).padding(left: 8),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Gap(16),
|
||||
Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.tune,
|
||||
).padding(horizontal: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'filters'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.filter_alt,
|
||||
fill: showFilters.value ? 1 : null,
|
||||
),
|
||||
onPressed: toggleFilterDisplay,
|
||||
tooltip: 'toggleFilters'.tr(),
|
||||
),
|
||||
const Gap(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
if (showFilters.value) buildFilterPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
const SliverGap(4),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: SearchBar(
|
||||
elevation: WidgetStateProperty.all(4),
|
||||
controller: searchController,
|
||||
hintText: 'search'.tr(),
|
||||
leading: const Icon(Icons.search),
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
),
|
||||
onChanged: onSearchChanged,
|
||||
onSubmitted: (value) {
|
||||
onSearchChanged(value, skipDebounce: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showFilters.value)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: buildFilterPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
PaginationList(
|
||||
provider: postListProvider(
|
||||
PostListQueryConfig(id: kSearchPostListId),
|
||||
),
|
||||
notifier: postListProvider(
|
||||
PostListQueryConfig(id: kSearchPostListId),
|
||||
).notifier,
|
||||
isSliver: true,
|
||||
isRefreshable: false,
|
||||
footerSkeletonChild: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: const PostItemSkeleton(),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 600),
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: PostActionableItem(
|
||||
item: post,
|
||||
borderRadius: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (searchState.value?.items.isEmpty == true &&
|
||||
searchController.text.isNotEmpty &&
|
||||
!searchState.isLoading)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text('noResultsFound'.tr())),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FediverseSearchTab extends HookConsumerWidget {
|
||||
const _FediverseSearchTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchController = useTextEditingController();
|
||||
final debounce = useMemoized(() => const Duration(milliseconds: 500));
|
||||
final debounceTimer = useRef<Timer?>(null);
|
||||
final searchResults = useState<List<SnActivityPubActor>>([]);
|
||||
final isSearching = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
return () {
|
||||
searchController.dispose();
|
||||
debounceTimer.value?.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
Future<void> performSearch(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
try {
|
||||
final service = ref.read(activityPubServiceProvider);
|
||||
final results = await service.searchUsers(query);
|
||||
searchResults.value = results;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onSearchChanged(String query) {
|
||||
if (debounceTimer.value?.isActive ?? false) {
|
||||
debounceTimer.value!.cancel();
|
||||
}
|
||||
debounceTimer.value = Timer(debounce, () {
|
||||
performSearch(query);
|
||||
});
|
||||
}
|
||||
|
||||
void updateActorIsFollowing(String actorId, bool isFollowing) {
|
||||
searchResults.value = searchResults.value
|
||||
.map(
|
||||
(a) => a.id == actorId ? a.copyWith(isFollowing: isFollowing) : a,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> handleFollow(SnActivityPubActor actor) async {
|
||||
try {
|
||||
updateActorIsFollowing(actor.id, true);
|
||||
final service = ref.read(activityPubServiceProvider);
|
||||
await service.followRemoteUser(actor.uri);
|
||||
showSnackBar(
|
||||
'followedUser'.tr(
|
||||
args: [
|
||||
'${actor.username?.isNotEmpty ?? false ? actor.username : actor.displayName}',
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
updateActorIsFollowing(actor.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleUnfollow(SnActivityPubActor actor) async {
|
||||
try {
|
||||
updateActorIsFollowing(actor.id, false);
|
||||
final service = ref.read(activityPubServiceProvider);
|
||||
await service.unfollowRemoteUser(actor.uri);
|
||||
showSnackBar(
|
||||
'unfollowedUser'.tr(
|
||||
args: [
|
||||
'${actor.username?.isNotEmpty ?? false ? actor.username : actor.displayName}',
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
updateActorIsFollowing(actor.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SearchBar(
|
||||
controller: searchController,
|
||||
hintText: 'searchFediverseHint'.tr(
|
||||
args: ['@username@instance.com'],
|
||||
),
|
||||
leading: const Icon(Symbols.search).padding(horizontal: 24),
|
||||
onChanged: onSearchChanged,
|
||||
onSubmitted: (value) {
|
||||
onSearchChanged(value);
|
||||
performSearch(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: isSearching.value
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: searchResults.value.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.search,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (searchController.text.isEmpty)
|
||||
Text(
|
||||
'searchFediverseEmpty'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'searchFediverseNoResults'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ExtendedRefreshIndicator(
|
||||
onRefresh: () => performSearch(searchController.text),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: searchResults.value.length,
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
itemBuilder: (context, index) {
|
||||
final actor = searchResults.value[index];
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ApActorListItem(
|
||||
actor: actor,
|
||||
isFollowing: actor.isFollowing ?? false,
|
||||
isLoading: false,
|
||||
onFollow: () => handleFollow(actor),
|
||||
onUnfollow: () => handleUnfollow(actor),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -794,6 +794,22 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Show fediverse content settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsShowFediverseContent').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.public),
|
||||
trailing: Switch(
|
||||
value: settings.showFediverseContent,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.setShowFediverseContent(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Default screen settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
|
||||
Reference in New Issue
Block a user