diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 5a3c614..16565cd 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -372,5 +372,10 @@ "makeAdmin": "Zum Admin machen", "removeAdmin": "Als Admin entfernen", "removeFromGroup": "Aus Gruppe entfernen", - "admin": "Admin" + "admin": "Admin", + "revokeAdminRightsTitle": "Adminrechte von {username} entfernen?", + "revokeAdminRightsOkBtn": "Als Admin entfernen", + "makeAdminRightsTitle": "{username} zum Admin machen?", + "makeAdminRightsBody": "{username} wird diese Gruppe und ihre Mitglieder bearbeiten können.", + "makeAdminRightsOkBtn": "Zum Admin machen" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index eb1cc6c..e0e3870 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -528,5 +528,10 @@ "makeAdmin": "Make admin", "removeAdmin": "Remove as admin", "removeFromGroup": "Remove from group", - "admin": "Admin" + "admin": "Admin", + "revokeAdminRightsTitle": "Revoke {username}'s admin rights?", + "revokeAdminRightsOkBtn": "Remove as admin", + "makeAdminRightsTitle": "Make {username} an admin?", + "makeAdminRightsBody": "{username} will be able to edit this group and its members.", + "makeAdminRightsOkBtn": "Make admin" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index cda4669..7bac4bb 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2275,6 +2275,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Admin'** String get admin; + + /// No description provided for @revokeAdminRightsTitle. + /// + /// In en, this message translates to: + /// **'Revoke {username}\'s admin rights?'** + String revokeAdminRightsTitle(Object username); + + /// No description provided for @revokeAdminRightsOkBtn. + /// + /// In en, this message translates to: + /// **'Remove as admin'** + String get revokeAdminRightsOkBtn; + + /// No description provided for @makeAdminRightsTitle. + /// + /// In en, this message translates to: + /// **'Make {username} an admin?'** + String makeAdminRightsTitle(Object username); + + /// No description provided for @makeAdminRightsBody. + /// + /// In en, this message translates to: + /// **'{username} will be able to edit this group and its members.'** + String makeAdminRightsBody(Object username); + + /// No description provided for @makeAdminRightsOkBtn. + /// + /// In en, this message translates to: + /// **'Make admin'** + String get makeAdminRightsOkBtn; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index f06b19a..cb7374a 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1204,4 +1204,25 @@ class AppLocalizationsDe extends AppLocalizations { @override String get admin => 'Admin'; + + @override + String revokeAdminRightsTitle(Object username) { + return 'Adminrechte von $username entfernen?'; + } + + @override + String get revokeAdminRightsOkBtn => 'Als Admin entfernen'; + + @override + String makeAdminRightsTitle(Object username) { + return '$username zum Admin machen?'; + } + + @override + String makeAdminRightsBody(Object username) { + return '$username wird diese Gruppe und ihre Mitglieder bearbeiten können.'; + } + + @override + String get makeAdminRightsOkBtn => 'Zum Admin machen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 7ab19e8..99ccfc3 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1197,4 +1197,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get admin => 'Admin'; + + @override + String revokeAdminRightsTitle(Object username) { + return 'Revoke $username\'s admin rights?'; + } + + @override + String get revokeAdminRightsOkBtn => 'Remove as admin'; + + @override + String makeAdminRightsTitle(Object username) { + return 'Make $username an admin?'; + } + + @override + String makeAdminRightsBody(Object username) { + return '$username will be able to edit this group and its members.'; + } + + @override + String get makeAdminRightsOkBtn => 'Make admin'; } diff --git a/lib/src/services/group.services.dart b/lib/src/services/group.services.dart index 5c76bbc..9f7d25a 100644 --- a/lib/src/services/group.services.dart +++ b/lib/src/services/group.services.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; @@ -317,7 +318,12 @@ Future addNewHiddenContact(int contactId) async { return true; } -Future updateGroupState(Group group, EncryptedGroupState state) async { +Future updateGroupState( + Group group, + EncryptedGroupState state, { + Uint8List? addAdmin, + Uint8List? removeAdmin, +}) async { final chacha20 = FlutterChacha20.poly1305Aead(); final encryptionNonce = chacha20.newNonce(); @@ -358,6 +364,8 @@ Future updateGroupState(Group group, EncryptedGroupState state) async { encryptedGroupState: encryptedGroupState.writeToBuffer(), publicKey: keyPair.getPublicKey().serialize(), nonce: responseNonce.bodyBytes, + addAdmin: addAdmin, + removeAdmin: removeAdmin, ); final random = getRandomUint8List(32); @@ -391,6 +399,79 @@ Future updateGroupState(Group group, EncryptedGroupState state) async { return (await fetchGroupState(group)) != null; } +Future manageAdminState( + Group group, + GroupMember member, + int contactId, + bool remove, +) async { + // ensure the latest state is used + final currentState = await fetchGroupState(group); + if (currentState == null) return false; + final (versionId, state) = currentState; + + final userId = Int64(contactId); + + Uint8List? addAdmin; + Uint8List? removeAdmin; + + if (remove) { + if (state.adminIds.contains(userId)) { + state.adminIds.remove(userId); + removeAdmin = member.groupPublicKey; + } else { + Log.info('User was already removed as admin.'); + return true; + } + } else { + if (!state.adminIds.contains(userId)) { + state.adminIds.add(userId); + addAdmin = member.groupPublicKey; + } else { + Log.info('User is already admin.'); + return true; + } + } + + if (addAdmin == null && removeAdmin == null) { + Log.info('User does not have a group public key.'); + return false; + } + + // send new state to the server + if (!await updateGroupState( + group, + state, + addAdmin: addAdmin, + removeAdmin: removeAdmin, + )) { + return false; + } + + final groupActionType = + remove ? GroupActionType.demoteToMember : GroupActionType.promoteToAdmin; + + await sendCipherTextToGroup( + group.groupId, + EncryptedContent( + groupUpdate: EncryptedContent_GroupUpdate( + groupActionType: groupActionType.name, + affectedContactId: Int64(contactId), + ), + ), + ); + + await twonlyDB.groupsDao.insertGroupAction( + GroupHistoriesCompanion( + groupId: Value(group.groupId), + type: Value(groupActionType), + affectedContactId: Value(contactId), + ), + ); + + return true; +} + Future updateGroupeName(Group group, String groupName) async { // ensure the latest state is used final currentState = await fetchGroupState(group); diff --git a/lib/src/views/chats/chat_messages_components/chat_group_action.dart b/lib/src/views/chats/chat_messages_components/chat_group_action.dart index 0f7fa9d..f748f7e 100644 --- a/lib/src/views/chats/chat_messages_components/chat_group_action.dart +++ b/lib/src/views/chats/chat_messages_components/chat_group_action.dart @@ -28,13 +28,15 @@ class _ChatGroupActionState extends State { } Future initAsync() async { - if (widget.action.contactId == null) return; - contact = - await twonlyDB.contactsDao.getContactById(widget.action.contactId!); + if (widget.action.contactId != null) { + contact = + await twonlyDB.contactsDao.getContactById(widget.action.contactId!); + } - if (widget.action.affectedContactId == null) return; - affectedContact = await twonlyDB.contactsDao - .getContactById(widget.action.affectedContactId!); + if (widget.action.affectedContactId != null) { + affectedContact = await twonlyDB.contactsDao + .getContactById(widget.action.affectedContactId!); + } if (mounted) setState(() {}); } @@ -43,14 +45,30 @@ class _ChatGroupActionState extends State { Widget build(BuildContext context) { var text = ''; - if (widget.action.type == GroupActionType.updatedGroupName) { - if (contact == null) { - text = - 'You have changed the group name to "${widget.action.newGroupName}".'; - } else { - text = - '${getContactDisplayName(contact!)} has changed the group name to "${widget.action.newGroupName}".'; - } + final affected = (affectedContact == null) + ? 'you' + : "${getContactDisplayName(affectedContact!)}'s"; + final affectedR = (affectedContact == null) ? 'your' : affected; + final maker = (contact == null) ? '' : getContactDisplayName(contact!); + + switch (widget.action.type) { + case GroupActionType.updatedGroupName: + text = (contact == null) + ? 'You have changed the group name to "${widget.action.newGroupName}".' + : '$maker has changed the group name to "${widget.action.newGroupName}".'; + case GroupActionType.createdGroup: + case GroupActionType.removedMember: + case GroupActionType.addMember: + case GroupActionType.leftGroup: + break; + case GroupActionType.promoteToAdmin: + text = (contact == null) + ? 'You made $affected an admin.' + : '$maker made $affected an admin.'; + case GroupActionType.demoteToMember: + text = (contact == null) + ? 'You revoked $affected admin rights.' + : '$maker revoked $affectedR admin rights.'; } if (text == '') return Container(); diff --git a/lib/src/views/groups/group.view.dart b/lib/src/views/groups/group.view.dart index 57c47ec..87bb0b4 100644 --- a/lib/src/views/groups/group.view.dart +++ b/lib/src/views/groups/group.view.dart @@ -75,12 +75,7 @@ class _GroupViewState extends State { newGroupName != group.groupName) { if (!await updateGroupeName(group, newGroupName)) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Network issue. Try again later.'), - duration: Duration(seconds: 3), - ), - ); + showNetworkIssue(context); } } } @@ -143,6 +138,7 @@ class _GroupViewState extends State { BetterListTile( padding: const EdgeInsets.only(left: 13), leading: AvatarIcon( + key: GlobalKey(), userData: gUser, fontSize: 16, ), @@ -159,12 +155,13 @@ class _GroupViewState extends State { ), ...members.map((member) { return GroupMemberContextMenu( - group: widget.group, + group: group, contact: member.$1, member: member.$2, child: BetterListTile( padding: const EdgeInsets.only(left: 13), leading: AvatarIcon( + key: GlobalKey(), contact: member.$1, fontSize: 16, ), @@ -232,3 +229,12 @@ Future showGroupNameChangeDialog( }, ); } + +void showNetworkIssue(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Network issue. Try again later.'), + duration: Duration(seconds: 3), + ), + ); +} diff --git a/lib/src/views/groups/group_member.context.dart b/lib/src/views/groups/group_member.context.dart index 4807e2f..1b39a69 100644 --- a/lib/src/views/groups/group_member.context.dart +++ b/lib/src/views/groups/group_member.context.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/group.services.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart'; +import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/context_menu.component.dart'; +import 'package:twonly/src/views/groups/group.view.dart'; class GroupMemberContextMenu extends StatelessWidget { const GroupMemberContextMenu({ @@ -52,19 +56,60 @@ class GroupMemberContextMenu extends StatelessWidget { }, icon: FontAwesomeIcons.userPlus, ), - if (group.isGroupAdmin && member.memberState == MemberState.normal) + if (member.groupPublicKey != null && + group.isGroupAdmin && + member.memberState == MemberState.normal) ContextMenuItem( title: context.lang.makeAdmin, onTap: () async { - // onResponseTriggered(); + final ok = await showAlertDialog( + context, + context.lang + .makeAdminRightsTitle(getContactDisplayName(contact)), + context.lang + .makeAdminRightsBody(getContactDisplayName(contact)), + customOk: context.lang.makeAdminRightsOkBtn, + ); + if (ok) { + if (!await manageAdminState( + group, + member, + contact.userId, + false, + )) { + if (context.mounted) { + showNetworkIssue(context); + } + } + } }, icon: FontAwesomeIcons.key, ), - if (group.isGroupAdmin && member.memberState == MemberState.admin) + if (member.groupPublicKey != null && + group.isGroupAdmin && + member.memberState == MemberState.admin) ContextMenuItem( title: context.lang.removeAdmin, onTap: () async { - // onResponseTriggered(); + final ok = await showAlertDialog( + context, + context.lang + .revokeAdminRightsTitle(getContactDisplayName(contact)), + '', + customOk: context.lang.revokeAdminRightsOkBtn, + ); + if (ok) { + if (!await manageAdminState( + group, + member, + contact.userId, + true, + )) { + if (context.mounted) { + showNetworkIssue(context); + } + } + } }, icon: FontAwesomeIcons.key, ),