diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index ee5c2ff..2dadc3c 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -40,9 +40,9 @@ Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) ### Core -- [ ] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` -- [ ] `MyListsCubit` — load user's lists via `getLists` -- [ ] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern +- [x] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` +- [x] `MyListsCubit` — load user's lists via `getLists` +- [x] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern ### List CRUD diff --git a/lib/features/lists/bloc/list_bloc.dart b/lib/features/lists/bloc/list_bloc.dart new file mode 100644 index 0000000..b0be041 --- /dev/null +++ b/lib/features/lists/bloc/list_bloc.dart @@ -0,0 +1,176 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; + +part 'list_event.dart'; +part 'list_state.dart'; + +class ListBloc extends Bloc { + ListBloc({required ListRepository listRepository}) + : _listRepository = listRepository, + super(const ListState.initial()) { + on(_onListRequested); + on(_onListRefreshed); + on(_onListItemAdded); + on(_onListItemRemoved); + on(_onListMuted); + on(_onListUnmuted); + on(_onListBlocked); + on(_onListUnblocked); + } + + final ListRepository _listRepository; + + Future _onListRequested(ListRequested event, Emitter emit) async { + emit(ListState.loading(listUri: event.listUri, limit: event.limit)); + + try { + final result = await _listRepository.getList(listUri: event.listUri, limit: event.limit); + + emit( + ListState.loaded( + listUri: event.listUri, + list: result.list, + items: result.items, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: event.limit, + ), + ); + } catch (error) { + emit(ListState.error(message: 'Failed to load list: $error', listUri: event.listUri, limit: event.limit)); + } + } + + Future _onListRefreshed(ListRefreshed event, Emitter emit) async { + if (state.listUri == null) { + return; + } + + await _reloadList(emit, isRefreshing: true, errorPrefix: 'Failed to refresh list'); + } + + Future _onListItemAdded(ListItemAdded event, Emitter emit) async { + if (state.listUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.addListItem(listUri: state.listUri!, subjectDid: event.subjectDid), + errorPrefix: 'Failed to add member', + ); + } + + Future _onListItemRemoved(ListItemRemoved event, Emitter emit) async { + if (state.listUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.removeListItem(listItemUri: event.listItemUri), + errorPrefix: 'Failed to remove member', + ); + } + + Future _onListMuted(ListMuted event, Emitter emit) async { + if (state.listUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.muteList(listUri: state.listUri!), + errorPrefix: 'Failed to mute list', + ); + } + + Future _onListUnmuted(ListUnmuted event, Emitter emit) async { + if (state.listUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.unmuteList(listUri: state.listUri!), + errorPrefix: 'Failed to unmute list', + ); + } + + Future _onListBlocked(ListBlocked event, Emitter emit) async { + if (state.listUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.blockList(listUri: state.listUri!), + errorPrefix: 'Failed to block list', + ); + } + + Future _onListUnblocked(ListUnblocked event, Emitter emit) async { + final blockUri = state.list?.viewer?.blocked; + if (blockUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + action: () => _listRepository.unblockList(blockUri: blockUri), + errorPrefix: 'Failed to unblock list', + ); + } + + Future _runMutation( + Emitter emit, { + required Future Function() action, + required String errorPrefix, + }) async { + if (state.status != ListStatus.loaded || state.listUri == null) { + return; + } + + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + await action(); + await _reloadList(emit, isRefreshing: false, isMutating: true, errorPrefix: errorPrefix); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: '$errorPrefix: $error')); + } + } + + Future _reloadList( + Emitter emit, { + required bool isRefreshing, + required String errorPrefix, + bool isMutating = false, + }) async { + final listUri = state.listUri; + if (listUri == null) { + return; + } + + emit(state.copyWith(isRefreshing: isRefreshing, isMutating: isMutating, errorMessage: null)); + + try { + final result = await _listRepository.getList(listUri: listUri, limit: state.limit); + emit( + ListState.loaded( + listUri: listUri, + list: result.list, + items: result.items, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: state.limit, + ), + ); + } catch (error) { + emit(state.copyWith(isRefreshing: false, isMutating: false, errorMessage: '$errorPrefix: $error')); + } + } +} diff --git a/lib/features/lists/bloc/list_event.dart b/lib/features/lists/bloc/list_event.dart new file mode 100644 index 0000000..fa41b6d --- /dev/null +++ b/lib/features/lists/bloc/list_event.dart @@ -0,0 +1,56 @@ +part of 'list_bloc.dart'; + +sealed class ListEvent extends Equatable { + const ListEvent(); + + @override + List get props => []; +} + +final class ListRequested extends ListEvent { + const ListRequested({required this.listUri, this.limit = 50}); + + final AtUri listUri; + final int limit; + + @override + List get props => [listUri, limit]; +} + +final class ListRefreshed extends ListEvent { + const ListRefreshed(); +} + +final class ListItemAdded extends ListEvent { + const ListItemAdded({required this.subjectDid}); + + final String subjectDid; + + @override + List get props => [subjectDid]; +} + +final class ListItemRemoved extends ListEvent { + const ListItemRemoved({required this.listItemUri}); + + final AtUri listItemUri; + + @override + List get props => [listItemUri]; +} + +final class ListMuted extends ListEvent { + const ListMuted(); +} + +final class ListUnmuted extends ListEvent { + const ListUnmuted(); +} + +final class ListBlocked extends ListEvent { + const ListBlocked(); +} + +final class ListUnblocked extends ListEvent { + const ListUnblocked(); +} diff --git a/lib/features/lists/bloc/list_feed_bloc.dart b/lib/features/lists/bloc/list_feed_bloc.dart new file mode 100644 index 0000000..4f7a11a --- /dev/null +++ b/lib/features/lists/bloc/list_feed_bloc.dart @@ -0,0 +1,84 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; + +part 'list_feed_event.dart'; +part 'list_feed_state.dart'; + +class ListFeedBloc extends Bloc { + ListFeedBloc({required ListRepository listRepository}) + : _listRepository = listRepository, + super(const ListFeedState.initial()) { + on(_onListFeedRequested); + on(_onListFeedLoadMoreRequested); + on(_onListFeedRefreshed); + } + + final ListRepository _listRepository; + + Future _onListFeedRequested(ListFeedRequested event, Emitter emit) async { + emit(ListFeedState.loading(listUri: event.listUri)); + + try { + final result = await _listRepository.getListFeed(listUri: event.listUri, limit: event.limit); + + emit( + ListFeedState.loaded( + listUri: event.listUri, + posts: result.posts, + cursor: result.cursor, + hasMore: result.cursor != null, + ), + ); + } catch (error) { + emit(ListFeedState.error('Failed to load list feed: $error', listUri: event.listUri)); + } + } + + Future _onListFeedLoadMoreRequested(ListFeedLoadMoreRequested event, Emitter emit) async { + if (state.status != ListFeedStatus.loaded || state.listUri == null || state.cursor == null || state.isLoadingMore) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final result = await _listRepository.getListFeed( + listUri: state.listUri!, + cursor: state.cursor, + limit: event.limit, + ); + + emit( + state.copyWith( + posts: [...state.posts, ...result.posts], + cursor: result.cursor, + hasMore: result.cursor != null, + isLoadingMore: false, + ), + ); + } catch (_) { + emit(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } + + Future _onListFeedRefreshed(ListFeedRefreshed event, Emitter emit) async { + if (state.status != ListFeedStatus.loaded || state.listUri == null) { + return; + } + + emit(state.copyWith(isRefreshing: true)); + + try { + final result = await _listRepository.getListFeed(listUri: state.listUri!, limit: event.limit); + + emit( + state.copyWith(posts: result.posts, cursor: result.cursor, hasMore: result.cursor != null, isRefreshing: false), + ); + } catch (_) { + emit(state.copyWith(isRefreshing: false)); + } + } +} diff --git a/lib/features/lists/bloc/list_feed_event.dart b/lib/features/lists/bloc/list_feed_event.dart new file mode 100644 index 0000000..39bf3eb --- /dev/null +++ b/lib/features/lists/bloc/list_feed_event.dart @@ -0,0 +1,36 @@ +part of 'list_feed_bloc.dart'; + +sealed class ListFeedEvent extends Equatable { + const ListFeedEvent(); + + @override + List get props => []; +} + +final class ListFeedRequested extends ListFeedEvent { + const ListFeedRequested({required this.listUri, this.limit = 50}); + + final AtUri listUri; + final int limit; + + @override + List get props => [listUri, limit]; +} + +final class ListFeedLoadMoreRequested extends ListFeedEvent { + const ListFeedLoadMoreRequested({this.limit = 50}); + + final int limit; + + @override + List get props => [limit]; +} + +final class ListFeedRefreshed extends ListFeedEvent { + const ListFeedRefreshed({this.limit = 50}); + + final int limit; + + @override + List get props => [limit]; +} diff --git a/lib/features/lists/bloc/list_feed_state.dart b/lib/features/lists/bloc/list_feed_state.dart new file mode 100644 index 0000000..3e5fcfd --- /dev/null +++ b/lib/features/lists/bloc/list_feed_state.dart @@ -0,0 +1,82 @@ +part of 'list_feed_bloc.dart'; + +enum ListFeedStatus { initial, loading, loaded, error } + +class ListFeedState extends Equatable { + const ListFeedState._({ + required this.status, + this.listUri, + this.posts = const [], + this.cursor, + this.hasMore = false, + this.isRefreshing = false, + this.isLoadingMore = false, + this.errorMessage, + }); + + const ListFeedState.initial() : this._(status: ListFeedStatus.initial); + + const ListFeedState.loading({required AtUri listUri}) : this._(status: ListFeedStatus.loading, listUri: listUri); + + const ListFeedState.loaded({ + required AtUri listUri, + required List posts, + String? cursor, + required bool hasMore, + bool isRefreshing = false, + bool isLoadingMore = false, + String? errorMessage, + }) : this._( + status: ListFeedStatus.loaded, + listUri: listUri, + posts: posts, + cursor: cursor, + hasMore: hasMore, + isRefreshing: isRefreshing, + isLoadingMore: isLoadingMore, + errorMessage: errorMessage, + ); + + const ListFeedState.error(String message, {AtUri? listUri}) + : this._(status: ListFeedStatus.error, listUri: listUri, errorMessage: message); + + final ListFeedStatus status; + final AtUri? listUri; + final List posts; + final String? cursor; + final bool hasMore; + final bool isRefreshing; + final bool isLoadingMore; + final String? errorMessage; + + bool get isLoading => status == ListFeedStatus.loading; + bool get hasError => status == ListFeedStatus.error; + bool get hasPosts => posts.isNotEmpty; + + ListFeedState copyWith({ + ListFeedStatus? status, + Object? listUri = _listFeedNoValue, + List? posts, + Object? cursor = _listFeedNoValue, + bool? hasMore, + bool? isRefreshing, + bool? isLoadingMore, + Object? errorMessage = _listFeedNoValue, + }) { + return ListFeedState._( + status: status ?? this.status, + listUri: listUri == _listFeedNoValue ? this.listUri : listUri as AtUri?, + posts: posts ?? this.posts, + cursor: cursor == _listFeedNoValue ? this.cursor : cursor as String?, + hasMore: hasMore ?? this.hasMore, + isRefreshing: isRefreshing ?? this.isRefreshing, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage == _listFeedNoValue ? this.errorMessage : errorMessage as String?, + ); + } + + @override + List get props => [status, listUri, posts, cursor, hasMore, isRefreshing, isLoadingMore, errorMessage]; +} + +const _listFeedNoValue = Object(); diff --git a/lib/features/lists/bloc/list_state.dart b/lib/features/lists/bloc/list_state.dart new file mode 100644 index 0000000..ec76e3d --- /dev/null +++ b/lib/features/lists/bloc/list_state.dart @@ -0,0 +1,106 @@ +part of 'list_bloc.dart'; + +enum ListStatus { initial, loading, loaded, error } + +class ListState extends Equatable { + const ListState._({ + required this.status, + this.listUri, + this.list, + this.items = const [], + this.cursor, + this.hasMore = false, + this.limit = 50, + this.isRefreshing = false, + this.isMutating = false, + this.errorMessage, + }); + + const ListState.initial() : this._(status: ListStatus.initial); + + const ListState.loading({required AtUri listUri, int limit = 50}) + : this._(status: ListStatus.loading, listUri: listUri, limit: limit); + + const ListState.loaded({ + required AtUri listUri, + required ListView list, + required List items, + String? cursor, + required bool hasMore, + int limit = 50, + bool isRefreshing = false, + bool isMutating = false, + String? errorMessage, + }) : this._( + status: ListStatus.loaded, + listUri: listUri, + list: list, + items: items, + cursor: cursor, + hasMore: hasMore, + limit: limit, + isRefreshing: isRefreshing, + isMutating: isMutating, + errorMessage: errorMessage, + ); + + const ListState.error({required String message, AtUri? listUri, int limit = 50}) + : this._(status: ListStatus.error, listUri: listUri, limit: limit, errorMessage: message); + + final ListStatus status; + final AtUri? listUri; + final ListView? list; + final List items; + final String? cursor; + final bool hasMore; + final int limit; + final bool isRefreshing; + final bool isMutating; + final String? errorMessage; + + bool get isLoading => status == ListStatus.loading; + bool get hasError => status == ListStatus.error || errorMessage != null; + bool get hasItems => items.isNotEmpty; + + ListState copyWith({ + ListStatus? status, + Object? listUri = _listNoValue, + Object? list = _listNoValue, + List? items, + Object? cursor = _listNoValue, + bool? hasMore, + int? limit, + bool? isRefreshing, + bool? isMutating, + Object? errorMessage = _listNoValue, + }) { + return ListState._( + status: status ?? this.status, + listUri: listUri == _listNoValue ? this.listUri : listUri as AtUri?, + list: list == _listNoValue ? this.list : list as ListView?, + items: items ?? this.items, + cursor: cursor == _listNoValue ? this.cursor : cursor as String?, + hasMore: hasMore ?? this.hasMore, + limit: limit ?? this.limit, + isRefreshing: isRefreshing ?? this.isRefreshing, + isMutating: isMutating ?? this.isMutating, + errorMessage: errorMessage == _listNoValue ? this.errorMessage : errorMessage as String?, + ); + } + + @override + List get props => [ + status, + listUri, + list, + items, + cursor, + hasMore, + limit, + isRefreshing, + isMutating, + errorMessage, + ]; +} + +const _listNoValue = Object(); diff --git a/lib/features/lists/cubit/my_lists_cubit.dart b/lib/features/lists/cubit/my_lists_cubit.dart new file mode 100644 index 0000000..67e0bcf --- /dev/null +++ b/lib/features/lists/cubit/my_lists_cubit.dart @@ -0,0 +1,79 @@ +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; + +part 'my_lists_state.dart'; + +class MyListsCubit extends Cubit { + MyListsCubit({required ListRepository listRepository}) + : _listRepository = listRepository, + super(const MyListsState.initial()); + + final ListRepository _listRepository; + + Future load({required String actor, int limit = 50}) async { + emit(MyListsState.loading(actor: actor, limit: limit)); + + try { + final result = await _listRepository.getLists(actor: actor, limit: limit); + emit( + MyListsState.loaded( + actor: actor, + lists: result.lists, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: limit, + ), + ); + } catch (error) { + emit(MyListsState.error(message: 'Failed to load lists: $error', actor: actor, limit: limit)); + } + } + + Future refresh() async { + if (state.actor == null) { + return; + } + + emit(state.copyWith(isRefreshing: true, errorMessage: null)); + + try { + final result = await _listRepository.getLists(actor: state.actor!, limit: state.limit); + emit( + MyListsState.loaded( + actor: state.actor!, + lists: result.lists, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: state.limit, + ), + ); + } catch (error) { + emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); + } + } + + Future loadMore() async { + if (state.status != MyListsStatus.loaded || state.actor == null || state.cursor == null || state.isLoadingMore) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final result = await _listRepository.getLists(actor: state.actor!, cursor: state.cursor, limit: state.limit); + + emit( + state.copyWith( + lists: [...state.lists, ...result.lists], + cursor: result.cursor, + hasMore: result.cursor != null, + isLoadingMore: false, + ), + ); + } catch (_) { + emit(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } +} diff --git a/lib/features/lists/cubit/my_lists_state.dart b/lib/features/lists/cubit/my_lists_state.dart new file mode 100644 index 0000000..367233a --- /dev/null +++ b/lib/features/lists/cubit/my_lists_state.dart @@ -0,0 +1,101 @@ +part of 'my_lists_cubit.dart'; + +enum MyListsStatus { initial, loading, loaded, error } + +class MyListsState extends Equatable { + const MyListsState._({ + required this.status, + this.actor, + this.lists = const [], + this.cursor, + this.hasMore = false, + this.limit = 50, + this.isRefreshing = false, + this.isLoadingMore = false, + this.errorMessage, + }); + + const MyListsState.initial() : this._(status: MyListsStatus.initial); + + const MyListsState.loading({required String actor, int limit = 50}) + : this._(status: MyListsStatus.loading, actor: actor, limit: limit); + + const MyListsState.loaded({ + required String actor, + required List lists, + String? cursor, + required bool hasMore, + int limit = 50, + bool isRefreshing = false, + bool isLoadingMore = false, + String? errorMessage, + }) : this._( + status: MyListsStatus.loaded, + actor: actor, + lists: lists, + cursor: cursor, + hasMore: hasMore, + limit: limit, + isRefreshing: isRefreshing, + isLoadingMore: isLoadingMore, + errorMessage: errorMessage, + ); + + const MyListsState.error({required String message, String? actor, int limit = 50}) + : this._(status: MyListsStatus.error, actor: actor, limit: limit, errorMessage: message); + + final MyListsStatus status; + final String? actor; + final List lists; + final String? cursor; + final bool hasMore; + final int limit; + final bool isRefreshing; + final bool isLoadingMore; + final String? errorMessage; + + List get curationLists => lists.where(_isCurationList).toList(growable: false); + List get moderationLists => lists.where(_isModerationList).toList(growable: false); + List get referenceLists => lists.where(_isReferenceList).toList(growable: false); + + MyListsState copyWith({ + MyListsStatus? status, + Object? actor = _myListsNoValue, + List? lists, + Object? cursor = _myListsNoValue, + bool? hasMore, + int? limit, + bool? isRefreshing, + bool? isLoadingMore, + Object? errorMessage = _myListsNoValue, + }) { + return MyListsState._( + status: status ?? this.status, + actor: actor == _myListsNoValue ? this.actor : actor as String?, + lists: lists ?? this.lists, + cursor: cursor == _myListsNoValue ? this.cursor : cursor as String?, + hasMore: hasMore ?? this.hasMore, + limit: limit ?? this.limit, + isRefreshing: isRefreshing ?? this.isRefreshing, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage == _myListsNoValue ? this.errorMessage : errorMessage as String?, + ); + } + + @override + List get props => [status, actor, lists, cursor, hasMore, limit, isRefreshing, isLoadingMore, errorMessage]; +} + +bool _isCurationList(ListView list) { + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsCuratelist; +} + +bool _isModerationList(ListView list) { + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsModlist; +} + +bool _isReferenceList(ListView list) { + return list.purpose.knownValue == KnownListPurpose.appBskyGraphDefsReferencelist; +} + +const _myListsNoValue = Object(); diff --git a/lib/features/lists/data/list_repository.dart b/lib/features/lists/data/list_repository.dart new file mode 100644 index 0000000..2bbf7e9 --- /dev/null +++ b/lib/features/lists/data/list_repository.dart @@ -0,0 +1,201 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:bluesky/app_bsky_graph_getlists.dart'; +import 'package:bluesky/app_bsky_graph_getlistswithmembership.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; + +class ListRepository { + ListRepository({required dynamic bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; + + final dynamic _bluesky; + final ModerationService? _moderationService; + + Future getLists({ + required String actor, + String? cursor, + int limit = 50, + bool includeReference = false, + }) async { + final response = await _bluesky.graph.getLists( + actor: actor, + cursor: cursor, + limit: limit, + purposes: includeReference ? null : _listPurposes, + $headers: await _moderationService?.headersForRequest(), + ); + + return ListsResult(lists: _filterLists(response.data.lists), cursor: response.data.cursor); + } + + Future getList({required AtUri listUri, String? cursor, int limit = 50}) async { + final response = await _bluesky.graph.getList( + list: listUri, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return ListDetailResult( + list: response.data.list, + items: _filterListItems(response.data.items), + cursor: response.data.cursor, + ); + } + + Future getListFeed({required AtUri listUri, String? cursor, int limit = 50}) async { + final response = await _bluesky.feed.getListFeed( + list: listUri, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return ListFeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + } + + Future getListsWithMembership({ + required String actor, + String? cursor, + int limit = 50, + }) async { + final response = await _bluesky.graph.getListsWithMembership( + actor: actor, + cursor: cursor, + limit: limit, + purposes: _membershipPurposes, + $headers: await _moderationService?.headersForRequest(), + ); + + return ListsWithMembershipResult( + lists: response.data.listsWithMembership.where((entry) => !_shouldFilterList(entry.list)).toList(growable: false), + cursor: response.data.cursor, + ); + } + + Future> searchActorsTypeahead({required String query, int limit = 10}) async { + final response = await _bluesky.actor.searchActorsTypeahead( + q: query, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return _filterProfiles(response.data.actors); + } + + Future addListItem({required AtUri listUri, required String subjectDid}) async { + final response = await _bluesky.graph.listitem.create( + list: listUri, + subject: subjectDid, + createdAt: DateTime.now(), + ); + + return response.data.uri.toString(); + } + + Future removeListItem({required AtUri listItemUri}) async { + await _bluesky.graph.listitem.delete(rkey: listItemUri.rkey); + } + + Future muteList({required AtUri listUri}) async { + await _bluesky.graph.muteActorList(list: listUri); + } + + Future unmuteList({required AtUri listUri}) async { + await _bluesky.graph.unmuteActorList(list: listUri); + } + + Future blockList({required AtUri listUri}) async { + final response = await _bluesky.graph.listblock.create(subject: listUri, createdAt: DateTime.now()); + + return response.data.uri.toString(); + } + + Future unblockList({required AtUri blockUri}) async { + await _bluesky.graph.listblock.delete(rkey: blockUri.rkey); + } + + List _filterLists(List lists) { + return lists.where((list) => !_shouldFilterList(list)).toList(growable: false); + } + + List _filterListItems(List items) { + final moderationService = _moderationService; + if (moderationService == null) { + return items; + } + + return items.where((item) => !moderationService.shouldFilterProfileInList(item.subject)).toList(growable: false); + } + + List _filterProfiles(List profiles) { + final moderationService = _moderationService; + if (moderationService == null) { + return profiles; + } + + return profiles + .where((profile) => !moderationService.shouldFilterProfileBasicInList(profile)) + .toList(growable: false); + } + + List _filterFeedPosts(List posts) { + final moderationService = _moderationService; + if (moderationService == null) { + return posts; + } + + return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(growable: false); + } + + bool _shouldFilterList(ListView list) { + final moderationService = _moderationService; + if (moderationService == null) { + return false; + } + + return moderationService.shouldFilterProfileInList(list.creator); + } + + static const List _listPurposes = [ + GraphGetListsPurposes.knownValue(data: KnownGraphGetListsPurposes.curatelist), + GraphGetListsPurposes.knownValue(data: KnownGraphGetListsPurposes.modlist), + ]; + + static const List _membershipPurposes = [ + GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.curatelist), + GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.modlist), + ]; +} + +class ListsResult { + const ListsResult({required this.lists, this.cursor}); + + final List lists; + final String? cursor; +} + +class ListDetailResult { + const ListDetailResult({required this.list, required this.items, this.cursor}); + + final ListView list; + final List items; + final String? cursor; +} + +class ListFeedResult { + const ListFeedResult({required this.posts, this.cursor}); + + final List posts; + final String? cursor; +} + +class ListsWithMembershipResult { + const ListsWithMembershipResult({required this.lists, this.cursor}); + + final List lists; + final String? cursor; +} diff --git a/lib/main.dart b/lib/main.dart index 1e89a5a..88e4a18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; @@ -157,6 +158,10 @@ class _LazuriteAppState extends State { create: (context) => SearchRepository(bluesky: bluesky, moderationService: context.read()), ), + RepositoryProvider( + create: (context) => + ListRepository(bluesky: bluesky, moderationService: context.read()), + ), RepositoryProvider( create: (context) => ProfileRepository( database: widget.database, diff --git a/test/features/lists/bloc/list_bloc_test.dart b/test/features/lists/bloc/list_bloc_test.dart new file mode 100644 index 0000000..bca5ba7 --- /dev/null +++ b/test/features/lists/bloc/list_bloc_test.dart @@ -0,0 +1,234 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/lists/bloc/list_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockListRepository extends Mock implements ListRepository {} + +void main() { + late MockListRepository mockListRepository; + + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); + final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); + final firstItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); + final secondItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-2'); + + setUpAll(() { + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listblock/fallback')); + }); + + setUp(() { + mockListRepository = MockListRepository(); + }); + + group('ListBloc', () { + final initialList = _buildListView(listUri: listUri); + final blockedList = _buildListView(listUri: listUri, blockUri: blockUri); + final firstItem = _buildListItem(uri: firstItemUri, did: 'did:plc:member-1', handle: 'member1.bsky.social'); + final secondItem = _buildListItem(uri: secondItemUri, did: 'did:plc:member-2', handle: 'member2.bsky.social'); + + blocTest( + 'loads a list and its members', + build: () => ListBloc(listRepository: mockListRepository), + setUp: () { + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 25, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: 'cursor-1')); + }, + act: (bloc) => bloc.add(ListRequested(listUri: listUri, limit: 25)), + expect: () => [ + ListState.loading(listUri: listUri, limit: 25), + ListState.loaded( + listUri: listUri, + list: initialList, + items: [firstItem], + cursor: 'cursor-1', + hasMore: true, + limit: 25, + ), + ], + ); + + blocTest( + 'refreshes the active list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem, secondItem], cursor: null)); + }, + act: (bloc) => bloc.add(const ListRefreshed()), + expect: () => [ + predicate((state) => state.isRefreshing && !state.isMutating), + predicate((state) => state.items.length == 2 && !state.isRefreshing && !state.hasMore), + ], + ); + + blocTest( + 'adds a member and rehydrates the list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when( + () => mockListRepository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-2'), + ).thenAnswer((_) async => secondItemUri.toString()); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem, secondItem], cursor: null)); + }, + act: (bloc) => bloc.add(const ListItemAdded(subjectDid: 'did:plc:member-2')), + expect: () => [ + predicate((state) => state.isMutating && !state.isRefreshing), + predicate((state) => !state.isMutating && state.items.length == 2), + ], + verify: (_) { + verify(() => mockListRepository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-2')).called(1); + }, + ); + + blocTest( + 'removes a member and rehydrates the list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => ListState.loaded( + listUri: listUri, + list: initialList, + items: [firstItem, secondItem], + cursor: null, + hasMore: false, + ), + setUp: () { + when(() => mockListRepository.removeListItem(listItemUri: secondItemUri)).thenAnswer((_) async {}); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); + }, + act: (bloc) => bloc.add(ListItemRemoved(listItemUri: secondItemUri)), + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => !state.isMutating && state.items.length == 1), + ], + ); + + blocTest( + 'mutes and unmutes the active list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.muteList(listUri: listUri)).thenAnswer((_) async {}); + when(() => mockListRepository.unmuteList(listUri: listUri)).thenAnswer((_) async {}); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer( + (_) async => ListDetailResult( + list: _buildListView(listUri: listUri, isMuted: true), + items: [firstItem], + cursor: null, + ), + ); + }, + act: (bloc) async { + bloc.add(const ListMuted()); + await Future.delayed(Duration.zero); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); + bloc.add(const ListUnmuted()); + }, + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => state.list?.viewer?.isMuted ?? false), + predicate((state) => state.isMutating), + predicate((state) => !(state.list?.viewer?.isMuted ?? false)), + ], + ); + + blocTest( + 'blocks and unblocks the active list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.blockList(listUri: listUri)).thenAnswer((_) async => blockUri.toString()); + when(() => mockListRepository.unblockList(blockUri: blockUri)).thenAnswer((_) async {}); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: blockedList, items: [firstItem], cursor: null)); + }, + act: (bloc) async { + bloc.add(const ListBlocked()); + await Future.delayed(Duration.zero); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); + bloc.add(const ListUnblocked()); + }, + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => state.list?.viewer?.blocked == blockUri), + predicate((state) => state.isMutating), + predicate((state) => state.list?.viewer?.blocked == null), + ], + ); + }); +} + +ListView _buildListView({required AtUri listUri, bool isMuted = false, AtUri? blockUri}) { + return ListView( + uri: listUri, + cid: 'cid-list', + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), + name: 'Core List', + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), + viewer: ListViewerState(muted: isMuted, blocked: blockUri), + indexedAt: DateTime.utc(2026, 3, 21), + ); +} + +ListItemView _buildListItem({required AtUri uri, required String did, required String handle}) { + return ListItemView( + uri: uri, + subject: ProfileView(did: did, handle: handle), + ); +} diff --git a/test/features/lists/bloc/list_feed_bloc_test.dart b/test/features/lists/bloc/list_feed_bloc_test.dart new file mode 100644 index 0000000..1ca8712 --- /dev/null +++ b/test/features/lists/bloc/list_feed_bloc_test.dart @@ -0,0 +1,96 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/lists/bloc/list_feed_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockListRepository extends Mock implements ListRepository {} + +void main() { + late MockListRepository mockListRepository; + + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); + + setUpAll(() { + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); + }); + + setUp(() { + mockListRepository = MockListRepository(); + }); + + group('ListFeedBloc', () { + final firstPost = _buildPost('at://did:plc:member-1/app.bsky.feed.post/post-1', 'First post'); + final secondPost = _buildPost('at://did:plc:member-2/app.bsky.feed.post/post-2', 'Second post'); + + blocTest( + 'loads the list feed', + build: () => ListFeedBloc(listRepository: mockListRepository), + setUp: () { + when( + () => mockListRepository.getListFeed( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 25, + ), + ).thenAnswer((_) async => ListFeedResult(posts: [firstPost], cursor: 'cursor-1')); + }, + act: (bloc) => bloc.add(ListFeedRequested(listUri: listUri, limit: 25)), + expect: () => [ + ListFeedState.loading(listUri: listUri), + ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), + ], + ); + + blocTest( + 'loads more feed items using the tracked list uri', + build: () => ListFeedBloc(listRepository: mockListRepository), + seed: () => ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockListRepository.getListFeed(listUri: listUri, cursor: 'cursor-1', limit: 50), + ).thenAnswer((_) async => ListFeedResult(posts: [secondPost], cursor: null)); + }, + act: (bloc) => bloc.add(const ListFeedLoadMoreRequested()), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate((state) => !state.isLoadingMore && state.posts.length == 2 && !state.hasMore), + ], + ); + + blocTest( + 'refreshes the current list feed', + build: () => ListFeedBloc(listRepository: mockListRepository), + seed: () => ListFeedState.loaded(listUri: listUri, posts: [firstPost], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockListRepository.getListFeed( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 10, + ), + ).thenAnswer((_) async => ListFeedResult(posts: [secondPost], cursor: null)); + }, + act: (bloc) => bloc.add(const ListFeedRefreshed(limit: 10)), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate((state) => !state.isRefreshing && state.posts.single == secondPost), + ], + ); + }); +} + +FeedViewPost _buildPost(String uri, String text) { + return FeedViewPost( + post: PostView( + uri: AtUri.parse(uri), + cid: 'cid-${uri.hashCode}', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 21).toIso8601String()}, + indexedAt: DateTime.utc(2026, 3, 21), + ), + ); +} diff --git a/test/features/lists/cubit/my_lists_cubit_test.dart b/test/features/lists/cubit/my_lists_cubit_test.dart new file mode 100644 index 0000000..894e9a3 --- /dev/null +++ b/test/features/lists/cubit/my_lists_cubit_test.dart @@ -0,0 +1,123 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockListRepository extends Mock implements ListRepository {} + +void main() { + late MockListRepository mockListRepository; + + const actor = 'did:plc:creator'; + final curationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/curation'); + final moderationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/moderation'); + final referenceListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/reference'); + + setUp(() { + mockListRepository = MockListRepository(); + }); + + group('MyListsCubit', () { + final curationList = _buildList( + uri: curationListUri, + purpose: KnownListPurpose.appBskyGraphDefsCuratelist, + name: 'Curation', + ); + final moderationList = _buildList( + uri: moderationListUri, + purpose: KnownListPurpose.appBskyGraphDefsModlist, + name: 'Moderation', + ); + final referenceList = _buildList( + uri: referenceListUri, + purpose: KnownListPurpose.appBskyGraphDefsReferencelist, + name: 'Reference', + ); + + blocTest( + 'loads and categorises lists', + build: () => MyListsCubit(listRepository: mockListRepository), + setUp: () { + when( + () => mockListRepository.getLists( + actor: actor, + cursor: any(named: 'cursor'), + limit: 25, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: 'cursor-1')); + }, + act: (cubit) => cubit.load(actor: actor, limit: 25), + expect: () => [ + const MyListsState.loading(actor: actor, limit: 25), + predicate( + (state) => + state.status == MyListsStatus.loaded && + state.curationLists.length == 1 && + state.moderationLists.length == 1 && + state.referenceLists.isEmpty && + state.cursor == 'cursor-1', + ), + ], + ); + + blocTest( + 'refreshes the current list collection', + build: () => MyListsCubit(listRepository: mockListRepository), + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockListRepository.getLists( + actor: actor, + cursor: any(named: 'cursor'), + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: null)); + }, + act: (cubit) => cubit.refresh(), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate((state) => !state.isRefreshing && state.lists.length == 2 && !state.hasMore), + ], + ); + + blocTest( + 'loads more pages for the active actor', + build: () => MyListsCubit(listRepository: mockListRepository), + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockListRepository.getLists( + actor: actor, + cursor: 'cursor-1', + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) async => ListsResult(lists: [moderationList, referenceList], cursor: null)); + }, + act: (cubit) => cubit.loadMore(), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate( + (state) => !state.isLoadingMore && state.lists.length == 3 && state.referenceLists.single == referenceList, + ), + ], + ); + }); +} + +ListView _buildList({required AtUri uri, required KnownListPurpose purpose, required String name}) { + return ListView( + uri: uri, + cid: 'cid-${uri.rkey}', + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), + name: name, + purpose: ListPurpose.knownValue(data: purpose), + indexedAt: DateTime.utc(2026, 3, 21), + ); +} diff --git a/test/features/lists/data/list_repository_test.dart b/test/features/lists/data/list_repository_test.dart new file mode 100644 index 0000000..0c75f3b --- /dev/null +++ b/test/features/lists/data/list_repository_test.dart @@ -0,0 +1,334 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:bluesky/app_bsky_graph_getlists.dart'; +import 'package:bluesky/app_bsky_graph_getlistswithmembership.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; + +void main() { + late _FakeGraphService graph; + late _FakeFeedService feed; + late _FakeActorService actor; + late ListRepository repository; + + final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); + final listItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); + final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); + + setUp(() { + graph = _FakeGraphService(); + feed = _FakeFeedService(); + actor = _FakeActorService(); + repository = ListRepository( + bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor), + ); + }); + + group('ListRepository', () { + final listView = _buildListView(listUri); + final listItem = ListItemView( + uri: listItemUri, + subject: const ProfileView(did: 'did:plc:member-1', handle: 'member1.bsky.social'), + ); + final feedPost = FeedViewPost( + post: PostView( + uri: AtUri.parse('at://did:plc:member-1/app.bsky.feed.post/post-1'), + cid: 'cid-post', + author: const ProfileViewBasic(did: 'did:plc:member-1', handle: 'member1.bsky.social'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'Hello from a list', + 'createdAt': DateTime.utc(2026, 3, 21).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 3, 21), + ), + ); + + test('getLists requests curation and moderation lists by default', () async { + graph.getListsResult = _FakeListsData(lists: [listView], cursor: 'cursor-1'); + + final result = await repository.getLists(actor: 'did:plc:creator', limit: 25); + + expect(result.lists, [listView]); + expect(result.cursor, 'cursor-1'); + expect(graph.lastGetListsActor, 'did:plc:creator'); + expect(graph.lastGetListsLimit, 25); + expect(graph.lastGetListsPurposes?.map((purpose) => purpose.toJson()).toList(), ['curatelist', 'modlist']); + }); + + test('getList returns the hydrated list and members', () async { + graph.getListResult = _FakeListData(list: listView, items: [listItem], cursor: null); + + final result = await repository.getList(listUri: listUri); + + expect(result.list, listView); + expect(result.items, [listItem]); + expect(graph.lastGetListUri, listUri); + }); + + test('getListFeed returns feed posts and cursor', () async { + feed.getListFeedResult = _FakeListFeedData(feed: [feedPost], cursor: 'cursor-2'); + + final result = await repository.getListFeed(listUri: listUri); + + expect(result.posts, [feedPost]); + expect(result.cursor, 'cursor-2'); + expect(feed.lastListUri, listUri); + }); + + test('getListsWithMembership returns membership records', () async { + graph.getListsWithMembershipResult = _FakeListsWithMembershipData( + listsWithMembership: [ListWithMembership(list: listView, listItem: listItem)], + cursor: null, + ); + + final result = await repository.getListsWithMembership(actor: 'did:plc:member-1'); + + expect(result.lists.length, 1); + expect(result.lists.single.listItem, listItem); + expect(graph.lastGetListsWithMembershipPurposes?.map((purpose) => purpose.toJson()).toList(), [ + 'curatelist', + 'modlist', + ]); + }); + + test('searchActorsTypeahead returns matching actors', () async { + actor.searchActorsResult = const _FakeActorsData( + actors: [ProfileViewBasic(did: 'did:plc:member-1', handle: 'member1.bsky.social')], + ); + + final result = await repository.searchActorsTypeahead(query: 'member', limit: 5); + + expect(result.single.did, 'did:plc:member-1'); + expect(actor.lastQuery, 'member'); + expect(actor.lastLimit, 5); + }); + + test('add and remove list members call the record accessors', () async { + graph.listitem.createdUri = listItemUri; + + final createdUri = await repository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-1'); + await repository.removeListItem(listItemUri: listItemUri); + + expect(createdUri, listItemUri.toString()); + expect(graph.listitem.lastCreatedList, listUri); + expect(graph.listitem.lastCreatedSubject, 'did:plc:member-1'); + expect(graph.listitem.lastDeletedRkey, listItemUri.rkey); + }); + + test('mute and unmute list call graph endpoints', () async { + await repository.muteList(listUri: listUri); + await repository.unmuteList(listUri: listUri); + + expect(graph.lastMutedList, listUri); + expect(graph.lastUnmutedList, listUri); + }); + + test('block and unblock list call listblock accessors', () async { + graph.listblock.createdUri = blockUri; + + final createdUri = await repository.blockList(listUri: listUri); + await repository.unblockList(blockUri: blockUri); + + expect(createdUri, blockUri.toString()); + expect(graph.listblock.lastCreatedSubject, listUri); + expect(graph.listblock.lastDeletedRkey, blockUri.rkey); + }); + }); +} + +ListView _buildListView(AtUri uri) { + return ListView( + uri: uri, + cid: 'cid-${uri.rkey}', + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), + name: 'Core List', + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsCuratelist), + indexedAt: DateTime.utc(2026, 3, 21), + ); +} + +class _FakeBlueskyClient { + _FakeBlueskyClient({required this.graph, required this.feed, required this.actor}); + + final _FakeGraphService graph; + final _FakeFeedService feed; + final _FakeActorService actor; +} + +class _FakeGraphService { + _FakeGraphService() : listitem = _FakeListitemAccessor(), listblock = _FakeListblockAccessor(); + + _FakeListsData? getListsResult; + _FakeListData? getListResult; + _FakeListsWithMembershipData? getListsWithMembershipResult; + + String? lastGetListsActor; + int? lastGetListsLimit; + List? lastGetListsPurposes; + AtUri? lastGetListUri; + AtUri? lastMutedList; + AtUri? lastUnmutedList; + List? lastGetListsWithMembershipPurposes; + + final _FakeListitemAccessor listitem; + final _FakeListblockAccessor listblock; + + Future<_FakeResponse<_FakeListsData>> getLists({ + required String actor, + int? limit, + String? cursor, + List? purposes, + Map? $headers, + }) async { + lastGetListsActor = actor; + lastGetListsLimit = limit; + lastGetListsPurposes = purposes; + return _FakeResponse(getListsResult!); + } + + Future<_FakeResponse<_FakeListData>> getList({ + required AtUri list, + int? limit, + String? cursor, + Map? $headers, + }) async { + lastGetListUri = list; + return _FakeResponse(getListResult!); + } + + Future<_FakeResponse<_FakeListsWithMembershipData>> getListsWithMembership({ + required String actor, + int? limit, + String? cursor, + List? purposes, + Map? $headers, + }) async { + lastGetListsWithMembershipPurposes = purposes; + return _FakeResponse(getListsWithMembershipResult!); + } + + Future muteActorList({required AtUri list, Map? $headers}) async { + lastMutedList = list; + } + + Future unmuteActorList({required AtUri list, Map? $headers}) async { + lastUnmutedList = list; + } +} + +class _FakeFeedService { + _FakeListFeedData? getListFeedResult; + AtUri? lastListUri; + + Future<_FakeResponse<_FakeListFeedData>> getListFeed({ + required AtUri list, + int? limit, + String? cursor, + Map? $headers, + }) async { + lastListUri = list; + return _FakeResponse(getListFeedResult!); + } +} + +class _FakeActorService { + _FakeActorsData? searchActorsResult; + String? lastQuery; + int? lastLimit; + + Future<_FakeResponse<_FakeActorsData>> searchActorsTypeahead({ + required String q, + int? limit, + Map? $headers, + }) async { + lastQuery = q; + lastLimit = limit; + return _FakeResponse(searchActorsResult!); + } +} + +class _FakeListitemAccessor { + AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-created'); + AtUri? lastCreatedList; + String? lastCreatedSubject; + String? lastDeletedRkey; + + Future<_FakeResponse<_FakeUriData>> create({ + required String subject, + required AtUri list, + DateTime? createdAt, + }) async { + lastCreatedList = list; + lastCreatedSubject = subject; + return _FakeResponse(_FakeUriData(createdUri)); + } + + Future delete({required String rkey}) async { + lastDeletedRkey = rkey; + } +} + +class _FakeListblockAccessor { + AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-created'); + AtUri? lastCreatedSubject; + String? lastDeletedRkey; + + Future<_FakeResponse<_FakeUriData>> create({required AtUri subject, DateTime? createdAt}) async { + lastCreatedSubject = subject; + return _FakeResponse(_FakeUriData(createdUri)); + } + + Future delete({required String rkey}) async { + lastDeletedRkey = rkey; + } +} + +class _FakeResponse { + _FakeResponse(this.data); + + final T data; +} + +class _FakeListsData { + const _FakeListsData({required this.lists, this.cursor}); + + final List lists; + final String? cursor; +} + +class _FakeListData { + const _FakeListData({required this.list, required this.items, this.cursor}); + + final ListView list; + final List items; + final String? cursor; +} + +class _FakeListsWithMembershipData { + const _FakeListsWithMembershipData({required this.listsWithMembership, this.cursor}); + + final List listsWithMembership; + final String? cursor; +} + +class _FakeListFeedData { + const _FakeListFeedData({required this.feed, this.cursor}); + + final List feed; + final String? cursor; +} + +class _FakeActorsData { + const _FakeActorsData({required this.actors}); + + final List actors; +} + +class _FakeUriData { + const _FakeUriData(this.uri); + + final AtUri uri; +}