diff --git a/assets/svg/action_createpost_deleterole.svg b/assets/svg/action_createpost_deleterole.svg index c1c0793665..ecf7c2b91e 100644 --- a/assets/svg/action_createpost_deleterole.svg +++ b/assets/svg/action_createpost_deleterole.svg @@ -1 +1,16 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + diff --git a/assets/svg/action_createpost_transferrole.svg b/assets/svg/action_createpost_transferrole.svg new file mode 100644 index 0000000000..92d880e09f --- /dev/null +++ b/assets/svg/action_createpost_transferrole.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/action_group_clearmessage.svg b/assets/svg/action_group_clearmessage.svg new file mode 100644 index 0000000000..4c467102fa --- /dev/null +++ b/assets/svg/action_group_clearmessage.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/svg/action_group_deletegroup.svg b/assets/svg/action_group_deletegroup.svg new file mode 100644 index 0000000000..a0771f4920 --- /dev/null +++ b/assets/svg/action_group_deletegroup.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/icon_admin_status.svg b/assets/svg/icon_admin_status.svg new file mode 100644 index 0000000000..75e4b30277 --- /dev/null +++ b/assets/svg/icon_admin_status.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/app/components/inputs/text_input/components/text_input_icons.dart b/lib/app/components/inputs/text_input/components/text_input_icons.dart index 62b5ba4378..9cfb5dc647 100644 --- a/lib/app/components/inputs/text_input/components/text_input_icons.dart +++ b/lib/app/components/inputs/text_input/components/text_input_icons.dart @@ -10,6 +10,7 @@ class TextInputIcons extends StatelessWidget { super.key, this.hasLeftDivider = false, this.hasRightDivider = false, + this.minWidth, }); final List icons; @@ -18,6 +19,8 @@ class TextInputIcons extends StatelessWidget { final bool hasRightDivider; + final double? minWidth; + @override Widget build(BuildContext context) { return Row( @@ -30,7 +33,7 @@ class TextInputIcons extends StatelessWidget { ), ConstrainedBox( constraints: BoxConstraints( - minWidth: 56.0.s, + minWidth: minWidth ?? 56.0.s, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/app/features/auth/views/components/user_data_inputs/general_user_data_input.dart b/lib/app/features/auth/views/components/user_data_inputs/general_user_data_input.dart index 179a896ed8..13ac4017fa 100644 --- a/lib/app/features/auth/views/components/user_data_inputs/general_user_data_input.dart +++ b/lib/app/features/auth/views/components/user_data_inputs/general_user_data_input.dart @@ -22,6 +22,7 @@ class GeneralUserDataInput extends HookWidget { this.initialValue, this.isLive = false, this.showNoErrorsIndicator = false, + this.initialVerification = true, this.prefix, this.keyboardType, this.inputFormatters, @@ -40,6 +41,7 @@ class GeneralUserDataInput extends HookWidget { final String? initialValue; final bool isLive; final bool showNoErrorsIndicator; + final bool initialVerification; final Widget? prefix; final TextInputType? keyboardType; final List? inputFormatters; @@ -49,6 +51,7 @@ class GeneralUserDataInput extends HookWidget { @override Widget build(BuildContext context) { final isValid = useState(false); + final hasInteracted = useState(false); return TextInput( prefixIcon: TextInputIcons( @@ -69,15 +72,24 @@ class GeneralUserDataInput extends HookWidget { minLines: minLines, initialValue: initialValue, isLive: isLive, - verified: isValid.value, + verified: _isVerified(isValid, hasInteracted), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - suffixIcon: isValid.value && showNoErrorsIndicator && errorText == null - ? TextInputIcons( - icons: [Assets.svg.iconBlockCheckboxOn.icon()], - ) - : null, + suffixIcon: + (_isVerified(isValid, hasInteracted)) && showNoErrorsIndicator && errorText == null + ? TextInputIcons( + icons: [Assets.svg.iconBlockCheckboxOn.icon()], + ) + : null, errorText: errorText, - onFocused: onFocused, + onFocused: (focused) { + if (focused) { + hasInteracted.value = true; + } + onFocused?.call(focused); + }, ); } + + bool _isVerified(ValueNotifier isValid, ValueNotifier hasInteracted) => + initialVerification ? isValid.value : hasInteracted.value && isValid.value; } diff --git a/lib/app/features/chat/community/models/group_type.dart b/lib/app/features/chat/community/models/group_type.dart index e2e6b50e13..440cb4d7ba 100644 --- a/lib/app/features/chat/community/models/group_type.dart +++ b/lib/app/features/chat/community/models/group_type.dart @@ -36,4 +36,10 @@ enum GroupType implements SelectableType { GroupType.private => 200, GroupType.encrypted => 5, }; + + int? get maxAdmins => switch (this) { + GroupType.public => null, + GroupType.private => null, + GroupType.encrypted => 2, + }; } diff --git a/lib/app/features/chat/e2ee/model/entities/encrypted_direct_message_entity.f.dart b/lib/app/features/chat/e2ee/model/entities/encrypted_direct_message_entity.f.dart index 59d1cc867d..f2e71c8cc8 100644 --- a/lib/app/features/chat/e2ee/model/entities/encrypted_direct_message_entity.f.dart +++ b/lib/app/features/chat/e2ee/model/entities/encrypted_direct_message_entity.f.dart @@ -8,6 +8,7 @@ import 'package:ion/app/exceptions/exceptions.dart'; import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/chat/community/models/entities/tags/conversation_identifier.f.dart'; import 'package:ion/app/features/chat/community/models/entities/tags/master_pubkey_tag.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart'; import 'package:ion/app/features/chat/model/group_subject.f.dart'; import 'package:ion/app/features/chat/model/message_type.dart'; import 'package:ion/app/features/ion_connect/ion_connect.dart'; @@ -35,7 +36,8 @@ part 'encrypted_direct_message_entity.f.freezed.dart'; @Freezed(equal: false) class EncryptedDirectMessageEntity - with IonConnectEntity, ReplaceableEntity, _$EncryptedDirectMessageEntity { + with IonConnectEntity, ReplaceableEntity, _$EncryptedDirectMessageEntity + implements EncryptedMessageEntityWithMedia { const factory EncryptedDirectMessageEntity({ required String id, required String pubkey, diff --git a/lib/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart b/lib/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart index 04fe674b4e..61822be21a 100644 --- a/lib/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart +++ b/lib/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart @@ -8,6 +8,7 @@ import 'package:ion/app/exceptions/exceptions.dart'; import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/chat/community/models/entities/tags/conversation_identifier.f.dart'; import 'package:ion/app/features/chat/community/models/entities/tags/master_pubkey_tag.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart'; import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; import 'package:ion/app/features/chat/model/group_subject.f.dart'; import 'package:ion/app/features/chat/model/message_type.dart'; @@ -35,7 +36,7 @@ part 'encrypted_group_message_entity.f.freezed.dart'; @Freezed(equal: false) class EncryptedGroupMessageEntity with IonConnectEntity, ReplaceableEntity, _$EncryptedGroupMessageEntity - implements Comparable { + implements Comparable, EncryptedMessageEntityWithMedia { const factory EncryptedGroupMessageEntity({ required String id, required String pubkey, diff --git a/lib/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart b/lib/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart new file mode 100644 index 0000000000..51646ea838 --- /dev/null +++ b/lib/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:ion/app/features/ion_connect/model/entity_data_with_encrypted_media_content.dart'; + +/// Common interface for encrypted message entities that have media attachments +/// and a master pubkey for decryption +abstract interface class EncryptedMessageEntityWithMedia { + /// The master pubkey used for decrypting media + String get masterPubkey; + + /// The entity data containing media attachments + EntityDataWithEncryptedMediaContent get data; +} diff --git a/lib/app/features/chat/e2ee/model/group_admin_tab.dart b/lib/app/features/chat/e2ee/model/group_admin_tab.dart index a3838bab06..6d3e863858 100644 --- a/lib/app/features/chat/e2ee/model/group_admin_tab.dart +++ b/lib/app/features/chat/e2ee/model/group_admin_tab.dart @@ -8,7 +8,9 @@ import 'package:ion/generated/assets.gen.dart'; enum GroupAdminTab implements TabType { members, media, - links; + links, + voice, + files; @override String get iconAsset { @@ -16,6 +18,8 @@ enum GroupAdminTab implements TabType { GroupAdminTab.members => Assets.svg.iconSearchFollowers, GroupAdminTab.media => Assets.svg.iconGalleryOpen, GroupAdminTab.links => Assets.svg.iconArticleLink, + GroupAdminTab.voice => Assets.svg.iconChatVoicemessage, + GroupAdminTab.files => Assets.svg.iconChatFile, }; } @@ -28,6 +32,10 @@ enum GroupAdminTab implements TabType { return context.i18n.common_media; case GroupAdminTab.links: return context.i18n.common_links; + case GroupAdminTab.voice: + return context.i18n.common_voice; + case GroupAdminTab.files: + return context.i18n.common_files; } } } diff --git a/lib/app/features/chat/e2ee/model/group_metadata.f.dart b/lib/app/features/chat/e2ee/model/group_metadata.f.dart index 9220273f78..b7bc46683a 100644 --- a/lib/app/features/chat/e2ee/model/group_metadata.f.dart +++ b/lib/app/features/chat/e2ee/model/group_metadata.f.dart @@ -23,6 +23,21 @@ class GroupMetadata with _$GroupMetadata { (member) => member.masterPubkey == currentUserMasterPubkey, ); } + + /// Returns members sorted with owner first, then other members + List get membersSorted { + return [...members]..sort((a, b) { + final aIsOwner = a is GroupMemberRoleOwner; + final bIsOwner = b is GroupMemberRoleOwner; + if (aIsOwner && !bIsOwner) { + return -1; + } + if (!aIsOwner && bIsOwner) { + return 1; + } + return 0; + }); + } } extension GroupMemberRoleExtension on GroupMemberRole { @@ -31,4 +46,10 @@ extension GroupMemberRoleExtension on GroupMemberRole { GroupMemberRoleOwner() => true, _ => false, }; + + bool get canEditGroup => switch (this) { + GroupMemberRoleAdmin() => false, + GroupMemberRoleOwner() => true, + _ => false, + }; } diff --git a/lib/app/features/chat/e2ee/model/role_permissions.dart b/lib/app/features/chat/e2ee/model/role_permissions.dart new file mode 100644 index 0000000000..9463be039e --- /dev/null +++ b/lib/app/features/chat/e2ee/model/role_permissions.dart @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; + +enum GroupPermission { + deleteMessages, + pinMessages, + deleteUsers, + addNewUsers, + addNewAdmins, + changeGroupInfo, + clearGroup, +} + +class RolePermissions { + const RolePermissions._(); + + static List rolePermission(GroupMemberRole role) { + return switch (role) { + GroupMemberRoleMember() => [], + GroupMemberRoleAdmin() => [ + GroupPermission.deleteMessages, + GroupPermission.pinMessages, + GroupPermission.deleteUsers, + GroupPermission.addNewUsers, + ], + GroupMemberRoleOwner() => GroupPermission.values, + GroupMemberRoleModerator() => [], + }; + } +} diff --git a/lib/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart b/lib/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart index 723c3073c3..60d66c8e25 100644 --- a/lib/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart +++ b/lib/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart @@ -2,7 +2,7 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_direct_message_entity.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_message_entity_interface.dart'; import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; import 'package:ion/app/services/compressors/audio_compressor.r.dart'; import 'package:ion/app/services/media_service/media_encryption_service.m.dart'; @@ -14,7 +14,7 @@ part 'chat_message_media_path_provider.r.g.dart'; @riverpod Future chatMessageMediaPath( Ref ref, { - required EncryptedDirectMessageEntity entity, + required EncryptedMessageEntityWithMedia entity, String? cacheKey, MediaAttachment? mediaAttachment, bool loadThumbnail = true, diff --git a/lib/app/features/chat/e2ee/providers/group/group_links_provider.r.dart b/lib/app/features/chat/e2ee/providers/group/group_links_provider.r.dart new file mode 100644 index 0000000000..f964e31fac --- /dev/null +++ b/lib/app/features/chat/e2ee/providers/group/group_links_provider.r.dart @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/services/text_parser/model/text_matcher.dart'; +import 'package:ion/app/services/text_parser/text_parser.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'group_links_provider.r.g.dart'; + +@riverpod +Future> groupLinks( + Ref ref, + String conversationId, +) async { + final messagesStream = ref.watch(conversationMessageDaoProvider).getMessages(conversationId); + + // Get the latest snapshot from the stream + final messagesByDate = await messagesStream.first; + final allLinks = []; + + // Flatten all messages from all dates + for (final messages in messagesByDate.values) { + for (final eventMessage in messages) { + try { + final entity = EncryptedGroupMessageEntity.fromEventMessage(eventMessage); + final eventReference = entity.toEventReference(); + final publishedAt = entity.data.publishedAt.value; + + // Extract links from content + final content = entity.data.content; + if (content.isNotEmpty) { + final textParser = TextParser(matchers: {const UrlMatcher()}); + final parsed = textParser.parse(content, onlyMatches: true); + final links = parsed + .where((match) => match.matcher is UrlMatcher) + .map((match) => match.text) + .toSet(); // Use Set to avoid duplicates + + for (final link in links) { + // Exclude media URLs (they're already in media attachments) + final isMediaUrl = entity.data.media.values.any( + (media) => media.url == link || media.thumb == link || media.image == link, + ); + if (!isMediaUrl) { + allLinks.add( + GroupLinkItem( + url: link, + eventReference: eventReference, + publishedAt: publishedAt, + ), + ); + } + } + } + } catch (_) { + // Skip messages that can't be parsed as EncryptedGroupMessageEntity + continue; + } + } + } + + // Sort by publishedAt descending (newest first) + allLinks.sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + + return allLinks; +} + +class GroupLinkItem { + GroupLinkItem({ + required this.url, + required this.eventReference, + required this.publishedAt, + }); + + final String url; + final EventReference eventReference; + final int publishedAt; +} diff --git a/lib/app/features/chat/e2ee/providers/group/group_media_provider.r.dart b/lib/app/features/chat/e2ee/providers/group/group_media_provider.r.dart new file mode 100644 index 0000000000..4b2bc41221 --- /dev/null +++ b/lib/app/features/chat/e2ee/providers/group/group_media_provider.r.dart @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; +import 'package:ion/app/features/core/model/media_type.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'group_media_provider.r.g.dart'; + +@riverpod +Future> groupMedia( + Ref ref, + String conversationId, +) async { + final messagesStream = ref.watch(conversationMessageDaoProvider).getMessages(conversationId); + + final messagesByDate = await messagesStream.first; + final allMedia = []; + + // Flatten all messages from all dates + for (final messages in messagesByDate.values) { + for (final eventMessage in messages) { + try { + final entity = EncryptedGroupMessageEntity.fromEventMessage(eventMessage); + final eventReference = entity.toEventReference(); + final publishedAt = entity.data.publishedAt.value; + + for (final media in entity.data.media.values) { + allMedia.add( + GroupMediaItem( + media: media, + eventReference: eventReference, + publishedAt: publishedAt, + ), + ); + } + } catch (_) { + // Skip messages that can't be parsed as EncryptedGroupMessageEntity + continue; + } + } + } + + // Sort by publishedAt descending (newest first) + allMedia.sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + + return allMedia; +} + +class GroupMediaItem { + GroupMediaItem({ + required this.media, + required this.eventReference, + required this.publishedAt, + }); + + final MediaAttachment media; + final EventReference eventReference; + final int publishedAt; + + bool get isVisualMedia { + final mediaType = media.mediaTypeEncrypted ?? media.mediaType; + return mediaType == MediaType.image || mediaType == MediaType.video; + } + + bool get isAudio { + final mediaType = media.mediaTypeEncrypted ?? media.mediaType; + return mediaType == MediaType.audio; + } + + bool get isFile { + final mediaType = media.mediaTypeEncrypted ?? media.mediaType; + return mediaType == MediaType.unknown; + } +} + +@riverpod +Future> groupMediaItems( + Ref ref, + String conversationId, +) async { + final allMedia = await ref.watch(groupMediaProvider(conversationId).future); + return allMedia.where((GroupMediaItem item) => item.isVisualMedia).toList(); +} + +@riverpod +Future> groupVoiceItems( + Ref ref, + String conversationId, +) async { + final allMedia = await ref.watch(groupMediaProvider(conversationId).future); + return allMedia.where((GroupMediaItem item) => item.isAudio).toList(); +} + +@riverpod +Future> groupFilesItems( + Ref ref, + String conversationId, +) async { + final allMedia = await ref.watch(groupMediaProvider(conversationId).future); + return allMedia.where((GroupMediaItem item) => item.isFile).toList(); +} diff --git a/lib/app/features/chat/e2ee/providers/group/update_group_chat_members_service.r.dart b/lib/app/features/chat/e2ee/providers/group/update_group_chat_members_service.r.dart deleted file mode 100644 index 260582d7a0..0000000000 --- a/lib/app/features/chat/e2ee/providers/group/update_group_chat_members_service.r.dart +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: ice License 1.0 - -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; -import 'package:ion/app/features/chat/e2ee/model/group_metadata.f.dart'; -import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; -import 'package:ion/app/features/chat/e2ee/providers/group/send_e2ee_group_chat_message_service.r.dart'; -import 'package:ion/app/services/media_service/media_encryption_service.m.dart'; -import 'package:ion/app/services/media_service/media_service.m.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'update_group_chat_members_service.r.g.dart'; - -@riverpod -UpdateGroupChatMembersService updateGroupChatMembersService(Ref ref) { - return UpdateGroupChatMembersService( - mediaEncryptionService: ref.read(mediaEncryptionServiceProvider), - sendE2eeGroupChatMessageService: ref.read(sendE2eeGroupChatMessageServiceProvider), - getGroupMetadata: (String groupId) async => - await ref.read(encryptedGroupMetadataProvider(groupId).future), - ); -} - -class UpdateGroupChatMembersService { - UpdateGroupChatMembersService({ - required this.mediaEncryptionService, - required this.sendE2eeGroupChatMessageService, - required this.getGroupMetadata, - }); - - final MediaEncryptionService mediaEncryptionService; - final SendE2eeGroupChatMessageService sendE2eeGroupChatMessageService; - final Future Function(String groupId) getGroupMetadata; - - Future _prepareGroupPicture(GroupMetadata groupMetadata) async { - if (groupMetadata.avatar.media == null) { - return null; - } - - final decryptedFile = await mediaEncryptionService.getEncryptedMedia( - groupMetadata.avatar.media!, - authorPubkey: groupMetadata.avatar.masterPubkey, - ); - - return MediaFile( - path: decryptedFile.path, - mimeType: groupMetadata.avatar.media!.mimeType, - originalMimeType: groupMetadata.avatar.media!.originalMimeType, - ); - } - - Future addMembers({ - required String groupId, - required List participantMasterPubkeys, - }) async { - final groupMetadata = await getGroupMetadata(groupId); - - if (groupMetadata == null) { - return; - } - - final existingMemberPubkeys = - groupMetadata.members.map((member) => member.masterPubkey).toSet(); - - final newParticipantPubkeys = participantMasterPubkeys - .where((pubkey) => !existingMemberPubkeys.contains(pubkey)) - .toList(); - - if (newParticipantPubkeys.isEmpty) { - return; - } - - // Build new members list: existing members + newly added participants - final newMembers = [ - ...groupMetadata.members, - ...newParticipantPubkeys.map(GroupMemberRole.member), - ]; - - final groupPicture = await _prepareGroupPicture(groupMetadata); - - unawaited( - sendE2eeGroupChatMessageService.sendMetadataMessage( - members: newMembers, - groupPicture: groupPicture, - groupId: groupId, - groupName: groupMetadata.name, - ), - ); - } - - Future removeMembers({ - required String groupId, - required List participantMasterPubkeys, - }) async { - final groupMetadata = await getGroupMetadata(groupId); - - if (groupMetadata == null) { - return; - } - - // Remove the specified members from the members list - final updatedMembers = [ - ...groupMetadata.members - .where((member) => !participantMasterPubkeys.contains(member.masterPubkey)), - ]; - - // Prepare group picture - final groupPicture = await _prepareGroupPicture(groupMetadata); - - // Send new metadata message with updated members (don't wait) - unawaited( - sendE2eeGroupChatMessageService.sendMetadataMessage( - members: updatedMembers, - groupPicture: groupPicture, - groupId: groupId, - groupName: groupMetadata.name, - ), - ); - } -} diff --git a/lib/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart b/lib/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart new file mode 100644 index 0000000000..756ae1d575 --- /dev/null +++ b/lib/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/group_metadata.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/send_e2ee_group_chat_message_service.r.dart'; +import 'package:ion/app/services/media_service/media_encryption_service.m.dart'; +import 'package:ion/app/services/media_service/media_service.m.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'update_group_metadata_service.r.g.dart'; + +@riverpod +UpdateGroupMetaDataService updateGroupMetaDataService(Ref ref) { + return UpdateGroupMetaDataService( + mediaEncryptionService: ref.read(mediaEncryptionServiceProvider), + sendE2eeGroupChatMessageService: ref.read(sendE2eeGroupChatMessageServiceProvider), + getGroupMetadata: (String groupId) async => + await ref.read(encryptedGroupMetadataProvider(groupId).future), + ); +} + +class UpdateGroupMetaDataService { + UpdateGroupMetaDataService({ + required this.mediaEncryptionService, + required this.sendE2eeGroupChatMessageService, + required this.getGroupMetadata, + }); + + final MediaEncryptionService mediaEncryptionService; + final SendE2eeGroupChatMessageService sendE2eeGroupChatMessageService; + final Future Function(String groupId) getGroupMetadata; + + Future _prepareGroupPicture(GroupMetadata groupMetadata) async { + if (groupMetadata.avatar.media == null) { + return null; + } + + final decryptedFile = await mediaEncryptionService.getEncryptedMedia( + groupMetadata.avatar.media!, + authorPubkey: groupMetadata.avatar.masterPubkey, + ); + + return MediaFile( + path: decryptedFile.path, + mimeType: groupMetadata.avatar.media!.mimeType, + originalMimeType: groupMetadata.avatar.media!.originalMimeType, + ); + } + + Future updateMetadata({ + required String groupId, + String? title, + MediaFile? newGroupPicture, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + // Use provided title or keep existing one + final updatedTitle = title ?? groupMetadata.name; + + // Use provided new group picture or prepare existing one + final groupPicture = newGroupPicture ?? await _prepareGroupPicture(groupMetadata); + + // Keep existing members + final members = groupMetadata.members; + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: members, + groupPicture: groupPicture, + groupId: groupId, + groupName: updatedTitle, + ), + ); + } + + Future addMembers({ + required String groupId, + required List participantMasterPubkeys, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + final existingMemberPubkeys = + groupMetadata.members.map((member) => member.masterPubkey).toSet(); + + final newParticipantPubkeys = participantMasterPubkeys + .where((pubkey) => !existingMemberPubkeys.contains(pubkey)) + .toList(); + + if (newParticipantPubkeys.isEmpty) { + return; + } + + // Build new members list: existing members + newly added participants + final newMembers = [ + ...groupMetadata.members, + ...newParticipantPubkeys.map(GroupMemberRole.member), + ]; + + final groupPicture = await _prepareGroupPicture(groupMetadata); + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: newMembers, + groupPicture: groupPicture, + groupId: groupId, + groupName: groupMetadata.name, + ), + ); + } + + Future removeMembers({ + required String groupId, + required List participantMasterPubkeys, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + // Remove the specified members from the members list + final updatedMembers = [ + ...groupMetadata.members + .where((member) => !participantMasterPubkeys.contains(member.masterPubkey)), + ]; + + final groupPicture = await _prepareGroupPicture(groupMetadata); + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: updatedMembers, + groupPicture: groupPicture, + groupId: groupId, + groupName: groupMetadata.name, + ), + ); + } + + Future promoteMemberToAdmin({ + required String groupId, + required String participantMasterPubkey, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + // Update the member's role to admin, keeping all other members unchanged + final updatedMembers = groupMetadata.members.map((member) { + if (member.masterPubkey == participantMasterPubkey) { + // Only promote if they're currently a member (not owner or already admin) + if (member is GroupMemberRoleMember) { + return GroupMemberRole.admin(participantMasterPubkey); + } + } + return member; + }).toList(); + + final groupPicture = await _prepareGroupPicture(groupMetadata); + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: updatedMembers, + groupPicture: groupPicture, + groupId: groupId, + groupName: groupMetadata.name, + ), + ); + } + + Future removeAdminRole({ + required String groupId, + required String participantMasterPubkey, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + // Update the admin's role to member, keeping all other members unchanged + final updatedMembers = groupMetadata.members.map((member) { + if (member.masterPubkey == participantMasterPubkey) { + // Only demote if they're currently an admin (not owner) + if (member is GroupMemberRoleAdmin) { + return GroupMemberRole.member(participantMasterPubkey); + } + } + return member; + }).toList(); + + final groupPicture = await _prepareGroupPicture(groupMetadata); + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: updatedMembers, + groupPicture: groupPicture, + groupId: groupId, + groupName: groupMetadata.name, + ), + ); + } + + Future transferOwnership({ + required String groupId, + required String newOwnerMasterPubkey, + required String currentOwnerMasterPubkey, + }) async { + final groupMetadata = await getGroupMetadata(groupId); + + if (groupMetadata == null) { + return; + } + + // Transfer ownership: make new owner the owner, and current owner becomes a member + final updatedMembers = groupMetadata.members.map((member) { + if (member.masterPubkey == newOwnerMasterPubkey) { + // Make the selected member the new owner + return GroupMemberRole.owner(newOwnerMasterPubkey); + } else if (member.masterPubkey == currentOwnerMasterPubkey) { + // Make the current owner an admin + return GroupMemberRole.member(currentOwnerMasterPubkey); + } + return member; + }).toList(); + + final groupPicture = await _prepareGroupPicture(groupMetadata); + + unawaited( + sendE2eeGroupChatMessageService.sendMetadataMessage( + members: updatedMembers, + groupPicture: groupPicture, + groupId: groupId, + groupName: groupMetadata.name, + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page.dart index f19da1d65a..29e748fee5 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page.dart @@ -9,21 +9,20 @@ import 'package:ion/app/components/scroll_to_top_wrapper/scroll_to_top_wrapper.d import 'package:ion/app/components/section_separator/section_separator.dart'; import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/auth/providers/auth_provider.m.dart'; -import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; import 'package:ion/app/features/chat/e2ee/model/group_admin_tab.dart'; -import 'package:ion/app/features/chat/e2ee/model/group_metadata.f.dart'; import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; -import 'package:ion/app/features/chat/e2ee/providers/group/update_group_chat_members_service.r.dart'; -import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/add_members_button.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart' show GroupAvatar; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_context_menu.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_details.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_files_tab.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_links_tab.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_media_tab.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_members_tab.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_tabs_header/group_tabs_header.dart'; -import 'package:ion/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_voice_tab.dart'; import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; import 'package:ion/app/hooks/use_animated_opacity_on_scroll.dart'; -import 'package:ion/app/router/app_routes.gr.dart'; import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; import 'package:ion/app/router/components/navigation_app_bar/navigation_back_button.dart'; import 'package:ion/generated/assets.gen.dart'; @@ -44,7 +43,7 @@ class GroupAdminPage extends HookConsumerWidget { if (groupMetadata == null) { return const Scaffold( - body: Center(child: CircularProgressIndicator()), + body: SizedBox.shrink(), ); } @@ -246,108 +245,18 @@ class _GroupTabContent extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - if (tab == GroupAdminTab.members) { - return _GroupMembersTab(conversationId: conversationId); + switch (tab) { + case GroupAdminTab.members: + return GroupMembersTab(conversationId: conversationId); + case GroupAdminTab.media: + return GroupMediaTab(conversationId: conversationId); + case GroupAdminTab.links: + return GroupLinksTab(conversationId: conversationId); + case GroupAdminTab.voice: + return GroupVoiceTab(conversationId: conversationId); + case GroupAdminTab.files: + return GroupFilesTab(conversationId: conversationId); } - - return Center( - child: Text( - context.i18n.group_tab_coming_soon(tab.getTitle(context)), - style: context.theme.appTextThemes.body, - ), - ); - } -} - -class _GroupMembersTab extends HookConsumerWidget { - const _GroupMembersTab({ - required this.conversationId, - }); - - final String conversationId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; - - if (groupMetadata == null) { - return const Center(child: CircularProgressIndicator()); - } - - final members = groupMetadata.members; - - if (members.isEmpty) { - return Center( - child: Text( - context.i18n.group_no_members, - style: context.theme.appTextThemes.body, - ), - ); - } - - final currentUserMasterPubkey = ref.watch(currentPubkeySelectorProvider); - final currentUserRole = currentUserMasterPubkey != null - ? groupMetadata.currentUserRole(currentUserMasterPubkey) - : null; - - final canRemoveMembers = currentUserRole?.canRemoveMembers ?? false; - final isOwner = currentUserRole is GroupMemberRoleOwner; - - // Sort members so that owner comes first - final sortedMembers = [...members]..sort((a, b) { - final aIsOwner = a is GroupMemberRoleOwner; - final bIsOwner = b is GroupMemberRoleOwner; - if (aIsOwner && !bIsOwner) { - return -1; - } - if (!aIsOwner && bIsOwner) { - return 1; - } - return 0; - }); - - return ListView.separated( - padding: EdgeInsets.symmetric(vertical: 8.0.s, horizontal: 16.0.s), - itemCount: sortedMembers.length + (isOwner ? 1 : 0), - separatorBuilder: (_, int index) { - return SizedBox(height: 12.0.s); - }, - itemBuilder: (_, int i) { - // Show AddMembersButton first if user is owner - if (isOwner && i == 0) { - return Padding( - padding: EdgeInsetsGeometry.only( - top: 10.0.s, - ), - child: AddMembersButton( - onTap: () { - AddGroupParticipantsModalRoute(conversationId: conversationId).push(context); - }, - ), - ); - } - - // Adjust index for member items when AddMembersButton is present - final memberIndex = isOwner ? i - 1 : i; - final memberRole = sortedMembers[memberIndex]; - final participantMasterkey = memberRole.masterPubkey; - - return GroupParticipantsListItem( - participantMasterkey: participantMasterkey, - role: memberRole, - showRemoveButton: canRemoveMembers, - onRemove: () { - ref.read(updateGroupChatMembersServiceProvider).removeMembers( - groupId: conversationId, - participantMasterPubkeys: [participantMasterkey], - ); - }, - onTap: () { - ProfileRoute(pubkey: participantMasterkey).push(context); - }, - ); - }, - ); } } diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/add_group_participants_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/add_group_participants_modal.dart index 1f83660f48..2c53dd70f0 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/add_group_participants_modal.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/add_group_participants_modal.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; -import 'package:ion/app/features/chat/e2ee/providers/group/update_group_chat_members_service.r.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; import 'package:ion/app/features/chat/views/pages/new_group_modal/pages/components/invite_group_participant.dart'; import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; @@ -72,7 +72,7 @@ class AddGroupParticipantsModal extends HookConsumerWidget { return; } - ref.read(updateGroupChatMembersServiceProvider).addMembers( + ref.read(updateGroupMetaDataServiceProvider).addMembers( groupId: conversationId, participantMasterPubkeys: newParticipantPubkeys, ); diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/clear_group_messages_confirm_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/clear_group_messages_confirm_modal.dart new file mode 100644 index 0000000000..31e3246b8b --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/clear_group_messages_confirm_modal.dart @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/modal_sheets/simple_modal_sheet.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class ClearGroupMessagesConfirmModal extends ConsumerWidget { + const ClearGroupMessagesConfirmModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonMinimalSize = Size(56.0.s, 56.0.s); + + return SimpleModalSheet.alert( + iconAsset: Assets.svg.actionGroupClearmessage, + title: context.i18n.group_clear_messages_confirm_title, + description: context.i18n.group_clear_messages_confirm_description, + button: ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + type: ButtonType.outlined, + label: Text(context.i18n.button_cancel), + onPressed: context.pop, + minimumSize: buttonMinimalSize, + ), + ), + SizedBox(width: 15.0.s), + Expanded( + child: Button.compact( + label: Text(context.i18n.group_clear_messages), + onPressed: () async { + //todo add clear messages logic + context.pop(); + // // Get all messages for this conversation + // final messages = ref + // .read(conversationMessagesProvider(conversationId, ConversationType.group)) + // .valueOrNull; + // + // if (messages != null && messages.isNotEmpty) { + // // Get all EventMessage objects from the messages + // final eventMessages = messages.values + // .expand((messageList) => messageList) + // .toList(); + // + // // Delete all messages using the E2EE delete provider + // if (eventMessages.isNotEmpty) { + // ref.read( + // e2eeDeleteMessageProvider( + // messageEvents: eventMessages, + // forEveryone: true, + // ), + // ); + // } + // } + }, + minimumSize: buttonMinimalSize, + backgroundColor: context.theme.appColors.attentionRed, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/confirm_admin_role_assign_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/confirm_admin_role_assign_modal.dart new file mode 100644 index 0000000000..2be8244e37 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/confirm_admin_role_assign_modal.dart @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/list_item/badges_user_list_item.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/role_permissions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/permission_item.dart'; +import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart'; +import 'package:ion/app/utils/username.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class ConfirmAdminRoleAssignModal extends ConsumerWidget { + const ConfirmAdminRoleAssignModal({ + required this.conversationId, + required this.participantMasterkey, + super.key, + }); + + final String conversationId; + final String participantMasterkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userPreviewData = ref.watch(userPreviewDataProvider(participantMasterkey)).valueOrNull; + + if (userPreviewData == null) { + return const SizedBox.shrink(); + } + + return Container( + padding: EdgeInsetsDirectional.only( + top: 20.0.s, + start: 16.0.s, + end: 16.0.s, + bottom: 16.0.s, + ), + decoration: ShapeDecoration( + color: context.theme.appColors.secondaryBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: Radius.circular(30.0.s), + topEnd: Radius.circular(30.0.s), + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Navigation bar + const _AppBar(), + SizedBox(height: 16.0.s), + // Selected user section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.i18n.selected_user_label, + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.quaternaryText, + ), + ), + SizedBox(height: 8.0.s), + BadgesUserListItem( + title: Text(userPreviewData.data.trimmedDisplayName), + subtitle: Text( + prefixUsername(username: userPreviewData.data.name, context: context), + ), + masterPubkey: participantMasterkey, + contentPadding: EdgeInsets.symmetric(horizontal: 12.0.s, vertical: 8.0.s), + backgroundColor: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + constraints: BoxConstraints(minHeight: 60.0.s), + trailing: Assets.svg.iconArrowRight.icon( + color: context.theme.appColors.secondaryText, + ), + ), + ], + ), + SizedBox(height: 16.0.s), + // Permissions section + _AdminPermissions(participantMasterkey: participantMasterkey), + SizedBox(height: 16.0.s), + // Confirm button + Button( + mainAxisSize: MainAxisSize.max, + minimumSize: Size.square(56.0.s), + label: Text(context.i18n.button_confirm), + onPressed: () async { + unawaited( + ref.read(updateGroupMetaDataServiceProvider).promoteMemberToAdmin( + groupId: conversationId, + participantMasterPubkey: participantMasterkey, + ), + ); + context.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.maybePop(); + } + }); + }, + ), + SizedBox(height: 16.0.s), + ], + ), + ); + } +} + +class _AppBar extends StatelessWidget { + const _AppBar(); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + onTap: () => context.pop(), + child: Assets.svg.iconBackArrow.icon( + size: 24.0.s, + flipForRtl: true, + ), + ), + Expanded( + child: Text( + context.i18n.add_administrator_title, + textAlign: TextAlign.center, + style: context.theme.appTextThemes.subtitle.copyWith( + color: context.theme.appColors.primaryText, + ), + ), + ), + SizedBox.square( + dimension: 24.0.s, + ), + ], + ), + ); + } +} + +class _AdminPermissions extends StatelessWidget { + const _AdminPermissions({ + required this.participantMasterkey, + }); + + final String participantMasterkey; + + @override + Widget build(BuildContext context) { + // Get admin role permissions + final adminRole = GroupMemberRole.admin(participantMasterkey); + final rolePermissions = RolePermissions.rolePermission(adminRole); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.i18n.what_can_admin_do_label, + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.quaternaryText, + ), + ), + SizedBox(height: 12.0.s), + Container( + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + child: Column( + children: GroupPermission.values.map((permission) { + return PermissionItem( + permission: permission, + enabled: rolePermissions.contains(permission), + ); + }).toList(), + ), + ), + ], + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_confirm_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_confirm_modal.dart new file mode 100644 index 0000000000..8545173bb0 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_confirm_modal.dart @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/modal_sheets/simple_modal_sheet.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class DeleteGroupConfirmModal extends ConsumerWidget { + const DeleteGroupConfirmModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonMinimalSize = Size(56.0.s, 56.0.s); + + return SimpleModalSheet.alert( + iconAsset: Assets.svg.actionGroupDeletegroup, + title: context.i18n.group_delete_group_confirm_title, + description: context.i18n.group_delete_group_confirm_description, + button: ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + type: ButtonType.outlined, + label: Text(context.i18n.button_cancel), + onPressed: context.pop, + minimumSize: buttonMinimalSize, + ), + ), + SizedBox(width: 15.0.s), + Expanded( + child: Button.compact( + label: Text(context.i18n.button_delete), + onPressed: () { + context.pop(); + //todo add delete conversation logic + // ref.read( + // e2eeDeleteConversationProvider( + // conversationIds: [conversationId], + // forEveryone: true, + // ), + // ); + }, + minimumSize: buttonMinimalSize, + backgroundColor: context.theme.appColors.attentionRed, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_user_confirm_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_user_confirm_modal.dart new file mode 100644 index 0000000000..c80651d728 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_user_confirm_modal.dart @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/modal_sheets/simple_modal_sheet.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class DeleteGroupUserConfirmModal extends ConsumerWidget { + const DeleteGroupUserConfirmModal({ + required this.userNickname, + required this.conversationId, + required this.participantMasterPubkey, + super.key, + }); + + final String userNickname; + final String conversationId; + final String participantMasterPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonMinimalSize = Size(56.0.s, 56.0.s); + + return SimpleModalSheet.alert( + iconAsset: Assets.svg.actionCreatepostDeleterole, + title: context.i18n.group_delete_user_confirm_title, + description: context.i18n.group_delete_user_confirm_description(userNickname), + button: ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + type: ButtonType.outlined, + label: Text(context.i18n.button_cancel), + onPressed: context.pop, + minimumSize: buttonMinimalSize, + ), + ), + SizedBox(width: 15.0.s), + Expanded( + child: Button.compact( + label: Text(context.i18n.button_delete), + onPressed: () { + context.pop(); + ref.read(updateGroupMetaDataServiceProvider).removeMembers( + groupId: conversationId, + participantMasterPubkeys: [participantMasterPubkey], + ); + }, + minimumSize: buttonMinimalSize, + backgroundColor: context.theme.appColors.attentionRed, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/edit_group_button.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/edit_group_button.dart index 964b1767f3..ad3737f4cd 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/edit_group_button.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/edit_group_button.dart @@ -4,18 +4,22 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/components/button/button.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; import 'package:ion/generated/assets.gen.dart'; class EditGroupButton extends ConsumerWidget { const EditGroupButton({ + required this.conversationId, super.key, }); + final String conversationId; + @override Widget build(BuildContext context, WidgetRef ref) { return Button( onPressed: () { - // TODO: Navigate to edit group page + GroupEditPageRoute(conversationId: conversationId).push(context); }, leadingIcon: Assets.svg.iconEditLink.icon( color: context.theme.appColors.onPrimaryAccent, diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_admins_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_admins_modal.dart new file mode 100644 index 0000000000..133df88e6f --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_admins_modal.dart @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/list_item/badges_user_list_item.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/community/models/group_type.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; +import 'package:ion/app/utils/username.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupAdminsModal extends HookConsumerWidget { + const GroupAdminsModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + + final adminsAndOwners = groupMetadata?.members + .where((member) => member is GroupMemberRoleOwner || member is GroupMemberRoleAdmin) + .toList(); + + if (adminsAndOwners == null || adminsAndOwners.isEmpty) { + return const SizedBox.shrink(); + } + + // Count admins (excluding owner) + final adminCount = adminsAndOwners.length - 1; + // Check if we've reached the max admin limit for encrypted groups + final maxAdmins = GroupType.encrypted.maxAdmins; + final isMaxAdminsReached = maxAdmins != null && adminCount >= maxAdmins; + + // Sort so owner comes first + final sortedAdmins = [...adminsAndOwners]..sort((a, b) { + final aIsOwner = a is GroupMemberRoleOwner; + final bIsOwner = b is GroupMemberRoleOwner; + if (aIsOwner && !bIsOwner) { + return -1; + } + if (!aIsOwner && bIsOwner) { + return 1; + } + return 0; + }); + + return SheetContent( + body: SizedBox( + height: 400.0.s, + child: CustomScrollView( + slivers: [ + SliverAppBar( + primary: false, + flexibleSpace: NavigationAppBar.modal( + showBackButton: false, + actions: [ + NavigationCloseButton( + onPressed: () => context.pop(), + ), + ], + title: Text(context.i18n.group_admins_modal_title), + ), + automaticallyImplyLeading: false, + toolbarHeight: NavigationAppBar.modalHeaderHeight, + pinned: true, + ), + PinnedHeaderSliver( + child: ScreenSideOffset.small( + child: Padding( + padding: EdgeInsetsDirectional.only(top: 8.0.s, bottom: 16.0.s), + child: Button( + mainAxisSize: MainAxisSize.max, + minimumSize: Size(56.0.s, 56.0.s), + leadingIcon: Assets.svg.iconPlusCreatechannel.icon( + color: context.theme.appColors.onPrimaryAccent, + ), + label: Text( + context.i18n.channel_create_admins_action, + ), + type: isMaxAdminsReached ? ButtonType.disabled : ButtonType.primary, + disabled: isMaxAdminsReached, + onPressed: isMaxAdminsReached + ? null + : () { + SelectAdministratorModalRoute(conversationId: conversationId) + .push(context); + }, + ), + ), + ), + ), + SliverList.separated( + separatorBuilder: (BuildContext _, int __) => SizedBox(height: 16.0.s), + itemCount: sortedAdmins.length, + itemBuilder: (BuildContext context, int index) { + final adminRole = sortedAdmins[index]; + final participantMasterkey = adminRole.masterPubkey; + + return ScreenSideOffset.small( + child: _GroupAdminCard( + conversationId: conversationId, + participantMasterkey: participantMasterkey, + role: adminRole, + ), + ); + }, + ), + ], + ), + ), + ); + } +} + +class _GroupAdminCard extends ConsumerWidget { + const _GroupAdminCard({ + required this.conversationId, + required this.participantMasterkey, + required this.role, + }); + + final String conversationId; + final String participantMasterkey; + final GroupMemberRole role; + + static double get itemHeight => 60.0.s; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userPreviewData = ref.watch(userPreviewDataProvider(participantMasterkey)); + + return userPreviewData.maybeWhen( + data: (userMetadata) { + if (userMetadata == null) { + return const SizedBox.shrink(); + } + + final roleText = role is GroupMemberRoleOwner + ? context.i18n.channel_create_admin_type_owner + : context.i18n.channel_create_admin_type_admin; + + final isOwner = role is GroupMemberRoleOwner; + + return BadgesUserListItem( + onTap: isOwner + ? () { + ManageOwnerRoleModalRoute(conversationId: conversationId).push(context); + } + : () { + ManageAdminRoleModalRoute( + conversationId: conversationId, + participantMasterkey: participantMasterkey, + ).push(context); + }, + title: Text(userMetadata.data.trimmedDisplayName), + subtitle: Text(prefixUsername(username: userMetadata.data.name, context: context)), + masterPubkey: participantMasterkey, + contentPadding: EdgeInsets.symmetric(horizontal: 12.0.s, vertical: 8.0.s), + backgroundColor: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + constraints: BoxConstraints(minHeight: itemHeight), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + roleText, + style: context.theme.appTextThemes.body + .copyWith(color: context.theme.appColors.primaryAccent), + ), + Padding( + padding: EdgeInsets.all(4.0.s), + child: Assets.svg.iconArrowRight.icon(color: context.theme.appColors.secondaryText), + ), + ], + ), + ); + }, + orElse: () => const SizedBox.shrink(), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart index 8c7d9cc506..3058f1e6cc 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart @@ -1,5 +1,7 @@ // SPDX-License-Identifier: ice License 1.0 +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -23,20 +25,44 @@ class GroupAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final groupImageFile = avatar.media != null - ? useFuture( - ref.watch(mediaEncryptionServiceProvider).getEncryptedMedia( - avatar.media!, - authorPubkey: avatar.masterPubkey, - ), - ).data - : null; + final mediaFuture = useMemoized>( + () { + if (avatar.media == null) { + return Future.value(); + } + return ref + .read(mediaEncryptionServiceProvider) + .getEncryptedMedia( + avatar.media!, + authorPubkey: avatar.masterPubkey, + ) + .then((file) => file as File?); + }, + [avatar.media?.url, avatar.masterPubkey], + ); + final groupImageFileResult = useFuture(mediaFuture); + + File? groupImageFile; + if (groupImageFileResult.hasData) { + final file = groupImageFileResult.data; + if (file != null && file.existsSync() && file.lengthSync() > 0) { + groupImageFile = file; + } + } final avatarSize = size ?? 65.0.s; return Avatar( size: avatarSize, borderRadius: borderRadius ?? BorderRadius.circular(16.0.s), - imageWidget: groupImageFile != null ? Image.file(groupImageFile) : null, + imageWidget: groupImageFile != null + ? Image.file( + groupImageFile, + errorBuilder: (context, error, stackTrace) { + // Show default avatar if image fails to load + return DefaultAvatar(size: avatarSize); + }, + ) + : null, defaultAvatar: DefaultAvatar(size: avatarSize), ); } diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_context_menu.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_context_menu.dart index 95ad811fc0..d8907cf019 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_context_menu.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_context_menu.dart @@ -103,7 +103,7 @@ class GroupContextMenu extends HookConsumerWidget { iconAsset: Assets.svg.iconPopupClear, onPressed: () { closeMenu(); - // TODO: Clear messages for group + ClearGroupMessagesConfirmRoute(conversationId: conversationId).push(context); }, ), ) @@ -119,7 +119,7 @@ class GroupContextMenu extends HookConsumerWidget { iconColor: context.theme.appColors.attentionRed, onPressed: () { closeMenu(); - DeleteConversationRoute(conversationIds: [conversationId]).push(context); + DeleteGroupConfirmRoute(conversationId: conversationId).push(context); }, ), ); diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_details.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_details.dart index ceba8d9af2..bbd4890c34 100644 --- a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_details.dart +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_details.dart @@ -33,9 +33,9 @@ class GroupDetails extends ConsumerWidget { style: context.theme.appTextThemes.title, textAlign: TextAlign.center, ), - if (currentUserRole?.canRemoveMembers ?? false) ...[ + if (currentUserRole?.canEditGroup ?? false) ...[ SizedBox(height: 12.0.s), - const EditGroupButton(), + EditGroupButton(conversationId: conversationId), SizedBox(height: 14.0.s), ], SizedBox(height: 2.0.s), diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_files_tab.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_files_tab.dart new file mode 100644 index 0000000000..3a48f1eeec --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_files_tab.dart @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/group_media_provider.r.dart' + show GroupMediaItem, groupFilesItemsProvider; +import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; +import 'package:ion/app/services/share/share.dart'; +import 'package:ion/app/utils/filesize.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupFilesTab extends ConsumerWidget { + const GroupFilesTab({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filesAsync = ref.watch>>( + groupFilesItemsProvider(conversationId), + ); + + return filesAsync.when( + data: (List files) { + if (files.isEmpty) { + return Center( + child: Text( + context.i18n.common_document, + style: context.theme.appTextThemes.body, + ), + ); + } + + return ListView.separated( + padding: EdgeInsetsDirectional.only( + start: 16.0.s, + end: 16.0.s, + top: 18.0.s, + ), + itemCount: files.length, + separatorBuilder: (context, index) => SizedBox(height: 16.0.s), + itemBuilder: (context, int index) { + final fileItem = files[index]; + return _GroupFileCell( + fileName: fileItem.media.alt ?? 'File', + eventReference: fileItem.eventReference, + publishedAt: fileItem.publishedAt, + media: fileItem.media, + ); + }, + ); + }, + loading: () => const SizedBox.shrink(), + error: (Object error, StackTrace stack) => Center( + child: Text( + context.i18n.common_error, + style: context.theme.appTextThemes.body, + ), + ), + ); + } +} + +class _GroupFileCell extends HookConsumerWidget { + const _GroupFileCell({ + required this.fileName, + required this.eventReference, + required this.publishedAt, + required this.media, + }); + + final String fileName; + final EventReference eventReference; + final int publishedAt; + final MediaAttachment media; + + String _formatDateAndTime(int timestamp) { + final date = timestamp.toDateTime; + final dateStr = DateFormat('d MMM yyyy').format(date); + final timeStr = DateFormat('HH:mm').format(date); + return '$dateStr • $timeStr'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventMessageFuture = useFuture( + useMemoized( + () => ref.read(eventMessageDaoProvider).getByReference(eventReference), + [eventReference], + ), + ); + + final entity = useMemoized( + () { + if (eventMessageFuture.hasData && eventMessageFuture.data != null) { + return EncryptedGroupMessageEntity.fromEventMessage(eventMessageFuture.data!); + } + return null; + }, + [eventMessageFuture.hasData, eventMessageFuture.data], + ); + + final localMediaPath = entity != null + ? ref.watch( + chatMessageMediaPathProvider( + entity: entity, + loadThumbnail: false, + mediaAttachment: media, + ).select((value) => value.valueOrNull), + ) + : null; + + final fileSizeStr = localMediaPath != null ? formattedFileSize(localMediaPath) ?? '0kb' : '0kb'; + final dateTimeStr = _formatDateAndTime(publishedAt); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (localMediaPath != null) { + shareFile(localMediaPath, name: fileName); + } + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.all(12.0.s), + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36.0.s, + height: 36.0.s, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30.0.s), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.0.s), + child: ColoredBox( + color: context.theme.appColors.tertiaryBackground, + child: Center( + child: Assets.svg.iconFeedAddfile.icon( + size: 20.0.s, + color: context.theme.appColors.primaryAccent, + ), + ), + ), + ), + ), + SizedBox(width: 12.0.s), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.theme.appTextThemes.subtitle3, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '$fileSizeStr • $dateTimeStr', + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.tertiaryText, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_links_tab.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_links_tab.dart new file mode 100644 index 0000000000..46b5c0a9ed --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_links_tab.dart @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/url_preview/url_preview.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/group_links_provider.r.dart' + show GroupLinkItem, groupLinksProvider; +import 'package:ion/app/services/browser/browser.dart'; +import 'package:ion/app/utils/url.dart'; + +class GroupLinksTab extends ConsumerWidget { + const GroupLinksTab({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final linksAsync = ref.watch>>( + groupLinksProvider(conversationId), + ); + + return linksAsync.when( + data: (List links) { + if (links.isEmpty) { + return Center( + child: Text( + context.i18n.common_links, + style: context.theme.appTextThemes.body, + ), + ); + } + + return ListView.separated( + padding: EdgeInsetsDirectional.only( + start: 16.0.s, + end: 16.0.s, + top: 18.0.s, + ), + itemCount: links.length, + separatorBuilder: (context, index) => SizedBox(height: 16.0.s), + itemBuilder: (context, int index) { + final linkItem = links[index]; + return _GroupLinkCell( + url: linkItem.url, + onTap: () => openDeepLinkOrInAppBrowser(linkItem.url, ref), + ); + }, + ); + }, + loading: () => const SizedBox.shrink(), + error: (Object error, StackTrace stack) => Center( + child: Text( + context.i18n.common_error, + style: context.theme.appTextThemes.body, + ), + ), + ); + } +} + +class _GroupLinkCell extends ConsumerWidget { + const _GroupLinkCell({ + required this.url, + required this.onTap, + }); + + final String url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: UrlPreview( + url: url, + builder: (meta, favIconUrl) { + return Container( + padding: EdgeInsets.all(16.0.s), + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (meta?.title != null) + Padding( + padding: EdgeInsetsDirectional.only(bottom: 8.0.s), + child: Text( + meta!.title!, + style: context.theme.appTextThemes.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: EdgeInsetsDirectional.only(bottom: 8.0.s), + child: Text( + normalizeUrl(url), + style: context.theme.appTextThemes.body2.copyWith( + color: context.theme.appColors.primaryAccent, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (meta?.description != null) + Text( + meta!.description!, + style: context.theme.appTextThemes.body2, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_media_tab.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_media_tab.dart new file mode 100644 index 0000000000..d4a58d8fde --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_media_tab.dart @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/group_media_provider.r.dart' + show GroupMediaItem, groupMediaItemsProvider; +import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; +import 'package:ion/app/features/core/model/media_type.dart'; +import 'package:ion/app/features/core/providers/mute_provider.r.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/services/file_cache/ion_file_cache_manager.r.dart'; +import 'package:ion/app/services/media_service/media_encryption_service.m.dart'; +import 'package:ion/app/utils/date.dart'; +import 'package:ion/app/utils/url.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupMediaTab extends HookConsumerWidget { + const GroupMediaTab({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + static const _horizontalPadding = 20.0; + static const _topPadding = 18.0; + static const _itemSpacing = 16.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaAsync = ref.watch>>( + groupMediaItemsProvider(conversationId), + ); + + return mediaAsync.when( + data: (List mediaItems) { + if (mediaItems.isEmpty) { + return Center( + child: Text( + context.i18n.common_media, + style: context.theme.appTextThemes.body, + ), + ); + } + + return ListView.separated( + padding: EdgeInsetsDirectional.only( + start: _horizontalPadding.s, + end: _horizontalPadding.s, + top: _topPadding.s, + ), + itemCount: mediaItems.length, + separatorBuilder: (context, index) => SizedBox(height: _itemSpacing.s), + itemBuilder: (context, int index) { + final mediaItem = mediaItems[index]; + return _GroupMediaCell( + media: mediaItem.media, + eventReference: mediaItem.eventReference, + onTap: () { + // Find all media items from the same event reference + final sameEventMedia = mediaItems + .where( + (item) => + item.eventReference == mediaItem.eventReference && item.isVisualMedia, + ) + .toList(); + final mediaIndex = sameEventMedia.indexOf(mediaItem); + + ChatMediaRoute( + eventReference: mediaItem.eventReference.encode(), + initialIndex: mediaIndex, + ).push(context); + }, + ); + }, + ); + }, + loading: () => const SizedBox.shrink(), + error: (Object error, StackTrace stack) => Center( + child: Text( + context.i18n.common_error, + style: context.theme.appTextThemes.body, + ), + ), + ); + } +} + +class _GroupMediaCell extends HookConsumerWidget { + const _GroupMediaCell({ + required this.media, + required this.eventReference, + required this.onTap, + }); + + static const _cornerRadius = 16.0; + + final MediaAttachment media; + final EventReference eventReference; + final VoidCallback onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isVideo = media.mediaTypeEncrypted == MediaType.video; + + return GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(_GroupMediaCell._cornerRadius.s), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + _MediaThumbnail( + media: media, + eventReference: eventReference, + ), + if (isVideo && media.duration != null) + PositionedDirectional( + bottom: 12.0.s, + start: 12.0.s, + child: Container( + padding: EdgeInsetsDirectional.only( + start: 4.0.s, + end: 4.0.s, + bottom: 1.0.s, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.theme.appColors.backgroundSheet.withValues(alpha: 0xB2 / 0xFF), + borderRadius: BorderRadius.circular(6.0.s), + ), + child: Text( + formatDuration(Duration(seconds: media.duration!)), + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.secondaryBackground, + ), + ), + ), + ), + if (isVideo) + PositionedDirectional( + bottom: 12.0.s, + end: 12.0.s, + child: Consumer( + builder: (context, ref, child) { + final isMuted = ref.watch(globalMuteNotifierProvider); + return GestureDetector( + onTap: () async { + await HapticFeedback.lightImpact(); + if (context.mounted) { + await ref.read(globalMuteNotifierProvider.notifier).toggle(); + } + }, + child: SizedBox.square( + dimension: 28.0.s, + child: Container( + decoration: BoxDecoration( + color: context.theme.appColors.backgroundSheet.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12.0.s), + ), + child: Center( + child: (isMuted + ? Assets.svg.iconChannelMute + : Assets.svg.iconChannelUnmute) + .icon( + size: 16.0.s, + color: context.theme.appColors.onPrimaryAccent, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MediaThumbnail extends HookConsumerWidget { + const _MediaThumbnail({ + required this.media, + required this.eventReference, + }); + + final MediaAttachment media; + final EventReference eventReference; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final thumbnailUrl = media.thumb ?? media.url; + final isRemoteUrl = isNetworkUrl(thumbnailUrl); + + if (!isRemoteUrl) { + return Image.file( + File(thumbnailUrl), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ); + } + + final fileFuture = useFuture( + useMemoized( + () async { + final cachedData = + await ref.read(ionConnectFileCacheServiceProvider).getFileFromCache(thumbnailUrl); + + if (cachedData != null) { + return cachedData.file.path; + } + + // Try to load the media using the entity + try { + final eventMessage = + await ref.read(eventMessageDaoProvider).getByReference(eventReference); + final entity = EncryptedGroupMessageEntity.fromEventMessage(eventMessage); + + // Get thumbnail from media attachments + final mediaAttachmentToLoad = entity.data.media.values.firstWhere( + (e) => e.url == media.thumb, + orElse: () => media, + ); + + final file = await ref.read(mediaEncryptionServiceProvider).getEncryptedMedia( + mediaAttachmentToLoad, + authorPubkey: entity.masterPubkey, + ); + + return file.path; + } catch (_) { + return null; + } + }, + [thumbnailUrl, eventReference], + ), + ); + + if (!fileFuture.hasData) { + return const SizedBox.shrink(); + } + + final path = fileFuture.data; + if (path == null) { + return const SizedBox.shrink(); + } + + if (fileFuture.hasError) { + return const SizedBox.shrink(); + } + + return Image.file( + File(path), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_members_tab.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_members_tab.dart new file mode 100644 index 0000000000..168c46fa51 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_members_tab.dart @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.m.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/group_metadata.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/add_members_button.dart'; +import 'package:ion/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart'; +import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/utils/username.dart'; + +class GroupMembersTab extends HookConsumerWidget { + const GroupMembersTab({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + + if (groupMetadata == null) { + return const SizedBox.shrink(); + } + + final members = groupMetadata.members; + + if (members.isEmpty) { + return Center( + child: Text( + context.i18n.group_no_members, + style: context.theme.appTextThemes.body, + ), + ); + } + + final currentUserMasterPubkey = ref.watch(currentPubkeySelectorProvider); + final currentUserRole = currentUserMasterPubkey != null + ? groupMetadata.currentUserRole(currentUserMasterPubkey) + : null; + + final canRemoveMembers = currentUserRole?.canRemoveMembers ?? false; + final isOwner = currentUserRole is GroupMemberRoleOwner; + + // Sort members so that owner comes first + final sortedMembers = groupMetadata.membersSorted; + + return ListView.separated( + padding: EdgeInsets.symmetric(vertical: 8.0.s, horizontal: 16.0.s), + itemCount: sortedMembers.length + (isOwner ? 1 : 0), + separatorBuilder: (_, int index) { + return SizedBox(height: 12.0.s); + }, + itemBuilder: (_, int i) { + // Show AddMembersButton first if user is owner + if (isOwner && i == 0) { + return Padding( + padding: EdgeInsetsGeometry.only( + top: 10.0.s, + ), + child: AddMembersButton( + onTap: () { + AddGroupParticipantsModalRoute(conversationId: conversationId).push(context); + }, + ), + ); + } + + // Adjust index for member items when AddMembersButton is present + final memberIndex = isOwner ? i - 1 : i; + final memberRole = sortedMembers[memberIndex]; + final participantMasterkey = memberRole.masterPubkey; + + return GroupParticipantsListItem( + participantMasterkey: participantMasterkey, + role: memberRole, + actionType: canRemoveMembers ? ActionType.remove : null, + onActionTap: () { + final userPreviewData = + ref.read(userPreviewDataProvider(participantMasterkey)).valueOrNull; + if (userPreviewData != null) { + final userNickname = prefixUsername( + username: userPreviewData.data.name, + context: context, + ); + DeleteGroupUserConfirmRoute( + userNickname: userNickname, + conversationId: conversationId, + participantMasterPubkey: participantMasterkey, + ).push(context); + } + }, + onTap: () { + ProfileRoute(pubkey: participantMasterkey).push(context); + }, + ); + }, + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_role_action_item.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_role_action_item.dart new file mode 100644 index 0000000000..b8ee625e36 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_role_action_item.dart @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/list_item/list_item.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupRoleActionItem extends StatelessWidget { + const GroupRoleActionItem({ + required this.title, + required this.onTap, + required this.iconAsset, + this.iconColor, + super.key, + }); + + final String title; + final VoidCallback onTap; + final String iconAsset; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final colors = context.theme.appColors; + final textStyles = context.theme.appTextThemes; + + return ScreenSideOffset.small( + child: ListItem( + contentPadding: EdgeInsetsDirectional.only( + start: ScreenSideOffset.defaultSmallMargin, + end: 8.0.s, + ), + title: Text( + title, + style: textStyles.body, + ), + backgroundColor: colors.tertiaryBackground, + onTap: onTap, + leading: ButtonIconFrame( + containerSize: 36.0.s, + borderRadius: BorderRadius.circular(10.0.s), + color: colors.onPrimaryAccent, + icon: iconAsset.icon( + size: 24.0.s, + color: iconColor ?? colors.attentionRed, + ), + border: Border.fromBorderSide( + BorderSide(color: colors.onTertiaryFill, width: 1.0.s), + ), + ), + trailing: Padding( + padding: EdgeInsets.all(8.0.s), + child: Assets.svg.iconArrowRight.icon( + color: colors.tertiaryText, + ), + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_voice_tab.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_voice_tab.dart new file mode 100644 index 0000000000..20966d02dc --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/group_voice_tab.dart @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:audio_waveforms/audio_waveforms.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:ion/app/components/progress_bar/ion_loading_indicator.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/group_media_provider.r.dart' + show GroupMediaItem, groupVoiceItemsProvider; +import 'package:ion/app/features/chat/hooks/use_audio_playback_controller.dart'; +import 'package:ion/app/features/chat/hooks/use_audio_playback_setup.dart'; +import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; +import 'package:ion/app/features/chat/providers/active_audio_message_provider.r.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/features/ion_connect/model/media_attachment.dart'; +import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart'; +import 'package:ion/app/utils/date.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupVoiceTab extends ConsumerWidget { + const GroupVoiceTab({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final voiceAsync = ref.watch>>( + groupVoiceItemsProvider(conversationId), + ); + + return voiceAsync.when( + data: (List voiceItems) { + if (voiceItems.isEmpty) { + return Center( + child: Text( + context.i18n.common_voice, + style: context.theme.appTextThemes.body, + ), + ); + } + + return ListView.separated( + padding: EdgeInsetsDirectional.only( + start: 16.0.s, + end: 16.0.s, + top: 18.0.s, + ), + itemCount: voiceItems.length, + separatorBuilder: (context, index) => SizedBox(height: 16.0.s), + itemBuilder: (context, int index) { + final voiceItem = voiceItems[index]; + return _GroupVoiceCell( + media: voiceItem.media, + duration: voiceItem.media.duration, + eventReference: voiceItem.eventReference, + publishedAt: voiceItem.publishedAt, + ); + }, + ); + }, + loading: () => const SizedBox.shrink(), + error: (Object error, StackTrace stack) => Center( + child: Text( + context.i18n.common_error, + style: context.theme.appTextThemes.body, + ), + ), + ); + } +} + +class _GroupVoiceCell extends HookConsumerWidget { + const _GroupVoiceCell({ + required this.media, + required this.duration, + required this.eventReference, + required this.publishedAt, + }); + + final MediaAttachment media; + final int? duration; + final EventReference eventReference; + final int publishedAt; + + String _formatDateAndTime(int timestamp) { + final date = timestamp.toDateTime; + final dateStr = DateFormat('d MMM yyyy').format(date); + final timeStr = DateFormat('HH:mm').format(date); + return '$dateStr • $timeStr'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final displayName = ref.watch( + userPreviewDataProvider(eventReference.masterPubkey, network: false) + .select(userPreviewDisplayNameSelector), + ); + + final eventMessageFuture = useFuture( + useMemoized( + () => ref.read(eventMessageDaoProvider).getByReference(eventReference), + [eventReference], + ), + ); + + final entity = useMemoized( + () { + if (eventMessageFuture.hasData && eventMessageFuture.data != null) { + return EncryptedGroupMessageEntity.fromEventMessage(eventMessageFuture.data!); + } + return null; + }, + [eventMessageFuture.hasData, eventMessageFuture.data], + ); + + final localMediaPath = entity != null + ? ref.watch( + chatMessageMediaPathProvider( + entity: entity, + loadThumbnail: false, + convertAudioToWav: true, + mediaAttachment: media, + ).select((value) => value.valueOrNull), + ) + : null; + + if (localMediaPath == null && eventMessageFuture.hasData) { + return const SizedBox(); + } + + final audioPlaybackController = useAudioWavePlaybackController() + ..setFinishMode(finishMode: FinishMode.pause); + + final eventMessageId = useMemoized( + () => eventMessageFuture.hasData && eventMessageFuture.data != null + ? eventMessageFuture.data!.id + : null, + [eventMessageFuture.hasData, eventMessageFuture.data], + ); + + final audioPlayback = useAudioPlaybackSetup( + eventMessageId: eventMessageId, + eventReference: eventReference, + localMediaPath: localMediaPath, + audioPlaybackController: audioPlaybackController, + liveWaveColor: context.theme.appColors.primaryText, + context: context, + ref: ref, + ); + final audioPlaybackState = audioPlayback.audioPlaybackState; + final playerId = audioPlayback.playerId; + + // Get duration from controller or fallback to media duration + final maxDuration = useMemoized( + () => audioPlaybackController.maxDuration > 0 + ? audioPlaybackController.maxDuration + : (duration != null ? duration! * 1000 : 0), + [audioPlaybackController.maxDuration, duration], + ); + + final durationStr = maxDuration > 0 + ? formatDuration(Duration(milliseconds: maxDuration)) + : (duration != null ? formatDuration(Duration(seconds: duration!)) : '0:00'); + + final dateTimeStr = _formatDateAndTime(publishedAt); + + return Container( + width: double.infinity, + padding: EdgeInsets.all(12.0.s), + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () { + if (localMediaPath == null || eventMessageId == null) { + return; + } + if (audioPlaybackState.value?.isPlaying ?? false) { + ref.read(activeAudioMessageProvider.notifier).activeAudioMessage = null; + } else { + ref.read(activeAudioMessageProvider.notifier).activeAudioMessage = playerId; + } + }, + child: Container( + width: 36.0.s, + height: 36.0.s, + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(12.0.s), + border: Border.all( + width: 1.0.s, + color: context.theme.appColors.onTertiaryFill, + ), + ), + child: Center( + child: localMediaPath == null || eventMessageId == null + ? const IONLoadingIndicator( + type: IndicatorType.dark, + ) + : ValueListenableBuilder( + valueListenable: audioPlaybackState, + builder: (context, state, child) { + if (state == null) { + return Assets.svg.iconVideoPlay.icon( + size: 20.0.s, + color: context.theme.appColors.primaryAccent, + ); + } + return state.isPlaying + ? Assets.svg.iconVideoPause.icon( + size: 20.0.s, + color: context.theme.appColors.primaryAccent, + ) + : Assets.svg.iconVideoPlay.icon( + size: 20.0.s, + color: context.theme.appColors.primaryAccent, + ); + }, + ), + ), + ), + ), + SizedBox(width: 8.0.s), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: context.theme.appTextThemes.subtitle3, + ), + Text( + '$durationStr • $dateTimeStr', + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.tertiaryText, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_admin_role_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_admin_role_modal.dart new file mode 100644 index 0000000000..042667f2b5 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_admin_role_modal.dart @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_role_action_item.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class ManageAdminRoleModal extends ConsumerWidget { + const ManageAdminRoleModal({ + required this.conversationId, + required this.participantMasterkey, + super.key, + }); + + final String conversationId; + final String participantMasterkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SheetContent( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NavigationAppBar.modal( + onBackPress: () => context.pop(), + title: Text(context.i18n.channel_create_admin_type_title), + ), + SizedBox(height: 16.0.s), + GroupRoleActionItem( + title: context.i18n.channel_create_admin_type_remove, + onTap: () { + RemoveAdminRoleConfirmModalRoute( + conversationId: conversationId, + participantMasterPubkey: participantMasterkey, + ).push(context); + }, + iconAsset: Assets.svg.iconBlockDelete, + ), + ScreenBottomOffset(), + ], + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_owner_role_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_owner_role_modal.dart new file mode 100644 index 0000000000..a56e20f5f8 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_owner_role_modal.dart @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_role_action_item.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class ManageOwnerRoleModal extends ConsumerWidget { + const ManageOwnerRoleModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SheetContent( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NavigationAppBar.modal( + showBackButton: false, + actions: [ + NavigationCloseButton( + onPressed: () => context.pop(), + ), + ], + title: Text(context.i18n.channel_create_admin_type_owner), + ), + SizedBox(height: 16.0.s), + GroupRoleActionItem( + title: context.i18n.transfer_ownership, + iconAsset: Assets.svg.iconSwap, + iconColor: context.theme.appColors.primaryAccent, + onTap: () { + TransferOwnershipPageRoute(conversationId: conversationId).push(context); + }, + ), + ScreenBottomOffset(), + ], + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/permission_item.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/permission_item.dart new file mode 100644 index 0000000000..c7ff0a7822 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/permission_item.dart @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:ion/app/components/list_item/list_item.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/role_permissions.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class PermissionItem extends StatelessWidget { + const PermissionItem({ + required this.permission, + required this.enabled, + super.key, + }); + + final GroupPermission permission; + final bool enabled; + + String _getTitle(BuildContext context) { + return switch (permission) { + GroupPermission.deleteMessages => context.i18n.admin_permission_delete_messages, + GroupPermission.pinMessages => context.i18n.admin_permission_pin_messages, + GroupPermission.deleteUsers => context.i18n.admin_permission_delete_users, + GroupPermission.addNewUsers => context.i18n.admin_permission_add_new_users, + GroupPermission.addNewAdmins => context.i18n.admin_permission_add_new_admins, + GroupPermission.changeGroupInfo => context.i18n.admin_permission_change_group_info, + GroupPermission.clearGroup => context.i18n.admin_permission_clear_group, + }; + } + + @override + Widget build(BuildContext context) { + return ListItem( + title: Text( + _getTitle(context), + style: context.theme.appTextThemes.body, + ), + backgroundColor: Colors.transparent, + contentPadding: EdgeInsets.symmetric(horizontal: 12.0.s, vertical: 8.0.s), + constraints: BoxConstraints(minHeight: 60.0.s), + trailing: enabled + ? Assets.svg.iconAdminStatus.icon( + size: 24.0.s, + color: context.theme.appColors.success, + ) + : Assets.svg.iconBlockClose3.icon( + size: 24.0.s, + color: context.theme.appColors.attentionRed, + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/remove_admin_role_confirm_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/remove_admin_role_confirm_modal.dart new file mode 100644 index 0000000000..0c5a6632ec --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/remove_admin_role_confirm_modal.dart @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/modal_sheets/simple_modal_sheet.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class RemoveAdminRoleConfirmModal extends ConsumerWidget { + const RemoveAdminRoleConfirmModal({ + required this.conversationId, + required this.participantMasterPubkey, + super.key, + }); + + final String conversationId; + final String participantMasterPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonMinimalSize = Size(56.0.s, 56.0.s); + + return SimpleModalSheet.alert( + iconAsset: Assets.svg.actionCreatepostDeleterole, + title: context.i18n.channel_create_admin_type_remove_title, + description: context.i18n.channel_create_admin_type_remove_desc, + button: ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + type: ButtonType.outlined, + label: Text(context.i18n.button_cancel), + onPressed: context.pop, + minimumSize: buttonMinimalSize, + ), + ), + SizedBox(width: 15.0.s), + Expanded( + child: Button.compact( + label: Text(context.i18n.button_delete), + onPressed: () { + ref.read(updateGroupMetaDataServiceProvider).removeAdminRole( + groupId: conversationId, + participantMasterPubkey: participantMasterPubkey, + ); + context.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.maybePop(); + } + }); + }, + minimumSize: buttonMinimalSize, + backgroundColor: context.theme.appColors.attentionRed, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_administrator_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_administrator_modal.dart new file mode 100644 index 0000000000..e15da93b66 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_administrator_modal.dart @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; + +class SelectAdministratorModal extends ConsumerWidget { + const SelectAdministratorModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + + if (groupMetadata == null) { + return const SizedBox.shrink(); + } + + final sortedMembers = groupMetadata.membersSorted; + + return SheetContent( + body: SizedBox( + height: 400.0.s, + child: Column( + children: [ + NavigationAppBar.modal( + showBackButton: false, + actions: [ + NavigationCloseButton( + onPressed: () => context.pop(), + ), + ], + title: Text(context.i18n.select_administrator_title), + ), + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric(vertical: 8.0.s, horizontal: 16.0.s), + itemCount: sortedMembers.length, + separatorBuilder: (_, int index) { + return SizedBox(height: 12.0.s); + }, + itemBuilder: (_, int index) { + final memberRole = sortedMembers[index]; + final participantMasterkey = memberRole.masterPubkey; + final isOwner = memberRole is GroupMemberRoleOwner; + final isAdmin = memberRole is GroupMemberRoleAdmin; + final isDisabled = isOwner || isAdmin; + + return GroupParticipantsListItem( + participantMasterkey: participantMasterkey, + role: memberRole, + actionType: ActionType.select, + disabled: isDisabled, + onTap: () { + if (isDisabled) return; + ConfirmAdminRoleAssignModalRoute( + conversationId: conversationId, + participantMasterkey: participantMasterkey, + ).push(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_owner_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_owner_modal.dart new file mode 100644 index 0000000000..651af6d738 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/select_owner_modal.dart @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.m.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; + +class SelectOwnerModal extends ConsumerWidget { + const SelectOwnerModal({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + + if (groupMetadata == null) { + return const SizedBox.shrink(); + } + + final currentUserMasterPubkey = ref.watch(currentPubkeySelectorProvider); + final sortedMembers = groupMetadata.membersSorted; + + return SheetContent( + body: SizedBox( + height: 400.0.s, + child: Column( + children: [ + NavigationAppBar.modal( + showBackButton: false, + actions: [ + NavigationCloseButton( + onPressed: () => context.pop(), + ), + ], + title: Text(context.i18n.common_select_option), + ), + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric(vertical: 8.0.s, horizontal: 16.0.s), + itemCount: sortedMembers.length, + separatorBuilder: (_, int index) { + return SizedBox(height: 12.0.s); + }, + itemBuilder: (_, int index) { + final memberRole = sortedMembers[index]; + final participantMasterkey = memberRole.masterPubkey; + final isOwner = memberRole is GroupMemberRoleOwner; + // Disable current owner (can't transfer to themselves) + final isDisabled = isOwner || participantMasterkey == currentUserMasterPubkey; + + return GroupParticipantsListItem( + participantMasterkey: participantMasterkey, + role: memberRole, + actionType: ActionType.select, + disabled: isDisabled, + onTap: () { + if (isDisabled) return; + context.pop(participantMasterkey); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_confirm_modal.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_confirm_modal.dart new file mode 100644 index 0000000000..5f190232c7 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_confirm_modal.dart @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/modal_sheets/simple_modal_sheet.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class TransferOwnershipConfirmModal extends ConsumerWidget { + const TransferOwnershipConfirmModal({ + required this.conversationId, + required this.newOwnerMasterPubkey, + required this.currentOwnerMasterPubkey, + super.key, + }); + + final String conversationId; + final String newOwnerMasterPubkey; + final String currentOwnerMasterPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonMinimalSize = Size(56.0.s, 56.0.s); + + return SimpleModalSheet.alert( + iconAsset: Assets.svg.actionCreatepostTransferrole, + title: context.i18n.transfer_group_ownership_title, + description: context.i18n.transfer_group_ownership_desc, + button: ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + label: Text(context.i18n.change_owner), + onPressed: () { + ref.read(updateGroupMetaDataServiceProvider).transferOwnership( + groupId: conversationId, + newOwnerMasterPubkey: newOwnerMasterPubkey, + currentOwnerMasterPubkey: currentOwnerMasterPubkey, + ); + context.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.maybePop(); + } + }); + }, + minimumSize: buttonMinimalSize, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_page.dart b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_page.dart new file mode 100644 index 0000000000..c37af781e1 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_page.dart @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/list_item/badges_user_list_item.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.m.dart'; +import 'package:ion/app/features/chat/e2ee/model/role_permissions.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/permission_item.dart'; +import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; +import 'package:ion/app/utils/username.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class TransferOwnershipPage extends HookConsumerWidget { + const TransferOwnershipPage({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedOwnerPubkey = useState(null); + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + + final currentUserMasterPubkey = ref.watch(currentPubkeySelectorProvider); + final currentUserRole = currentUserMasterPubkey != null + ? groupMetadata?.currentUserRole(currentUserMasterPubkey) + : null; + if (groupMetadata == null || currentUserRole == null) { + return const SheetContent( + body: SizedBox.shrink(), + ); + } + + final selectedUserData = selectedOwnerPubkey.value != null + ? ref.watch(userPreviewDataProvider(selectedOwnerPubkey.value!)).valueOrNull + : null; + + final canConfirm = selectedOwnerPubkey.value != null; + + // Get current user role permissions as we checked already he is the owner + final rolePermissions = RolePermissions.rolePermission(currentUserRole); + + return SheetContent( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NavigationAppBar.modal( + onBackPress: () => context.pop(), + title: Text(context.i18n.channel_create_admin_type_owner), + ), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 16.0.s, vertical: 16.0.s), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Owner selection section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.i18n.channel_create_admin_type_owner, + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.quaternaryText, + ), + ), + SizedBox(height: 8.0.s), + GestureDetector( + onTap: () { + SelectOwnerModalRoute(conversationId: conversationId) + .push(context) + .then((result) { + if (result != null) { + selectedOwnerPubkey.value = result; + } + }); + }, + child: Container( + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + padding: EdgeInsets.symmetric(horizontal: 12.0.s, vertical: 8.0.s), + constraints: BoxConstraints(minHeight: 60.0.s), + child: selectedUserData != null + ? BadgesUserListItem( + title: Text(selectedUserData.data.trimmedDisplayName), + subtitle: Text( + prefixUsername( + username: selectedUserData.data.name, + context: context, + ), + ), + masterPubkey: selectedOwnerPubkey.value!, + contentPadding: EdgeInsets.zero, + trailing: Assets.svg.iconArrowRight.icon( + color: context.theme.appColors.secondaryText, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.i18n.select_new_owner, + style: context.theme.appTextThemes.body.copyWith( + color: context.theme.appColors.tertiaryText, + ), + ), + Assets.svg.iconArrowRight.icon( + color: context.theme.appColors.secondaryText, + ), + ], + ), + ), + ), + ], + ), + SizedBox(height: 24.0.s), + // Permissions section + _OwnerPermissions(rolePermissions: rolePermissions), + ], + ), + ), + ), + // Confirm button + ScreenSideOffset.small( + child: Padding( + padding: EdgeInsetsDirectional.only(bottom: 16.0.s), + child: Button( + mainAxisSize: MainAxisSize.max, + minimumSize: Size(56.0.s, 56.0.s), + label: Text(context.i18n.button_confirm), + type: canConfirm ? ButtonType.primary : ButtonType.disabled, + disabled: !canConfirm, + onPressed: canConfirm + ? () { + TransferOwnershipConfirmModalRoute( + conversationId: conversationId, + newOwnerMasterPubkey: selectedOwnerPubkey.value!, + currentOwnerMasterPubkey: currentUserMasterPubkey!, + ).push(context); + } + : null, + ), + ), + ), + ], + ), + ); + } +} + +class _OwnerPermissions extends StatelessWidget { + const _OwnerPermissions({ + required this.rolePermissions, + }); + + final List rolePermissions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.i18n.what_can_owner_do_label, + style: context.theme.appTextThemes.caption.copyWith( + color: context.theme.appColors.quaternaryText, + ), + ), + SizedBox(height: 12.0.s), + Container( + decoration: BoxDecoration( + color: context.theme.appColors.tertiaryBackground, + borderRadius: BorderRadius.circular(16.0.s), + ), + child: Column( + children: GroupPermission.values.map((permission) { + return PermissionItem( + permission: permission, + enabled: rolePermissions.contains(permission), + ); + }).toList(), + ), + ), + ], + ); + } +} diff --git a/lib/app/features/chat/e2ee/views/pages/group_edit_page.dart b/lib/app/features/chat/e2ee/views/pages/group_edit_page.dart new file mode 100644 index 0000000000..ac15ef8b17 --- /dev/null +++ b/lib/app/features/chat/e2ee/views/pages/group_edit_page.dart @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/progress_bar/ion_loading_indicator.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/views/components/user_data_inputs/general_user_data_input.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_group_message_entity.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/entities/group_member_role.f.dart'; +import 'package:ion/app/features/chat/e2ee/model/group_metadata.f.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/encrypted_group_metadata_provider.r.dart'; +import 'package:ion/app/features/chat/e2ee/providers/group/update_group_metadata_service.r.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_avatar.dart'; +import 'package:ion/app/features/chat/views/components/general_selection_button.dart'; +import 'package:ion/app/features/components/avatar_picker/avatar_picker.dart'; +import 'package:ion/app/features/user/providers/image_proccessor_notifier.m.dart'; +import 'package:ion/app/router/app_routes.gr.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; +import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; +import 'package:ion/app/services/media_service/image_proccessing_config.dart'; +import 'package:ion/app/utils/validators.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class GroupEditPage extends HookConsumerWidget { + const GroupEditPage({ + required this.conversationId, + super.key, + }); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupMetadata = ref.watch(encryptedGroupMetadataProvider(conversationId)).valueOrNull; + final formKey = useMemoized(GlobalKey.new); + + if (groupMetadata == null) { + return const Center(child: CircularProgressIndicator()); + } + final nameController = useTextEditingController(text: groupMetadata.name); + + final adminCount = groupMetadata.members + .where((member) => member is GroupMemberRoleOwner || member is GroupMemberRoleAdmin) + .length; + + return SheetContent( + topPadding: 0, + body: Column( + children: [ + Container( + padding: EdgeInsetsDirectional.only( + top: 20.0.s, + start: 16.0.s, + end: 16.0.s, + bottom: 16.0.s, + ), + child: NavigationAppBar.modal( + showBackButton: false, + title: Text( + context.i18n.group_edit_title, + style: context.theme.appTextThemes.subtitle.copyWith( + color: context.theme.appColors.primaryText, + ), + ), + horizontalPadding: 0, + actions: const [ + NavigationCloseButton(), + ], + ), + ), + Expanded( + child: Form( + key: formKey, + child: SingleChildScrollView( + child: Center( + child: SizedBox( + width: 287.0.s, + child: Column( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + AvatarPicker( + avatarSize: 100.0.s, + iconSize: 24.0.s, + iconBackgroundSize: 36.0.s, + avatarWidget: GroupAvatar( + avatar: groupMetadata.avatar, + size: 100.0.s, + borderRadius: BorderRadius.circular(20.0.s), + ), + ), + ], + ), + SizedBox(height: 40.0.s), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + GeneralUserDataInput( + controller: nameController, + prefixIconAssetName: Assets.svg.iconFieldName, + labelText: context.i18n.group_create_name_label, + initialVerification: false, + validator: (String? value) { + if (Validators.isEmpty(value)) return ''; + if (Validators.isInvalidLength( + value, + maxLength: EncryptedGroupMessageEntity.nameMaxLength, + )) { + return context.i18n.error_input_length_max( + EncryptedGroupMessageEntity.nameMaxLength, + ); + } + return null; + }, + ), + SizedBox(height: 20.0.s), + GeneralSelectionButton( + iconAsset: Assets.svg.iconChannelType, + title: context.i18n.group_create_type, + selectedValue: context.i18n.group_create_type_encrypted, + enabled: false, + ), + SizedBox(height: 20.0.s), + GeneralSelectionButton( + iconAsset: Assets.svg.iconChannelAdmin, + title: context.i18n.channel_create_admins, + selectedValue: adminCount.toString(), + onPress: () { + GroupAdminsModalRoute(conversationId: conversationId) + .push(context); + }, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + _EditGroupButton( + formKey: formKey, + conversationId: conversationId, + nameController: nameController, + groupMetadata: groupMetadata, + ), + ], + ), + ); + } +} + +class _EditGroupButton extends HookConsumerWidget { + const _EditGroupButton({ + required this.formKey, + required this.conversationId, + required this.nameController, + required this.groupMetadata, + }); + + final GlobalKey formKey; + final String conversationId; + final TextEditingController nameController; + final GroupMetadata groupMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = useState(false); + final isFormValid = useState(true); + final hasTitleChanged = useState(false); + final hasPictureChanged = useState(false); + + final avatarProcessorState = + ref.watch(imageProcessorNotifierProvider(ImageProcessingType.avatar)); + + // Check if title has changed + useEffect( + () { + void checkTitleChange() { + hasTitleChanged.value = nameController.text.trim() != groupMetadata.name.trim(); + } + + nameController.addListener(checkTitleChange); + checkTitleChange(); + return () { + nameController.removeListener(checkTitleChange); + }; + }, + [nameController, groupMetadata.name], + ); + + // Check if picture has changed + useEffect( + () { + final groupPicture = avatarProcessorState.whenOrNull( + cropped: (file) => file, + processed: (file) => file, + ); + hasPictureChanged.value = groupPicture != null; + return null; + }, + [avatarProcessorState], + ); + + // Check if anything has changed + final hasChanges = hasTitleChanged.value || hasPictureChanged.value; + + useEffect( + () { + void validateForm() { + isFormValid.value = formKey.currentState?.validate() ?? false; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + validateForm(); + nameController.addListener(validateForm); + }); + return () { + nameController.removeListener(validateForm); + }; + }, + [nameController], + ); + return Container( + padding: EdgeInsetsDirectional.only( + start: 44.0.s, + end: 44.0.s, + top: 16.0.s, + bottom: 16.0.s, + ), + child: Button( + mainAxisSize: MainAxisSize.max, + label: Text( + context.i18n.button_save, + style: context.theme.appTextThemes.body.copyWith( + color: context.theme.appColors.onPrimaryAccent, + ), + ), + leadingIcon: isLoading.value + ? IONLoadingIndicator(size: Size(24.s, 24.s)) + : Assets.svg.iconProfileSave.icon( + color: context.theme.appColors.onPrimaryAccent, + size: 24.0.s, + ), + disabled: !isFormValid.value || isLoading.value || !hasChanges, + trailingIcon: isLoading.value ? const IONLoadingIndicator() : null, + type: isFormValid.value && hasChanges ? ButtonType.primary : ButtonType.disabled, + onPressed: () async { + if (formKey.currentState!.validate() && hasChanges) { + isLoading.value = true; + + try { + final groupPicture = avatarProcessorState.whenOrNull( + cropped: (file) => file, + processed: (file) => file, + ); + + await ref.read(updateGroupMetaDataServiceProvider).updateMetadata( + groupId: conversationId, + title: hasTitleChanged.value ? nameController.text.trim() : null, + newGroupPicture: hasPictureChanged.value ? groupPicture : null, + ); + + if (context.mounted) { + context.pop(); + } + } finally { + isLoading.value = false; + } + } + }, + ), + ); + } +} diff --git a/lib/app/features/chat/hooks/use_audio_playback_setup.dart b/lib/app/features/chat/hooks/use_audio_playback_setup.dart new file mode 100644 index 0000000000..eebde766ef --- /dev/null +++ b/lib/app/features/chat/hooks/use_audio_playback_setup.dart @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:audio_waveforms/audio_waveforms.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/chat/providers/active_audio_message_provider.r.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.f.dart'; +import 'package:ion/app/services/audio_wave_playback_service/audio_wave_playback_service.r.dart'; + +/// Hook that sets up audio playback for a message. +/// +/// Handles: +/// - Player initialization +/// - State subscriptions +/// - Active audio message listening +/// +/// Returns a record containing: +/// - [audioPlaybackState]: A [ValueNotifier] that tracks the current playback state +/// - [playerWaveStyle]: The [PlayerWaveStyle] used for the audio waveform +/// - [playerId]: The calculated player ID +({ + ValueNotifier audioPlaybackState, + PlayerWaveStyle playerWaveStyle, + String? playerId, +}) useAudioPlaybackSetup({ + required String? localMediaPath, + required PlayerController audioPlaybackController, + required Color liveWaveColor, + required BuildContext context, + required WidgetRef ref, + String? eventMessageId, + EventReference? eventReference, +}) { + final audioPlaybackState = useState(null); + + final playerId = useMemoized( + () => + eventMessageId ?? + (eventReference is ImmutableEventReference + ? eventReference.eventId + : eventReference?.toString()), + [eventMessageId, eventReference], + ); + + final playerWaveStyle = useMemoized( + () => PlayerWaveStyle( + spacing: 2.0.s, + waveThickness: 1.0.s, + seekLineColor: Colors.transparent, + fixedWaveColor: context.theme.appColors.sheetLine, + liveWaveColor: liveWaveColor, + ), + [liveWaveColor], + ); + + // Initialize audio player when path is available + useEffect( + () { + if (localMediaPath == null || playerId == null) { + return null; + } + + ref.read(audioWavePlaybackServiceProvider).initializePlayer( + playerId, + localMediaPath, + audioPlaybackController, + playerWaveStyle, + ); + + final stateSubscription = audioPlaybackController.onPlayerStateChanged.listen((event) { + if (context.mounted) { + if (event != PlayerState.stopped) { + audioPlaybackState.value = event; + } + } + }); + + final completionSubscription = audioPlaybackController.onCompletion.listen((event) { + if (context.mounted) { + ref.read(activeAudioMessageProvider.notifier).activeAudioMessage = null; + } + }); + + return () { + stateSubscription.cancel(); + completionSubscription.cancel(); + }; + }, + [localMediaPath, playerId], + ); + + // Listen to active audio message changes + useEffect( + () { + if (playerId == null) { + return null; + } + + final subscription = ref.listenManual(activeAudioMessageProvider, (previous, next) { + if (next == playerId) { + audioPlaybackController.startPlayer(); + } else { + audioPlaybackController.pausePlayer(); + } + }); + return subscription.close; + }, + [playerId], + ); + + return ( + audioPlaybackState: audioPlaybackState, + playerWaveStyle: playerWaveStyle, + playerId: playerId, + ); +} diff --git a/lib/app/features/chat/views/components/general_selection_button.dart b/lib/app/features/chat/views/components/general_selection_button.dart index e35f1c4a05..17b6147a71 100644 --- a/lib/app/features/chat/views/components/general_selection_button.dart +++ b/lib/app/features/chat/views/components/general_selection_button.dart @@ -10,8 +10,9 @@ class GeneralSelectionButton extends StatelessWidget { const GeneralSelectionButton({ required this.iconAsset, required this.title, - required this.onPress, + this.onPress, this.selectedValue, + this.enabled = true, super.key, }); @@ -19,6 +20,7 @@ class GeneralSelectionButton extends StatelessWidget { final String title; final String? selectedValue; final VoidCallback? onPress; + final bool enabled; @override Widget build(BuildContext context) { @@ -29,14 +31,17 @@ class GeneralSelectionButton extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( - border: Border.all(color: colors.strokeElements), + border: enabled ? Border.all(color: colors.strokeElements) : null, borderRadius: BorderRadius.circular(16.0.s), - color: colors.secondaryBackground, + color: enabled ? colors.secondaryBackground : colors.primaryBackground, ), child: ListItem( contentPadding: EdgeInsetsDirectional.only( - end: 8.0.s, + end: enabled ? 8.0.s : 16.0.s, ), + leadingPadding: enabled + ? ListItem.defaultLeadingPadding + : EdgeInsetsDirectional.only(start: 16.0.s, end: 5.0.s), title: Text( title, style: textTheme.body @@ -44,27 +49,36 @@ class GeneralSelectionButton extends StatelessWidget { ), backgroundColor: Colors.transparent, leading: TextInputIcons( - hasRightDivider: true, + hasRightDivider: enabled, icons: [iconAsset.icon(color: colors.secondaryText)], + minWidth: enabled ? null : 24.0.s, ), - onTap: onPress, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (selectedValue != null) - Text( - selectedValue!, - style: textTheme.body.copyWith(color: colors.primaryAccent), - ), - GestureDetector( - onTap: onPress, - child: Padding( - padding: EdgeInsets.all(4.0.s), - child: Assets.svg.iconArrowRight.icon(color: colors.secondaryText), - ), - ), - ], - ), + onTap: enabled ? onPress : null, + trailing: enabled + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selectedValue != null) + Text( + selectedValue!, + style: textTheme.body.copyWith(color: colors.primaryAccent), + ), + GestureDetector( + onTap: onPress, + child: Padding( + padding: EdgeInsets.all(4.0.s), + child: Assets.svg.iconArrowRight.icon(color: colors.secondaryText), + ), + ), + ], + ) + : selectedValue != null + ? Text( + selectedValue!, + textAlign: TextAlign.right, + style: textTheme.caption.copyWith(color: colors.primaryAccent), + ) + : null, ), ); } diff --git a/lib/app/features/chat/views/components/message_items/message_types/audio_message/audio_message.dart b/lib/app/features/chat/views/components/message_items/message_types/audio_message/audio_message.dart index 49c96a6a3b..8bf7d1f916 100644 --- a/lib/app/features/chat/views/components/message_items/message_types/audio_message/audio_message.dart +++ b/lib/app/features/chat/views/components/message_items/message_types/audio_message/audio_message.dart @@ -11,6 +11,7 @@ import 'package:ion/app/features/chat/e2ee/model/entities/encrypted_direct_messa import 'package:ion/app/features/chat/e2ee/providers/chat_medias_provider.r.dart'; import 'package:ion/app/features/chat/e2ee/providers/chat_message_media_path_provider.r.dart'; import 'package:ion/app/features/chat/hooks/use_audio_playback_controller.dart'; +import 'package:ion/app/features/chat/hooks/use_audio_playback_setup.dart'; import 'package:ion/app/features/chat/hooks/use_has_reaction.dart'; import 'package:ion/app/features/chat/model/database/chat_database.m.dart'; import 'package:ion/app/features/chat/model/message_list_item.f.dart'; @@ -23,7 +24,6 @@ import 'package:ion/app/features/chat/views/components/message_items/message_typ import 'package:ion/app/features/components/entities_list/list_cached_objects.dart'; import 'package:ion/app/features/ion_connect/ion_connect.dart'; import 'package:ion/app/hooks/use_on_init.dart'; -import 'package:ion/app/services/audio_wave_playback_service/audio_wave_playback_service.r.dart'; import 'package:ion/app/utils/date.dart'; import 'package:ion/generated/assets.gen.dart'; @@ -100,84 +100,21 @@ class AudioMessage extends HookConsumerWidget { return const SizedBox(); } - final audioPlaybackState = useState(null); final audioPlaybackController = useAudioWavePlaybackController() ..setFinishMode(finishMode: FinishMode.pause); - final playerWaveStyle = useMemoized( - () => PlayerWaveStyle( - spacing: 2.0.s, - waveThickness: 1.0.s, - seekLineColor: Colors.transparent, - fixedWaveColor: context.theme.appColors.sheetLine, - liveWaveColor: - isMe ? context.theme.appColors.onPrimaryAccent : context.theme.appColors.primaryText, - ), - [isMe], - ); - - useEffect( - () { - ref.read(audioWavePlaybackServiceProvider).initializePlayer( - eventMessage.id, - localMediaPath, - audioPlaybackController, - playerWaveStyle, - ); - - final stateSubscription = audioPlaybackController.onPlayerStateChanged.listen((event) { - if (context.mounted) { - if (event != PlayerState.stopped) { - audioPlaybackState.value = event; - } - } - }); - - final completionSubscription = audioPlaybackController.onCompletion.listen((event) { - if (context.mounted) { - ref.read(activeAudioMessageProvider.notifier).activeAudioMessage = null; - } - }); - - return () { - stateSubscription.cancel(); - completionSubscription.cancel(); - }; - }, - [localMediaPath], - ); - - useEffect( - () { - final stateSubscription = audioPlaybackController.onPlayerStateChanged.listen((event) { - if (context.mounted) { - if (event != PlayerState.stopped) { - audioPlaybackState.value = event; - } - } - }); - - final completionSubscription = audioPlaybackController.onCompletion.listen((event) { - if (context.mounted) { - ref.read(activeAudioMessageProvider.notifier).activeAudioMessage = null; - } - }); - - return () { - stateSubscription.cancel(); - completionSubscription.cancel(); - }; - }, - [localMediaPath], + final audioPlayback = useAudioPlaybackSetup( + eventMessageId: eventMessage.id, + eventReference: eventReference, + localMediaPath: localMediaPath, + audioPlaybackController: audioPlaybackController, + liveWaveColor: + isMe ? context.theme.appColors.onPrimaryAccent : context.theme.appColors.primaryText, + context: context, + ref: ref, ); - - ref.listen(activeAudioMessageProvider, (previous, next) { - if (next == eventMessage.id) { - audioPlaybackController.startPlayer(); - } else { - audioPlaybackController.pausePlayer(); - } - }); + final audioPlaybackState = audioPlayback.audioPlaybackState; + final playerWaveStyle = audioPlayback.playerWaveStyle; final metadataWidth = useState(0); final metadataKey = useMemoized(GlobalKey.new); diff --git a/lib/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart b/lib/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart index b930c11000..c944961e90 100644 --- a/lib/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart +++ b/lib/app/features/chat/views/pages/new_group_modal/componentes/group_participant_list_item.dart @@ -13,17 +13,19 @@ class GroupParticipantsListItem extends ConsumerWidget { const GroupParticipantsListItem({ required this.participantMasterkey, this.role, - this.onRemove, - this.showRemoveButton = true, + this.onActionTap, this.onTap, + this.actionType, + this.disabled = false, super.key, }); final String participantMasterkey; final GroupMemberRole? role; - final VoidCallback? onRemove; - final bool showRemoveButton; + final VoidCallback? onActionTap; final VoidCallback? onTap; + final ActionType? actionType; + final bool disabled; @override Widget build(BuildContext context, WidgetRef ref) { @@ -33,7 +35,24 @@ class GroupParticipantsListItem extends ConsumerWidget { data: (userPreviewData) { if (userPreviewData == null) return const SizedBox.shrink(); - return BadgesUserListItem( + Widget? trailing; + if (role is GroupMemberRoleOwner || + role is GroupMemberRoleAdmin || + role is GroupMemberRoleModerator) { + trailing = _RoleBadge(role: role!); + } else if (actionType == ActionType.select) { + trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Assets.svg.iconArrowRight.icon( + size: 24.0.s, + color: context.theme.appColors.tertiaryText, + ), + ], + ); + } + + final item = BadgesUserListItem( title: Text(userPreviewData.data.trimmedDisplayName), subtitle: Text( prefixUsername(username: userPreviewData.data.name, context: context), @@ -44,31 +63,42 @@ class GroupParticipantsListItem extends ConsumerWidget { masterPubkey: userPreviewData.masterPubkey, contentPadding: EdgeInsets.zero, constraints: BoxConstraints(maxHeight: 39.0.s), - onTap: onTap, - trailing: role is GroupMemberRoleOwner - ? const _OwnerBadge() - : showRemoveButton - ? GestureDetector( - onTap: onRemove, - behavior: HitTestBehavior.opaque, - child: Assets.svg.iconBlockDelete.icon( - size: 24.0.s, - color: context.theme.appColors.sheetLine, - ), - ) - : null, + onTap: disabled ? null : onTap, + trailing: trailing, ); + + return disabled + ? Opacity( + opacity: 0.5, + child: item, + ) + : item; }, orElse: () => const SizedBox.shrink(), ); } } -class _OwnerBadge extends StatelessWidget { - const _OwnerBadge(); +class _RoleBadge extends StatelessWidget { + const _RoleBadge({ + required this.role, + }); + + final GroupMemberRole role; @override Widget build(BuildContext context) { + final roleText = switch (role) { + GroupMemberRoleOwner() => context.i18n.channel_create_admin_type_owner, + GroupMemberRoleAdmin() => context.i18n.channel_create_admin_type_admin, + GroupMemberRoleModerator() => context.i18n.channel_create_admin_type_moderator, + GroupMemberRoleMember() => '', + }; + + if (roleText.isEmpty) { + return const SizedBox.shrink(); + } + return Container( padding: EdgeInsets.symmetric(horizontal: 8.0.s, vertical: 2.0.s), decoration: ShapeDecoration( @@ -78,7 +108,7 @@ class _OwnerBadge extends StatelessWidget { ), ), child: Text( - context.i18n.channel_create_admin_type_owner, + roleText, style: context.theme.appTextThemes.caption3.copyWith( color: context.theme.appColors.primaryAccent, ), @@ -86,3 +116,5 @@ class _OwnerBadge extends StatelessWidget { ); } } + +enum ActionType { remove, select } diff --git a/lib/app/features/chat/views/pages/new_group_modal/pages/create_group_modal.dart b/lib/app/features/chat/views/pages/new_group_modal/pages/create_group_modal.dart index cecbe1c4ab..b2102584a3 100644 --- a/lib/app/features/chat/views/pages/new_group_modal/pages/create_group_modal.dart +++ b/lib/app/features/chat/views/pages/new_group_modal/pages/create_group_modal.dart @@ -129,7 +129,6 @@ class CreateGroupModal extends HookConsumerWidget { iconAsset: Assets.svg.iconChannelType, title: context.i18n.group_create_type, selectedValue: createGroupForm.type.getTitle(context), - onPress: null, ), SizedBox(height: 24.0.s), Row( @@ -168,7 +167,7 @@ class CreateGroupModal extends HookConsumerWidget { return GroupParticipantsListItem( participantMasterkey: participantMasterkey, - onRemove: () { + onActionTap: () { createGroupFormNotifier.toggleMember(participantMasterkey); }, ); diff --git a/lib/app/router/app_routes.gr.dart b/lib/app/router/app_routes.gr.dart index 6d0b4775c4..87aa651383 100644 --- a/lib/app/router/app_routes.gr.dart +++ b/lib/app/router/app_routes.gr.dart @@ -24,7 +24,20 @@ import 'package:ion/app/features/chat/community/channel/views/pages/create_chann import 'package:ion/app/features/chat/community/channel/views/pages/edit_channel_page/edit_channel_page.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/add_group_participants_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/clear_group_messages_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/confirm_admin_role_assign_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/delete_group_user_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/group_admins_modal.dart'; import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/leave_group_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_admin_role_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/manage_owner_role_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/remove_admin_role_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/select_administrator_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/select_owner_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_confirm_modal.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_admin_page/components/transfer_ownership_page.dart'; +import 'package:ion/app/features/chat/e2ee/views/pages/group_edit_page.dart'; import 'package:ion/app/features/chat/recent_chats/views/pages/delete_conversation_modal/delete_conversation_modal.dart'; import 'package:ion/app/features/chat/recent_chats/views/pages/delete_message_modal/delete_message_modal.dart'; import 'package:ion/app/features/chat/views/components/message_items/message_types/money_message/components/address_not_found_chat_modal.dart'; diff --git a/lib/app/router/chat_routes.dart b/lib/app/router/chat_routes.dart index 86221d9a2f..28f3e8c11e 100644 --- a/lib/app/router/chat_routes.dart +++ b/lib/app/router/chat_routes.dart @@ -31,7 +31,27 @@ class ChatRoutes { path: 'add-group-participants/:conversationId', ), TypedGoRoute(path: 'create-group'), + TypedGoRoute(path: 'group-edit/:conversationId'), TypedGoRoute(path: 'leave-group-confirm'), + TypedGoRoute(path: 'delete-group-user-confirm'), + TypedGoRoute(path: 'delete-group-confirm'), + TypedGoRoute(path: 'clear-group-messages-confirm'), + TypedGoRoute(path: 'group-admins/:conversationId'), + TypedGoRoute(path: 'select-administrator/:conversationId'), + TypedGoRoute( + path: 'confirm-admin-role-assign/:conversationId/:participantMasterkey', + ), + TypedGoRoute( + path: 'manage-admin-role/:conversationId/:participantMasterkey', + ), + TypedGoRoute(path: 'remove-admin-role-confirm'), + TypedGoRoute(path: 'manage-owner-role/:conversationId'), + TypedGoRoute(path: 'transfer-ownership/:conversationId'), + TypedGoRoute(path: 'select-owner/:conversationId'), + TypedGoRoute( + path: + 'transfer-ownership-confirm/:conversationId/:newOwnerMasterPubkey/:currentOwnerMasterPubkey', + ), TypedGoRoute(path: 'share-via-message/:eventReference'), TypedGoRoute(path: 'select-payment-type'), TypedGoRoute(path: 'coin-selector-chat'), @@ -94,6 +114,16 @@ class GroupAdminPageRoute extends BaseRouteData with _$GroupAdminPageRoute { final String conversationId; } +class GroupEditPageRoute extends BaseRouteData with _$GroupEditPageRoute { + GroupEditPageRoute({required this.conversationId}) + : super( + child: GroupEditPage(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + class AppTestRoute extends BaseRouteData with _$AppTestRoute { AppTestRoute() : super(child: const AppTestPage()); } @@ -211,6 +241,172 @@ class LeaveGroupConfirmRoute extends BaseRouteData with _$LeaveGroupConfirmRoute ); } +class DeleteGroupUserConfirmRoute extends BaseRouteData with _$DeleteGroupUserConfirmRoute { + DeleteGroupUserConfirmRoute({ + required this.userNickname, + required this.conversationId, + required this.participantMasterPubkey, + }) : super( + child: DeleteGroupUserConfirmModal( + userNickname: userNickname, + conversationId: conversationId, + participantMasterPubkey: participantMasterPubkey, + ), + type: IceRouteType.bottomSheet, + ); + + final String userNickname; + final String conversationId; + final String participantMasterPubkey; +} + +class DeleteGroupConfirmRoute extends BaseRouteData with _$DeleteGroupConfirmRoute { + DeleteGroupConfirmRoute({ + required this.conversationId, + }) : super( + child: DeleteGroupConfirmModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class ClearGroupMessagesConfirmRoute extends BaseRouteData with _$ClearGroupMessagesConfirmRoute { + ClearGroupMessagesConfirmRoute({ + required this.conversationId, + }) : super( + child: ClearGroupMessagesConfirmModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class GroupAdminsModalRoute extends BaseRouteData with _$GroupAdminsModalRoute { + GroupAdminsModalRoute({ + required this.conversationId, + }) : super( + child: GroupAdminsModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class SelectAdministratorModalRoute extends BaseRouteData with _$SelectAdministratorModalRoute { + SelectAdministratorModalRoute({ + required this.conversationId, + }) : super( + child: SelectAdministratorModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class ConfirmAdminRoleAssignModalRoute extends BaseRouteData + with _$ConfirmAdminRoleAssignModalRoute { + ConfirmAdminRoleAssignModalRoute({ + required this.conversationId, + required this.participantMasterkey, + }) : super( + child: ConfirmAdminRoleAssignModal( + conversationId: conversationId, + participantMasterkey: participantMasterkey, + ), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; + final String participantMasterkey; +} + +class ManageAdminRoleModalRoute extends BaseRouteData with _$ManageAdminRoleModalRoute { + ManageAdminRoleModalRoute({ + required this.conversationId, + required this.participantMasterkey, + }) : super( + child: ManageAdminRoleModal( + conversationId: conversationId, + participantMasterkey: participantMasterkey, + ), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; + final String participantMasterkey; +} + +class RemoveAdminRoleConfirmModalRoute extends BaseRouteData + with _$RemoveAdminRoleConfirmModalRoute { + RemoveAdminRoleConfirmModalRoute({ + required this.conversationId, + required this.participantMasterPubkey, + }) : super( + child: RemoveAdminRoleConfirmModal( + conversationId: conversationId, + participantMasterPubkey: participantMasterPubkey, + ), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; + final String participantMasterPubkey; +} + +class ManageOwnerRoleModalRoute extends BaseRouteData with _$ManageOwnerRoleModalRoute { + ManageOwnerRoleModalRoute({ + required this.conversationId, + }) : super( + child: ManageOwnerRoleModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class TransferOwnershipPageRoute extends BaseRouteData with _$TransferOwnershipPageRoute { + TransferOwnershipPageRoute({ + required this.conversationId, + }) : super( + child: TransferOwnershipPage(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class SelectOwnerModalRoute extends BaseRouteData with _$SelectOwnerModalRoute { + SelectOwnerModalRoute({ + required this.conversationId, + }) : super( + child: SelectOwnerModal(conversationId: conversationId), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; +} + +class TransferOwnershipConfirmModalRoute extends BaseRouteData + with _$TransferOwnershipConfirmModalRoute { + TransferOwnershipConfirmModalRoute({ + required this.conversationId, + required this.newOwnerMasterPubkey, + required this.currentOwnerMasterPubkey, + }) : super( + child: TransferOwnershipConfirmModal( + conversationId: conversationId, + newOwnerMasterPubkey: newOwnerMasterPubkey, + currentOwnerMasterPubkey: currentOwnerMasterPubkey, + ), + type: IceRouteType.bottomSheet, + ); + + final String conversationId; + final String newOwnerMasterPubkey; + final String currentOwnerMasterPubkey; +} + class CreateGroupModalRoute extends BaseRouteData with _$CreateGroupModalRoute { CreateGroupModalRoute() : super( diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 1bb22d30bb..79c0258fbd 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -207,15 +207,18 @@ "common_archive_all": "أرشفة الكل", "common_article": "مقالة", "common_camera": "كاميرا", + "common_chat": "دردشة", "common_comment": "رد", "common_comments": "التعليقات", "common_confirm_password": "تأكيد كلمة المرور", "common_congratulations": "تهانينا", "common_copied": "تم النسخ", "common_crop_image": "اقتصاص الصورة", + "common_deleted_account": "حساب محذوف", "common_desc": "الوصف", "common_document": "وثيقة", "common_email_address": "عنوان البريد الإلكتروني", + "common_error": "خطأ", "common_forwarded": "معاد توجيهه", "common_forwarded_from": "معاد توجيهه من", "common_identity_key_name": "اسم مفتاح الهوية", @@ -225,9 +228,11 @@ "common_language": "لغة", "common_links": "روابط", "common_media": "وسائط", + "common_message_edited": "تم التعديل", "common_no_access_permission": "لمنح الوصول، اذهب إلى الإعدادات وقم بتمكين الإعدادات المناسبة", "common_no_camera_permission": "لا يوجد وصول إلى الكاميرا", "common_no_camera_permission_hint": "قم بتمكين الكاميرا من إعدادات جهازك.", + "common_no_internet_connection": "لا يوجد اتصال بالإنترنت", "common_password": "كلمة المرور", "common_paste": "لصق", "common_photo": "صورة", @@ -241,6 +246,7 @@ "common_seconds": "{seconds} ثانية", "common_select_coin": "اختر العملة", "common_select_coin_button_unselected": "اختر العملة", + "common_select_language": "اختر لغة", "common_select_languages": "اختر اللغات", "common_select_network_button_unselected": "اختر الشبكة", "common_select_option": "اختر الخيار", @@ -249,11 +255,14 @@ "common_show_less": "أظهر أقل", "common_show_more": "أظهر المزيد", "common_successfully": "بنجاح", + "common_story": "قصة", "common_title": "العنوان", "common_unarchive": "استعادة الكل من الأرشيف", "common_unarchive_single": "استعادة من الأرشيف", "common_video": "فيديو", + "common_voice": "صوت", "common_voice_message": "رسالة صوتية", + "common_files": "ملفات", "common_you": "أنت", "confirm_delete_description": "ستفقد جميع بياناتك الاجتماعية إلى الأبد. هل أنت متأكد أنك تريد حذف حسابك؟", "confirm_delete_title": "حذف حسابك؟", @@ -450,12 +459,38 @@ "group_admin_tab_members": "الأعضاء", "group_add_members_button": "إضافة أعضاء", "group_add_members_title": "إضافة أعضاء", + "group_admins_modal_title": "الإدارة", + "select_administrator_title": "اختر المدير", + "add_administrator_title": "إضافة مدير", + "selected_user_label": "المستخدم المحدد", + "what_can_admin_do_label": "ماذا يمكن لهذا المدير أن يفعل", + "admin_permission_delete_messages": "حذف الرسائل", + "admin_permission_pin_messages": "تثبيت الرسائل", + "admin_permission_delete_users": "حذف المستخدمين", + "admin_permission_add_new_users": "إضافة مستخدمين جدد", + "admin_permission_add_new_admins": "إضافة مدراء جدد", + "admin_permission_change_group_info": "تغيير معلومات المجموعة", + "admin_permission_clear_group": "مسح المجموعة", "group_edit_button": "تعديل المجموعة", + "group_edit_title": "تعديل مجموعة", "group_clear_messages": "مسح الرسائل", + "group_clear_messages_confirm_title": "مسح جميع الرسائل؟", + "group_clear_messages_confirm_description": "هل أنت متأكد أنك تريد مسح جميع الرسائل من هذه المجموعة؟", "group_delete_group": "حذف المجموعة", + "group_delete_group_confirm_title": "حذف المجموعة؟", + "group_delete_group_confirm_description": "هل أنت متأكد أنك تريد حذف جميع المحتويات والأعضاء والمجموعة نفسها؟", "group_leave": "مغادرة", "group_leave_confirm_title": "مغادرة المجموعة؟", "group_leave_confirm_description": "هل أنت متأكد أنك تريد مغادرة المجموعة؟", + "group_delete_user_confirm_title": "حذف المستخدم؟", + "group_delete_user_confirm_description": "هل أنت متأكد أنك تريد حذف {userNickname} من هذه المجموعة؟", + "@group_delete_user_confirm_description": { + "placeholders": { + "userNickname": { + "type": "String" + } + } + }, "group_no_members": "لا يوجد أعضاء", "group_tab_coming_soon": "{tabName} - قريباً", "@group_tab_coming_soon": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8924e7ada2..5d2a32cc4b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -279,7 +279,9 @@ "common_unarchive": "Alle entarchivieren", "common_unarchive_single": "Entarchivieren", "common_video": "Video", + "common_voice": "Stimme", "common_voice_message": "Sprachnachricht", + "common_files": "Dateien", "common_you": "Du", "confirm_delete_description": "Alle deine sozialen Daten gehen dauerhaft verloren. Bist du sicher, dass du dein Konto löschen möchtest?", "confirm_delete_title": "Dein Konto löschen?", @@ -513,12 +515,38 @@ "group_admin_tab_members": "Mitglieder", "group_add_members_button": "Mitglieder hinzufügen", "group_add_members_title": "Mitglieder hinzufügen", + "group_admins_modal_title": "Verwaltung", + "select_administrator_title": "Administrator auswählen", + "add_administrator_title": "Administrator hinzufügen", + "selected_user_label": "Ausgewählter Benutzer", + "what_can_admin_do_label": "Was kann dieser Administrator tun", + "admin_permission_delete_messages": "Nachrichten löschen", + "admin_permission_pin_messages": "Nachrichten anheften", + "admin_permission_delete_users": "Benutzer löschen", + "admin_permission_add_new_users": "Neue Benutzer hinzufügen", + "admin_permission_add_new_admins": "Neue Administratoren hinzufügen", + "admin_permission_change_group_info": "Gruppeninformationen ändern", + "admin_permission_clear_group": "Gruppe löschen", "group_edit_button": "Gruppe bearbeiten", + "group_edit_title": "Gruppe bearbeiten", "group_clear_messages": "Nachrichten löschen", + "group_clear_messages_confirm_title": "Alle Nachrichten löschen?", + "group_clear_messages_confirm_description": "Möchtest du wirklich alle Nachrichten aus dieser Gruppe löschen?", "group_delete_group": "Gruppe löschen", + "group_delete_group_confirm_title": "Gruppe löschen?", + "group_delete_group_confirm_description": "Möchtest du wirklich alle Inhalte, Mitglieder und die Gruppe selbst löschen?", "group_leave": "Verlassen", "group_leave_confirm_title": "Gruppe verlassen?", "group_leave_confirm_description": "Möchtest du die Gruppe wirklich verlassen?", + "group_delete_user_confirm_title": "Benutzer löschen?", + "group_delete_user_confirm_description": "Möchtest du {userNickname} wirklich aus dieser Gruppe entfernen?", + "@group_delete_user_confirm_description": { + "placeholders": { + "userNickname": { + "type": "String" + } + } + }, "group_no_members": "Keine Mitglieder", "group_tab_coming_soon": "{tabName} Tab - Kommt bald", "@group_tab_coming_soon": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c132a496f4..ec0cee68e7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -279,7 +279,9 @@ "common_unarchive": "Unarchive all", "common_unarchive_single": "Unarchive", "common_video": "Video", + "common_voice": "Voice", "common_voice_message": "Voice message", + "common_files": "Files", "common_you": "You", "confirm_delete_description": "All your social data will be lost forever. Are you sure you want to delete your account?", "confirm_delete_title": "Delete your account?", @@ -514,12 +516,44 @@ "group_admin_tab_members": "Members", "group_add_members_button": "Add members", "group_add_members_title": "Add members", + "group_admins_modal_title": "Administration", + "select_administrator_title": "Select administrator", + "add_administrator_title": "Add administrator", + "selected_user_label": "Selected user", + "what_can_admin_do_label": "What can this admin do", + "what_can_owner_do_label": "What can this owner do", + "transfer_ownership": "Transfer ownership", + "select_new_owner": "Select new owner", + "transfer_group_ownership_title": "Transfer group ownership", + "transfer_group_ownership_desc": "Are you sure you want to transfer the ownership of this group to another user?", + "change_owner": "Change owner", + "admin_permission_delete_messages": "Delete messages", + "admin_permission_pin_messages": "Pin messages", + "admin_permission_delete_users": "Delete Users", + "admin_permission_add_new_users": "Add new users", + "admin_permission_add_new_admins": "Add new Admins", + "admin_permission_change_group_info": "Change group info", + "admin_permission_clear_group": "Clear group", "group_edit_button": "Edit Group", + "group_edit_title": "Edit a group", "group_clear_messages": "Clear messages", + "group_clear_messages_confirm_title": "Clear all messages?", + "group_clear_messages_confirm_description": "Are you sure you want to clear all messages from this group?", "group_delete_group": "Delete group", + "group_delete_group_confirm_title": "Delete group?", + "group_delete_group_confirm_description": "Are you sure you want to delete all content, members, and the group itself?", "group_leave": "Leave", "group_leave_confirm_title": "Leave the Group?", "group_leave_confirm_description": "Are you sure you want to leave the group?", + "group_delete_user_confirm_title": "Delete user?", + "group_delete_user_confirm_description": "Are you sure you want to delete {userNickname} from this group?", + "@group_delete_user_confirm_description": { + "placeholders": { + "userNickname": { + "type": "String" + } + } + }, "group_no_members": "No members", "group_tab_coming_soon": "{tabName} Tab - Coming soon", "@group_tab_coming_soon": {