diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bbe891c3..52ac86bc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -836,5 +836,29 @@ } }, "pushNotificationsDialog_selectDistributor": "Select push distributor", - "crossPost": "Cross post" + "crossPost": "Cross post", + "modlog": "Modlog", + "modlog_deletedPost": "Deleted post", + "modlog_restoredPost": "Restored post", + "modlog_deletedComment": "Deleted comment", + "modlog_restoredComment": "Restored comment", + "modlog_pinnedPost": "Pinned post", + "modlog_unpinnedPost": "Unpinned post", + "modlog_bannedUser": "Banned user", + "modlog_unbannedUser": "Unbanned user", + "modlog_addModerator": "Added moderator", + "modlog_removedModerator": "Removed moderator", + "modlog_reason": "Reason: {reason}", + "@modlog_reason": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "modlog_all": "All", + "modlog_communityAdded": "Community added", + "modlog_communityRemoved": "Community removed", + "modlog_postLocked": "Post locked", + "modlog_postUnlocked": "Post unlocked" } diff --git a/lib/src/api/moderation.dart b/lib/src/api/moderation.dart index 5fb9d032..a0ea379a 100644 --- a/lib/src/api/moderation.dart +++ b/lib/src/api/moderation.dart @@ -1,6 +1,7 @@ import 'package:interstellar/src/api/client.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/comment.dart'; +import 'package:interstellar/src/models/modlog.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/utils/utils.dart'; @@ -10,6 +11,90 @@ const _postTypeMbinComment = { PostType.microblog: 'post-comment', }; +enum ModLogType { + all, + postDeleted, + postRestored, + commentDeleted, + commentRestored, + postPinned, + postUnpinned, + microblogPostDeleted, + microblogPostRestored, + microblogCommentDeleted, + microblogCommentRestored, + ban, + unban, + moderatorAdded, + moderatorRemoved, + communityAdded, + communityRemoved, + postLocked, + postUnlocked; + + static ModLogType fromMbin(String type) => switch (type) { + 'log_entry_deleted' => ModLogType.postDeleted, + 'log_entry_restored' => ModLogType.postRestored, + 'log_entry_comment_deleted' => ModLogType.commentDeleted, + 'log_entry_comment_restored' => ModLogType.commentRestored, + 'log_entry_pinned' => ModLogType.postPinned, + 'log_entry_unpinned' => ModLogType.postUnpinned, + 'log_post_deleted' => ModLogType.microblogPostDeleted, + 'log_post_restored' => ModLogType.microblogPostRestored, + 'log_post_comment_deleted' => ModLogType.microblogCommentDeleted, + 'log_post_comment_restored' => ModLogType.microblogCommentRestored, + 'log_ban' => ModLogType.ban, + 'log_unban' => ModLogType.unban, + 'log_moderator_add' => ModLogType.moderatorAdded, + 'log_moderator_remove' => ModLogType.moderatorRemoved, + String() => ModLogType.all, + }; + + String get toMbin => switch (this) { + ModLogType.all => 'all', + ModLogType.postDeleted => 'entry_deleted', + ModLogType.postRestored => 'entry_restored', + ModLogType.commentDeleted => 'entry_comment_deleted', + ModLogType.commentRestored => 'entry_comment_restored', + ModLogType.postPinned => 'entry_pinned', + ModLogType.postUnpinned => 'entry_unpinned', + ModLogType.microblogPostDeleted => 'post_deleted', + ModLogType.microblogPostRestored => 'post_restored', + ModLogType.microblogCommentDeleted => 'post_comment_deleted', + ModLogType.microblogCommentRestored => 'post_comment_restored', + ModLogType.ban => 'ban', + ModLogType.unban => 'unban', + ModLogType.moderatorAdded => 'moderator_add', + ModLogType.moderatorRemoved => 'moderator_remove', + ModLogType.communityAdded => 'all', + ModLogType.communityRemoved => 'all', + ModLogType.postLocked => 'all', + ModLogType.postUnlocked => 'all', + }; + + String get toLemmy => switch (this) { + ModLogType.all => 'All', + ModLogType.postDeleted => 'ModRemovePost', + ModLogType.postRestored => 'ModRemovePost', + ModLogType.commentDeleted => 'ModRemoveComment', + ModLogType.commentRestored => 'ModRemoveComment', + ModLogType.postPinned => 'ModFeaturePost', + ModLogType.postUnpinned => 'ModFeaturePost', + ModLogType.microblogPostDeleted => 'ModRemovePost', + ModLogType.microblogPostRestored => 'ModRemovePost', + ModLogType.microblogCommentDeleted => 'ModRemoveComment', + ModLogType.microblogCommentRestored => 'ModRemoveComment', + ModLogType.ban => 'ModBanFromCommunity', + ModLogType.unban => 'ModBanFromCommunity', + ModLogType.moderatorAdded => 'ModAddCommunity', + ModLogType.moderatorRemoved => 'ModAddCommunity', + ModLogType.communityAdded => 'ModRemoveCommunity', + ModLogType.communityRemoved => 'ModRemoveCommunity', + ModLogType.postLocked => 'ModLockPost', + ModLogType.postUnlocked => 'ModLockPost', + }; +} + class APIModeration { final ServerClient client; @@ -152,4 +237,54 @@ class APIModeration { ); } } + + Future modLog({ + int? communityId, + int? userId, + ModLogType type = ModLogType.all, + String? page, + }) async { + switch (client.software) { + case ServerSoftware.mbin: + final path = communityId != null ? '/magazine/$communityId/log' : '/modlog'; + final query = { + 'p': page, + if (type != ModLogType.all) + 'types[0]': type.toMbin, + + // if type set to post or comment also include the corresponding type for microblogs + if (type == ModLogType.postDeleted) + 'types[1]': ModLogType.microblogPostDeleted.toMbin, + if (type == ModLogType.postRestored) + 'types[1]': ModLogType.microblogPostRestored.toMbin, + if (type == ModLogType.commentDeleted) + 'types[1]': ModLogType.microblogCommentDeleted.toMbin, + if (type == ModLogType.commentRestored) + 'types[1]': ModLogType.microblogCommentRestored.toMbin, + }; + + final response = await client.get(path, queryParams: query); + return ModlogListModel.fromMbin(response.bodyJson); + + case ServerSoftware.lemmy: + const path = '/modlog'; + final query = { + if (communityId != null) 'community_id': communityId.toString(), + if (userId != null) 'mod_person_id': userId.toString(), + 'page': page, + 'type_': type.toLemmy, + }; + final response = await client.get(path, queryParams: query); + final json = response.bodyJson; + return ModlogListModel.fromLemmy({ + 'next_page': + (int.parse(((page?.isNotEmpty ?? false) ? page : '0') ?? '0') + 1) + .toString(), + ...json, + }, langCodeIdPairs: await client.languageCodeIdPairs()); + + case ServerSoftware.piefed: + throw UnimplementedError('Not yet implemented for PieFed'); + } + } } diff --git a/lib/src/models/comment.dart b/lib/src/models/comment.dart index 26f585db..9e1c8ede 100644 --- a/lib/src/models/comment.dart +++ b/lib/src/models/comment.dart @@ -173,7 +173,7 @@ abstract class CommentModel with _$CommentModel { required List<(String, int)> langCodeIdPairs, }) { final lemmyComment = json['comment'] as JsonMap; - final lemmyCounts = json['counts'] as JsonMap; + final lemmyCounts = json['counts'] as JsonMap?; final lemmyPath = lemmyComment['path'] as String; final lemmyPathSegments = lemmyPath @@ -216,21 +216,21 @@ abstract class CommentModel with _$CommentModel { .where((pair) => pair.$2 == lemmyComment['language_id'] as int) .firstOrNull ?.$1, - upvotes: lemmyCounts['upvotes'] as int, - downvotes: lemmyCounts['downvotes'] as int, + upvotes: lemmyCounts?['upvotes'] as int? ?? 0, + downvotes: lemmyCounts?['downvotes'] as int? ?? 0, boosts: null, myVote: json['my_vote'] as int?, myBoost: null, createdAt: DateTime.parse(lemmyComment['published'] as String), editedAt: optionalDateTime(json['updated'] as String?), children: children, - childCount: lemmyCounts['child_count'] as int, + childCount: lemmyCounts?['child_count'] as int? ?? 0, visibility: 'visible', canAuthUserModerate: null, notificationControlStatus: null, bookmarks: [ // Empty string indicates comment is saved. No string indicates comment is not saved. - if (json['saved'] as bool) '', + if (((json['saved'] as bool?) != null) ? json['saved'] as bool : false) '', ], apId: lemmyComment['ap_id'] as String, ); diff --git a/lib/src/models/community.dart b/lib/src/models/community.dart index a3ac3845..aa1501a5 100644 --- a/lib/src/models/community.dart +++ b/lib/src/models/community.dart @@ -262,7 +262,7 @@ abstract class CommunityBanModel with _$CommunityBanModel { required DateTime? expiresAt, required CommunityModel community, required UserModel bannedUser, - required UserModel bannedBy, + required UserModel? bannedBy, required bool expired, }) = _CommunityBanModel; @@ -275,6 +275,19 @@ abstract class CommunityBanModel with _$CommunityBanModel { expired: json['expired'] as bool, ); + factory CommunityBanModel.fromLemmy(JsonMap json) { + final expiration = json['expires'] != null ? DateTime.parse(json['expires'] as String) : null; + + return CommunityBanModel( + reason: json['reason'] as String?, + expiresAt: expiration, + community: CommunityModel.fromLemmy(json['community'] as JsonMap), + bannedUser: UserModel.fromLemmy(json['banned_person'] as JsonMap), + bannedBy: json['moderator'] != null ? UserModel.fromLemmy(json['moderator'] as JsonMap) : null, + expired: expiration?.isBefore(DateTime.now()) ?? false, + ); + } + factory CommunityBanModel.fromPiefed(JsonMap json) => CommunityBanModel( reason: json['reason'] as String?, expiresAt: optionalDateTime(json['expires_at'] as String?), diff --git a/lib/src/models/modlog.dart b/lib/src/models/modlog.dart new file mode 100644 index 00000000..a727c9e5 --- /dev/null +++ b/lib/src/models/modlog.dart @@ -0,0 +1,308 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/comment.dart'; +import 'package:interstellar/src/models/community.dart'; +import 'package:interstellar/src/models/user.dart'; +import 'package:interstellar/src/utils/models.dart'; +import 'package:interstellar/src/utils/utils.dart'; + +import '../api/moderation.dart'; + +part 'modlog.freezed.dart'; + +@freezed +abstract class ModlogItemModel with _$ModlogItemModel { + const factory ModlogItemModel({ + required ModLogType type, + required DateTime createdAt, + required String? reason, + required CommunityModel community, + required DetailedUserModel? moderator, + required int? postId, + required String? postTitle, + required CommentModel? comment, + required DetailedUserModel? user, + }) = _ModlogItemModel; + + factory ModlogItemModel.fromMbin(JsonMap json) { + final type = ModLogType.fromMbin(json['type'] as String); + + return ModlogItemModel( + type: type, + createdAt: DateTime.parse(json['createdAt'] as String), + reason: '', + community: CommunityModel.fromMbin(json['magazine'] as JsonMap), + moderator: DetailedUserModel.fromMbin(json['moderator'] as JsonMap), + postId: switch (type) { + ModLogType.all => null, + ModLogType.postDeleted => + (json['subject'] as JsonMap)['entryId'] as int, + ModLogType.postRestored => + (json['subject'] as JsonMap)['entryId'] as int, + ModLogType.commentDeleted => null, + ModLogType.commentRestored => null, + ModLogType.postPinned => null, + ModLogType.postUnpinned => null, + ModLogType.microblogPostDeleted => + (json['subject'] as JsonMap)['postId'] as int, + ModLogType.microblogPostRestored => + (json['subject'] as JsonMap)['postId'] as int, + ModLogType.microblogCommentDeleted => null, + ModLogType.microblogCommentRestored => null, + ModLogType.ban => null, + ModLogType.unban => null, + ModLogType.moderatorAdded => null, + ModLogType.moderatorRemoved => null, + ModLogType.communityAdded => null, + ModLogType.communityRemoved => null, + ModLogType.postLocked => null, + ModLogType.postUnlocked => null, + }, + postTitle: switch (type) { + ModLogType.all => null, + ModLogType.postDeleted => + (json['subject'] as JsonMap)['title'] as String, + ModLogType.postRestored => + (json['subject'] as JsonMap)['title'] as String, + ModLogType.commentDeleted => null, + ModLogType.commentRestored => null, + ModLogType.postPinned => null, + ModLogType.postUnpinned => null, + ModLogType.microblogPostDeleted => + (json['subject'] as JsonMap)['body'] as String, + ModLogType.microblogPostRestored => + (json['subject'] as JsonMap)['body'] as String, + ModLogType.microblogCommentDeleted => null, + ModLogType.microblogCommentRestored => null, + ModLogType.ban => null, + ModLogType.unban => null, + ModLogType.moderatorAdded => null, + ModLogType.moderatorRemoved => null, + ModLogType.communityAdded => null, + ModLogType.communityRemoved => null, + ModLogType.postLocked => null, + ModLogType.postUnlocked => null, + }, + comment: switch (type) { + ModLogType.all => null, + ModLogType.postDeleted => null, + ModLogType.postRestored => null, + ModLogType.commentDeleted => CommentModel.fromMbin( + json['subject'] as JsonMap, + ), + ModLogType.commentRestored => CommentModel.fromMbin( + json['subject'] as JsonMap, + ), + ModLogType.postPinned => null, + ModLogType.postUnpinned => null, + ModLogType.microblogPostDeleted => null, + ModLogType.microblogPostRestored => null, + ModLogType.microblogCommentDeleted => CommentModel.fromMbin( + json['subject'] as JsonMap, + ), + ModLogType.microblogCommentRestored => CommentModel.fromMbin( + json['subject'] as JsonMap, + ), + ModLogType.ban => null, + ModLogType.unban => null, + ModLogType.moderatorAdded => null, + ModLogType.moderatorRemoved => null, + ModLogType.communityAdded => null, + ModLogType.communityRemoved => null, + ModLogType.postLocked => null, + ModLogType.postUnlocked => null, + }, + user: switch (type) { + ModLogType.all => null, + ModLogType.postDeleted => null, + ModLogType.postRestored => null, + ModLogType.commentDeleted => null, + ModLogType.commentRestored => null, + ModLogType.postPinned => null, + ModLogType.postUnpinned => null, + ModLogType.microblogPostDeleted => null, + ModLogType.microblogPostRestored => null, + ModLogType.microblogCommentDeleted => null, + ModLogType.microblogCommentRestored => null, + ModLogType.ban => DetailedUserModel.fromMbin((json['subject'] as JsonMap)['bannedUser'] as JsonMap), + ModLogType.unban => DetailedUserModel.fromMbin((json['subject'] as JsonMap)['bannedUser'] as JsonMap), + ModLogType.moderatorAdded => null, + ModLogType.moderatorRemoved => null, + ModLogType.communityAdded => null, + ModLogType.communityRemoved => null, + ModLogType.postLocked => null, + ModLogType.postUnlocked => null, + }, + ); + } + + factory ModlogItemModel.fromLemmy( + JsonMap json, { + required List<(String, int)> langCodeIdPairs, + }) { + final type = ModLogType.values.byName(json['type'] as String); + + return ModlogItemModel( + type: type, + createdAt: DateTime.parse(json['createdAt'] as String), + reason: json['reason'] as String?, + community: CommunityModel.fromLemmy(json['community'] as JsonMap), + moderator: json['moderator'] != null + ? DetailedUserModel.fromLemmy(json['moderator'] as JsonMap) + : null, + postId: (json['post'] as JsonMap?)?['id'] as int?, + postTitle: (json['post'] as JsonMap?)?['name'] as String?, + comment: json['comment'] != null + ? CommentModel.fromLemmy(json, langCodeIdPairs: langCodeIdPairs) + : null, + user: switch (type) { + ModLogType.all => null, + ModLogType.postDeleted => null, + ModLogType.postRestored => null, + ModLogType.commentDeleted => null, + ModLogType.commentRestored => null, + ModLogType.postPinned => null, + ModLogType.postUnpinned => null, + ModLogType.microblogPostDeleted => null, + ModLogType.microblogPostRestored => null, + ModLogType.microblogCommentDeleted => null, + ModLogType.microblogCommentRestored => null, + ModLogType.ban => DetailedUserModel.fromLemmy(json['banned_person'] as JsonMap), + ModLogType.unban => DetailedUserModel.fromLemmy(json['banned_person'] as JsonMap), + ModLogType.moderatorAdded => DetailedUserModel.fromLemmy(json['modded_person'] as JsonMap), + ModLogType.moderatorRemoved => DetailedUserModel.fromLemmy(json['modded_person'] as JsonMap), + ModLogType.communityAdded => null, + ModLogType.communityRemoved => null, + ModLogType.postLocked => null, + ModLogType.postUnlocked => null, + }, + ); + } +} + +@freezed +abstract class ModlogListModel with _$ModlogListModel { + const factory ModlogListModel({ + required List items, + required String? nextPage, + }) = _ModlogListModel; + + factory ModlogListModel.fromMbin(JsonMap json) => ModlogListModel( + items: (json['items'] as List) + .map((item) => ModlogItemModel.fromMbin(item as JsonMap)) + .toList(), + nextPage: mbinCalcNextPaginationPage(json['pagination'] as JsonMap), + ); + + factory ModlogListModel.fromLemmy( + JsonMap json, { + required List<(String, int)> langCodeIdPairs, + }) { + final removedPosts = (json['removed_posts'] as List) + .map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_remove_post'] as JsonMap)['removed'] as bool + ? ModLogType.postDeleted.name + : ModLogType.postRestored.name, + 'createdAt': + (item['mod_remove_post'] as JsonMap)['when_'] as String, + 'reason': (item['mod_remove_post'] as JsonMap)['reason'] as String?, + ...item, + }, langCodeIdPairs: langCodeIdPairs), + ) + .toList(); + + final lockedPosts = (json['locked_posts'] as List).map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_lock_post'] as JsonMap)['locked'] as bool + ? ModLogType.postLocked.name + : ModLogType.postUnlocked.name, + 'createdAt': (item['mod_lock_post'] as JsonMap)['when_'] as String, + ...item, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final featuredPosts = (json['featured_posts'] as List).map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_feature_post'] as JsonMap)['featured'] as bool + ? ModLogType.postPinned.name + : ModLogType.postUnpinned.name, + 'createdAt': (item['mod_feature_post'] as JsonMap)['when_'] as String, + ...item, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final removedComments = (json['removed_comments'] as List).map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_remove_comment'] as JsonMap)['removed'] as bool + ? ModLogType.commentDeleted.name + : ModLogType.commentRestored.name, + 'createdAt': (item['mod_remove_comment'] as JsonMap)['when_'] as String, + 'reason': (item['mod_remove_comment'] as JsonMap)['reason'] as String?, + ...item as JsonMap, + 'creator': {'person': item['commenter'] as JsonMap}, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final removedCommunities = (json['removed_communities'] as List) + .map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_remove_community'] as JsonMap)['removed'] as bool + ? ModLogType.communityRemoved.name + : ModLogType.communityAdded.name, + 'createdAt': + (item['mod_remove_community'] as JsonMap)['when_'] as String, + ...item as JsonMap, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final modBannedCommunity = (json['banned_from_community'] as List) + .map( + (item) => ModlogItemModel.fromLemmy({ + 'type': + (item['mod_ban_from_community'] as JsonMap)['banned'] as bool + ? ModLogType.ban.name + : ModLogType.unban.name, + 'createdAt': + (item['mod_ban_from_community'] as JsonMap)['when_'] as String, + ...item, + 'reason': + (item['mod_ban_from_community'] as JsonMap)['reason'] + as String?, + 'expires': + (item['mod_ban_from_community'] as JsonMap)['expires'] + as String?, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final modAddedToCommunity = (json['added_to_community'] as List) + .map( + (item) => ModlogItemModel.fromLemmy({ + 'type': (item['mod_add_community'] as JsonMap)['removed'] as bool + ? ModLogType.moderatorRemoved.name + : ModLogType.moderatorAdded.name, + 'createdAt': + (item['mod_add_community'] as JsonMap)['when_'] as String, + ...item, + 'expires': + (item['mod_add_community'] as JsonMap)['expires'] as String?, + }, langCodeIdPairs: langCodeIdPairs), + ); + + final items = [ + ...removedPosts, + ...lockedPosts, + ...featuredPosts, + ...removedComments, + ...removedCommunities, + ...modBannedCommunity, + ...modAddedToCommunity, + ]; + + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + return ModlogListModel( + items: items, + nextPage: items.isNotEmpty ? json['next_page'] as String? : null, + ); + } +} diff --git a/lib/src/models/post.dart b/lib/src/models/post.dart index dee4431c..78822d45 100644 --- a/lib/src/models/post.dart +++ b/lib/src/models/post.dart @@ -199,7 +199,7 @@ abstract class PostModel with _$PostModel { }) { final postView = json['post_view'] as JsonMap; final lemmyPost = postView['post'] as JsonMap; - final lemmyCounts = postView['counts'] as JsonMap; + final lemmyCounts = postView['counts'] as JsonMap?; final isImagePost = ((lemmyPost['url_content_type'] != null && @@ -231,9 +231,9 @@ abstract class PostModel with _$PostModel { .where((pair) => pair.$2 == lemmyPost['language_id'] as int) .firstOrNull ?.$1, - numComments: lemmyCounts['comments'] as int, - upvotes: lemmyCounts['upvotes'] as int, - downvotes: lemmyCounts['downvotes'] as int, + numComments: lemmyCounts?['comments'] as int? ?? 0, + upvotes: lemmyCounts?['upvotes'] as int? ?? 0, + downvotes: lemmyCounts?['downvotes'] as int? ?? 0, boosts: null, myVote: postView['my_vote'] as int?, myBoost: null, @@ -244,13 +244,13 @@ abstract class PostModel with _$PostModel { lemmyPost['featured_local'] as bool, createdAt: DateTime.parse(lemmyPost['published'] as String), editedAt: optionalDateTime(lemmyPost['updated'] as String?), - lastActive: DateTime.parse(lemmyCounts['newest_comment_time'] as String), + lastActive: lemmyCounts == null ? DateTime.now() : DateTime.parse(lemmyCounts['newest_comment_time'] as String), visibility: 'visible', canAuthUserModerate: null, notificationControlStatus: null, bookmarks: [ // Empty string indicates post is saved. No string indicates post is not saved. - if (postView['saved'] as bool) '', + if (((postView['saved'] as bool?) != null) ? postView['saved'] as bool : false) '', ], read: postView['read'] as bool? ?? false, crossPosts: diff --git a/lib/src/screens/explore/mod_log.dart b/lib/src/screens/explore/mod_log.dart new file mode 100644 index 00000000..094eea6a --- /dev/null +++ b/lib/src/screens/explore/mod_log.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/server.dart'; +import 'package:interstellar/src/models/modlog.dart'; +import 'package:interstellar/src/models/post.dart'; +import 'package:interstellar/src/models/user.dart'; +import 'package:interstellar/src/screens/explore/community_screen.dart'; +import 'package:interstellar/src/screens/explore/user_item.dart'; +import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/feed/post_comment_screen.dart'; +import 'package:interstellar/src/screens/feed/post_page.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/content_item/content_info.dart'; +import 'package:interstellar/src/widgets/paging.dart'; +import 'package:interstellar/src/widgets/selection_menu.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../api/moderation.dart'; + +class ModLog extends StatefulWidget { + const ModLog({super.key, this.communityId, this.userId}); + + final int? communityId; + final int? userId; + + @override + State createState() => _ModLogState(); +} + +class _ModLogState extends State { + late final _pagingController = + AdvancedPagingController( + logger: context.read().logger, + firstPageKey: '', + getItemId: (item) => item.hashCode, + fetchPage: (pageKey) async { + final ac = context.read(); + + final newPage = await ac.api.moderation.modLog( + communityId: widget.communityId, + userId: widget.userId, + type: _filter, + page: pageKey, + ); + + final newItems = switch (ac.serverSoftware) { + ServerSoftware.mbin => newPage.items, + ServerSoftware.lemmy => + _filter != ModLogType.all + ? newPage.items.where((item) => item.type == _filter).toList() + : newPage.items, + // Lemmy API returns both positive and negative mod action types for each filter type. + // e.g. passing PinnedPost to the API returns both pinned and unpinned actions. + // So we do a little extra filtering here to narrow it down further. + ServerSoftware.piefed => throw UnimplementedError(), + }; + + return (newItems, newPage.nextPage); + }, + ); + ModLogType _filter = ModLogType.all; + + Function()? _itemOnTap(ModlogItemModel item) => switch (item.type) { + ModLogType.all => null, + ModLogType.postDeleted => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.postRestored => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.commentDeleted => + item.comment == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostCommentScreen(PostType.thread, item.comment!.id), + ), + ModLogType.commentRestored => + item.comment == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostCommentScreen(PostType.thread, item.comment!.id), + ), + ModLogType.postPinned => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.postUnpinned => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.microblogPostDeleted => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.microblogPostRestored => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.microblogCommentDeleted => + item.comment == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostCommentScreen(PostType.thread, item.comment!.id), + ), + ModLogType.microblogCommentRestored => + item.comment == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostCommentScreen(PostType.thread, item.comment!.id), + ), + ModLogType.ban => + item.user == null + ? null + : () => pushRoute( + context, + builder: (context) => UserScreen(item.user!.id), + ), + ModLogType.unban => + item.user == null + ? null + : () => pushRoute( + context, + builder: (context) => UserScreen(item.user!.id), + ), + ModLogType.moderatorAdded => + item.user == null + ? null + : () => pushRoute( + context, + builder: (context) => UserScreen(item.user!.id), + ), + ModLogType.moderatorRemoved => + item.user == null + ? null + : () => pushRoute( + context, + builder: (context) => UserScreen(item.user!.id), + ), + ModLogType.communityAdded => () => pushRoute( + context, + builder: (context) => CommunityScreen(item.community.id), + ), + ModLogType.communityRemoved => () => pushRoute( + context, + builder: (context) => CommunityScreen(item.community.id), + ), + ModLogType.postLocked => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + ModLogType.postUnlocked => + item.postId == null + ? null + : () => pushRoute( + context, + builder: (context) => + PostPage(postType: PostType.thread, postId: item.postId), + ), + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context).modlog), + actions: [ + IconButton( + onPressed: () async { + final filter = await modlogFilterType( + context, + ).askSelection(context, _filter); + if (filter == null) return; + setState(() { + _filter = filter; + }); + _pagingController.refresh(); + }, + icon: const Icon(Symbols.filter_alt_rounded), + ), + ], + ), + body: AdvancedPagedScrollView( + controller: _pagingController, + itemBuilder: (context, item, index) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: _itemOnTap(item), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: switch (item.type) { + ModLogType.all => Colors.white, + ModLogType.postDeleted => Colors.red, + ModLogType.postRestored => Colors.green, + ModLogType.commentDeleted => Colors.red, + ModLogType.commentRestored => Colors.green, + ModLogType.postPinned => Colors.orange, + ModLogType.postUnpinned => Colors.orange, + ModLogType.microblogPostDeleted => Colors.red, + ModLogType.microblogPostRestored => Colors.green, + ModLogType.microblogCommentDeleted => Colors.red, + ModLogType.microblogCommentRestored => Colors.green, + ModLogType.ban => Colors.red, + ModLogType.unban => Colors.green, + ModLogType.moderatorAdded => Colors.orange, + ModLogType.moderatorRemoved => Colors.orange, + ModLogType.communityAdded => Colors.green, + ModLogType.communityRemoved => Colors.red, + ModLogType.postLocked => Colors.orange, + ModLogType.postUnlocked => Colors.orange, + }, + borderRadius: BorderRadius.circular(10), + ), + child: Text(switch (item.type) { + ModLogType.all => '', + ModLogType.postDeleted => l( + context, + ).modlog_deletedPost, + ModLogType.postRestored => l( + context, + ).modlog_restoredPost, + ModLogType.commentDeleted => l( + context, + ).modlog_deletedComment, + ModLogType.commentRestored => l( + context, + ).modlog_restoredComment, + ModLogType.postPinned => l( + context, + ).modlog_pinnedPost, + ModLogType.postUnpinned => l( + context, + ).modlog_unpinnedPost, + ModLogType.microblogPostDeleted => l( + context, + ).modlog_deletedPost, + ModLogType.microblogPostRestored => l( + context, + ).modlog_restoredPost, + ModLogType.microblogCommentDeleted => l( + context, + ).modlog_deletedComment, + ModLogType.microblogCommentRestored => l( + context, + ).modlog_restoredComment, + ModLogType.ban => l(context).modlog_bannedUser, + ModLogType.unban => l(context).modlog_unbannedUser, + ModLogType.moderatorAdded => l( + context, + ).modlog_addModerator, + ModLogType.moderatorRemoved => l( + context, + ).modlog_removedModerator, + ModLogType.communityAdded => l( + context, + ).modlog_communityAdded, + ModLogType.communityRemoved => l( + context, + ).modlog_communityRemoved, + ModLogType.postLocked => l( + context, + ).modlog_postLocked, + ModLogType.postUnlocked => l( + context, + ).modlog_postUnlocked, + }), + ), + ), + ContentInfo( + user: item.moderator, + community: item.community, + createdAt: item.createdAt, + ), + if (item.postId != null || item.comment != null) + Text( + 'Content: ${item.postId != null + ? item.postTitle ?? l(context).modlog_deletedPost + : item.comment?.body ?? l(context).modlog_deletedComment}', + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + if (item.user != null) + UserItemSimple(UserModel.fromDetailedUser(item.user!), noTap: true), + if (item.reason != null && item.reason!.isNotEmpty) + Text( + l(context).modlog_reason(item.reason!), + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ), + const Divider(thickness: 0, height: 0), + ], + ); + }, + ), + ); + } +} + +SelectionMenu modlogFilterType(BuildContext context) { + final software = context.read().serverSoftware; + return SelectionMenu(l(context).modlog, [ + SelectionMenuItem(value: ModLogType.all, title: l(context).modlog_all), + SelectionMenuItem( + value: ModLogType.postDeleted, + title: l(context).modlog_deletedPost, + ), + if (software == ServerSoftware.mbin) + SelectionMenuItem( + value: ModLogType.postRestored, + title: l(context).modlog_restoredPost, + ), + SelectionMenuItem( + value: ModLogType.commentDeleted, + title: l(context).modlog_deletedComment, + ), + if (software == ServerSoftware.mbin) + SelectionMenuItem( + value: ModLogType.commentRestored, + title: l(context).modlog_restoredComment, + ), + SelectionMenuItem( + value: ModLogType.postPinned, + title: l(context).modlog_pinnedPost, + ), + SelectionMenuItem( + value: ModLogType.postUnpinned, + title: l(context).modlog_unpinnedPost, + ), + SelectionMenuItem( + value: ModLogType.ban, + title: l(context).modlog_bannedUser, + ), + SelectionMenuItem( + value: ModLogType.unban, + title: l(context).modlog_unbannedUser, + ), + SelectionMenuItem( + value: ModLogType.moderatorAdded, + title: l(context).modlog_addModerator, + ), + SelectionMenuItem( + value: ModLogType.moderatorRemoved, + title: l(context).modlog_removedModerator, + ), + if (software != ServerSoftware.mbin) ...[ + SelectionMenuItem( + value: ModLogType.communityAdded, + title: l(context).modlog_communityAdded, + ), + SelectionMenuItem( + value: ModLogType.communityRemoved, + title: l(context).modlog_communityRemoved, + ), + SelectionMenuItem( + value: ModLogType.postLocked, + title: l(context).modlog_postLocked, + ), + SelectionMenuItem( + value: ModLogType.postUnlocked, + title: l(context).modlog_postUnlocked, + ), + ], + ]); +} diff --git a/lib/src/screens/explore/user_item.dart b/lib/src/screens/explore/user_item.dart index 1fdd8d77..cc219e7e 100644 --- a/lib/src/screens/explore/user_item.dart +++ b/lib/src/screens/explore/user_item.dart @@ -21,7 +21,7 @@ class UserItemSimple extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: () => + onTap: noTap ? null : () => pushRoute(context, builder: (context) => UserScreen(user.id)), child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/src/screens/settings/about_screen.dart b/lib/src/screens/settings/about_screen.dart index c52afd26..19f47d5b 100644 --- a/lib/src/screens/settings/about_screen.dart +++ b/lib/src/screens/settings/about_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/screens/explore/community_screen.dart'; +import 'package:interstellar/src/screens/explore/mod_log.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/widgets/open_webpage.dart'; @@ -43,6 +45,15 @@ class _AboutScreenState extends State { builder: (context) => const DebugSettingsScreen(), ), ), + if (context.read().serverSoftware != ServerSoftware.piefed) + ListTile( + leading: const Icon(Symbols.shield_rounded), + title: Text(l(context).modlog), + onTap: () => pushRoute( + context, + builder: (context) => ModLog() + ), + ), ListTile( leading: const Icon(Symbols.favorite_rounded), title: Text(l(context).settings_donate), diff --git a/lib/src/widgets/content_item/content_info.dart b/lib/src/widgets/content_item/content_info.dart index 3a525be9..1db1b29d 100644 --- a/lib/src/widgets/content_item/content_info.dart +++ b/lib/src/widgets/content_item/content_info.dart @@ -52,61 +52,46 @@ class ContentInfo extends StatelessWidget { Widget build(BuildContext context) { final warning = filterListWarnings == null || filterListWarnings!.isEmpty ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l( - context, - ).filterListWarningX(filterListWarnings!.join(', ')), - triggerMode: TooltipTriggerMode.tap, - child: const Icon( - Symbols.warning_amber_rounded, - color: Colors.red, - ), - ), + : Tooltip( + message: l( + context, + ).filterListWarningX(filterListWarnings!.join(', ')), + triggerMode: TooltipTriggerMode.tap, + child: const Icon(Symbols.warning_amber_rounded, color: Colors.red), ); final pinned = !isPinned ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).pinnedInCommunity, - triggerMode: TooltipTriggerMode.tap, - child: const Icon(Symbols.push_pin_rounded, size: 20), - ), + : Tooltip( + message: l(context).pinnedInCommunity, + triggerMode: TooltipTriggerMode.tap, + child: const Icon(Symbols.push_pin_rounded, size: 20), ); final nsfw = !isNSFW ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).notSafeForWork_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).notSafeForWork_short, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), + : Tooltip( + message: l(context).notSafeForWork_long, + triggerMode: TooltipTriggerMode.tap, + child: Text( + l(context).notSafeForWork_short, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, ), ), ); final oc = !isOC ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).originalContent_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).originalContent_short, - style: const TextStyle( - color: Colors.lightGreen, - fontWeight: FontWeight.bold, - ), + : Tooltip( + message: l(context).originalContent_long, + triggerMode: TooltipTriggerMode.tap, + child: Text( + l(context).originalContent_short, + style: const TextStyle( + color: Colors.lightGreen, + fontWeight: FontWeight.bold, ), ), ); @@ -115,43 +100,36 @@ class ContentInfo extends StatelessWidget { lang == null || lang == context.read().profile.defaultCreateLanguage ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: getLanguageName(context, lang!), - triggerMode: TooltipTriggerMode.tap, - child: Text( - lang!, - style: const TextStyle( - color: Colors.purple, - fontWeight: FontWeight.bold, - ), + : Tooltip( + message: getLanguageName(context, lang!), + triggerMode: TooltipTriggerMode.tap, + child: Text( + lang!, + style: const TextStyle( + color: Colors.purple, + fontWeight: FontWeight.bold, ), ), ); final created = createdAt == null ? null - : Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: - l(context).createdAt(dateTimeFormat(createdAt!)) + - (editedAt == null - ? '' - : '\n${l(context).editedAt(dateTimeFormat(editedAt!))}'), - triggerMode: TooltipTriggerMode.tap, - child: Text( - dateDiffFormat(createdAt!), - style: const TextStyle(fontWeight: FontWeight.w300), - ), + : Tooltip( + message: + l(context).createdAt(dateTimeFormat(createdAt!)) + + (editedAt == null + ? '' + : '\n${l(context).editedAt(dateTimeFormat(editedAt!))}'), + triggerMode: TooltipTriggerMode.tap, + child: Text( + dateDiffFormat(createdAt!), + style: const TextStyle(fontWeight: FontWeight.w300), ), ); final userWidget = user == null ? null - : Padding( - padding: const EdgeInsets.only(right: 10), + : Flexible( child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -188,8 +166,7 @@ class ContentInfo extends StatelessWidget { final communityWidget = community == null ? null - : Padding( - padding: const EdgeInsets.only(right: 10), + : Flexible( child: DisplayName( community!.name, icon: community!.icon, @@ -200,23 +177,25 @@ class ContentInfo extends StatelessWidget { ), ); - // return Row( final internal = Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - ?warning, - ?pinned, - ?nsfw, - ?oc, - ?langWidget, - if (showCommunityFirst) ?communityWidget, - if (!showCommunityFirst) ?userWidget, - ?created, - if (!showCommunityFirst) ?communityWidget, - if (showCommunityFirst) ?userWidget, - ], + Expanded( + child: Row( + spacing: 10, + children: [ + ?warning, + ?pinned, + ?nsfw, + ?oc, + ?langWidget, + if (showCommunityFirst) ?communityWidget, + if (!showCommunityFirst) ?userWidget, + ?created, + if (!showCommunityFirst) ?communityWidget, + if (showCommunityFirst) ?userWidget, + ], + ), ), ?menuWidget, ], diff --git a/lib/src/widgets/menus/community_menu.dart b/lib/src/widgets/menus/community_menu.dart index 49dda2c9..acbaed0a 100644 --- a/lib/src/widgets/menus/community_menu.dart +++ b/lib/src/widgets/menus/community_menu.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/community.dart'; +import 'package:interstellar/src/screens/explore/mod_log.dart'; import 'package:interstellar/src/screens/explore/community_screen.dart'; import 'package:interstellar/src/screens/explore/explore_screen.dart'; import 'package:interstellar/src/screens/explore/user_item.dart'; @@ -163,6 +165,14 @@ Future showCommunityMenu( ), ), ), + if (ac.serverSoftware != ServerSoftware.piefed) + ContextMenuItem( + title: l(context).modlog, + onTap: () => pushRoute( + context, + builder: (context) => ModLog(communityId: detailedCommunity?.id ?? community!.id) + ) + ), ], ).openMenu(context); } diff --git a/lib/src/widgets/menus/user_menu.dart b/lib/src/widgets/menus/user_menu.dart index 6557efb3..fa3a5aea 100644 --- a/lib/src/widgets/menus/user_menu.dart +++ b/lib/src/widgets/menus/user_menu.dart @@ -4,6 +4,7 @@ import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/screens/explore/explore_screen.dart'; +import 'package:interstellar/src/screens/explore/mod_log.dart'; import 'package:interstellar/src/screens/explore/user_screen.dart'; import 'package:interstellar/src/utils/ap_urls.dart'; import 'package:interstellar/src/utils/utils.dart'; @@ -131,6 +132,14 @@ Future showUserMenu( ); } ), + if (ac.serverSoftware == ServerSoftware.lemmy) + ContextMenuItem( + title: l(context).modlog, + onTap: () => pushRoute( + context, + builder: (context) => ModLog(userId: user.id), + ) + ), ], ).openMenu(context); }