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": {