diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 37741f3464..8407896bda 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -733,12 +733,18 @@ class GroupMember ( GroupMemberStatus.MemCreator -> true } - fun canBeRemoved(membership: GroupMember): Boolean { - val userRole = membership.memberRole + fun canBeRemoved(groupInfo: GroupInfo): Boolean { + val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent + && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberCurrent } + fun canChangeRoleTo(groupInfo: GroupInfo): List? = + if (!canBeRemoved(groupInfo)) null + else groupInfo.membership.memberRole.let { userRole -> + GroupMemberRole.values().filter { it <= userRole } + } + val memberIncognito = memberProfile.profileId != memberContactProfileId companion object { @@ -1025,6 +1031,8 @@ data class ChatItem ( is RcvGroupEvent.GroupDeleted -> false is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberLeft -> false + is RcvGroupEvent.MemberRole -> true + is RcvGroupEvent.UserRole -> false is RcvGroupEvent.MemberDeleted -> false } is CIContent.SndGroupEventContent -> true @@ -1527,6 +1535,8 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent() @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent() + @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent() + @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): RcvGroupEvent() @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent() @Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent() @@ -1536,6 +1546,8 @@ sealed class RcvGroupEvent() { is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected) is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left) + is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text) + is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text) is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName) is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted) is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted) @@ -1545,11 +1557,15 @@ sealed class RcvGroupEvent() { @Serializable sealed class SndGroupEvent() { + @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): SndGroupEvent() + @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): SndGroupEvent() @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent() @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent() val text: String get() = when (this) { + is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text) + is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text) is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(R.string.snd_group_event_user_left) is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e18b49644a..0131a4e5a3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -785,12 +785,27 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } - suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? { - val r = sendCmd(CC.ApiRemoveMember(groupId, memberId)) - if (r is CR.UserDeletedMember) return r.member - Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}") - return null - } + suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? = + when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) { + is CR.UserDeletedMember -> r.member + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiRemoveMember", generalGetString(R.string.error_removing_member), r) + } + null + } + } + + suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = + when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) { + is CR.MemberRoleUser -> r.member + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiMemberRole", generalGetString(R.string.error_changing_role), r) + } + throw Exception("failed to change member role: ${r.responseType} ${r.details}") + } + } suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { val r = sendCmd(CC.ApiLeaveGroup(groupId)) @@ -1342,7 +1357,7 @@ sealed class CC { class NewGroup(val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() - // class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() + class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() class ApiRemoveMember(val groupId: Long, val memberId: Long): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() @@ -1400,6 +1415,7 @@ sealed class CC { is NewGroup -> "/_group ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" + is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" is ApiRemoveMember -> "/_remove #$groupId $memberId" is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" @@ -1458,6 +1474,7 @@ sealed class CC { is NewGroup -> "newGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" + is ApiMemberRole -> "apiMemberRole" is ApiRemoveMember -> "apiRemoveMember" is ApiLeaveGroup -> "apiLeaveGroup" is ApiListMembers -> "apiListMembers" @@ -1710,6 +1727,8 @@ sealed class CR { @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberRole") class MemberRole(val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() @Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -1799,6 +1818,8 @@ sealed class CR { is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" + is MemberRole -> "memberRole" + is MemberRoleUser -> "memberRoleUser" is DeletedMemberUser -> "deletedMemberUser" is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" @@ -1887,6 +1908,8 @@ sealed class CR { is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole" is GroupDeletedUser -> json.encodeToString(groupInfo) is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member" + is MemberRole -> "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" + is MemberRoleUser -> "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member" is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember" is LeftMember -> "groupInfo: $groupInfo\nmember: $member" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 205ad79492..b86389bffd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -138,7 +138,7 @@ fun AddGroupMembersLayout( } @Composable -fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState) { +private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index 2034bbecc4..255608eae1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -2,6 +2,7 @@ package chat.simplex.app.views.chat.group import InfoRow import SectionDivider +import SectionItemView import SectionSpacer import SectionView import androidx.activity.compose.BackHandler @@ -10,7 +11,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -40,10 +41,12 @@ fun GroupMemberInfoView( val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { + val newRole = remember { mutableStateOf(member.memberRole) } GroupMemberInfoLayout( groupInfo, member, connStats, + newRole, developerTools, openDirectChat = { withApi { @@ -61,7 +64,24 @@ fun GroupMemberInfoView( closeAll() } }, - removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) } + removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, + onRoleSelected = { + if (it == newRole.value) return@GroupMemberInfoLayout + val prevValue = newRole.value + newRole.value = it + updateMemberRoleDialog(it, member, onDismiss = { + newRole.value = prevValue + }) { + withApi { + kotlin.runCatching { + val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it) + chatModel.upsertGroupMember(groupInfo, mem) + }.onFailure { + newRole.value = prevValue + } + } + } + } ) } } @@ -88,9 +108,11 @@ fun GroupMemberInfoLayout( groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, + newRole: MutableState, developerTools: Boolean, openDirectChat: () -> Unit, removeMember: () -> Unit, + onRoleSelected: (GroupMemberRole) -> Unit, ) { Column( Modifier @@ -113,6 +135,15 @@ fun GroupMemberInfoLayout( SectionView(title = stringResource(R.string.member_info_section_title_member)) { InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName) + SectionDivider() + val roles = remember { member.canChangeRoleTo(groupInfo) } + if (roles != null) { + SectionItemView { + RoleSelectionRow(roles, newRole, onRoleSelected) + } + } else { + InfoRow(stringResource(R.string.role_in_group), member.memberRole.text) + } val conn = member.activeConn if (conn != null) { SectionDivider() @@ -143,7 +174,7 @@ fun GroupMemberInfoLayout( } } - if (member.canBeRemoved(groupInfo.membership)) { + if (member.canBeRemoved(groupInfo)) { SectionView { RemoveMemberButton(removeMember) } @@ -207,6 +238,48 @@ fun OpenChatButton(onClick: () -> Unit) { ) } +@Composable +private fun RoleSelectionRow( + roles: List, + selectedRole: MutableState, + onSelected: (GroupMemberRole) -> Unit +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val values = remember { roles.map { it to it.text } } + ExposedDropDownSettingRow( + generalGetString(R.string.change_role), + values, + selectedRole, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) + } +} + +private fun updateMemberRoleDialog( + newRole: GroupMemberRole, + member: GroupMember, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.change_member_role_question), + text = if (member.memberCurrent) + String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text) + else + String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text), + confirmText = generalGetString(R.string.change_verb), + onDismiss = onDismiss, + onConfirm = onConfirm, + onDismissRequest = onDismiss + ) +} + @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -215,9 +288,11 @@ fun PreviewGroupMemberInfoLayout() { groupInfo = GroupInfo.sampleData, member = GroupMember.sampleData, connStats = null, + newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, openDirectChat = {}, - removeMember = {} + removeMember = {}, + onRoleSelected = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index 9ec7d974f1..171cbe5552 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -92,6 +92,13 @@ class AlertManager { } } + fun showAlertMsg( + title: Int, + text: Int? = null, + confirmText: Int = R.string.ok, + onConfirm: (() -> Unit)? = null + ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm) + @Composable fun showInView() { if (presentAlert.value) alertView.value?.invoke() diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index d0894bfe7b..9f051d3a6b 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -770,6 +770,16 @@ Das Mitglied wird aus der Gruppe entfernt - dies kann nicht rückgängig gemacht werden! Entfernen MITGLIED + Role + Change role + Change + Change group role? + The role will be changed to \"%s\". Everyone in the group will be notified. + The role will be changed to \"%s\". The member will receive a new invitation. + Error removing member + Error changing role + member %s role: %s + your role: %s Gruppe Verbindung direkt diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index ce3a257a97..7d0c921bd0 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -770,6 +770,16 @@ Член группы будет удален - это действие нельзя отменить! Удалить ЧЛЕН ГРУППЫ + Роль + Поменять роль + Поменять + Поменять роль в группе? + Роль будет изменена на \"%s\". Все в группе получат сообщение. + Роль будет изменена на \"%s\". Будет отправлено новое приглашение. + Ошибка при удалении члена группы + Ошибка при изменении роли + роль %s: %s + ваша роль: %s Группа Соединение прямое diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 706e86e0e6..0b4f023906 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -770,6 +770,16 @@ Member will be removed from group - this cannot be undone! Remove MEMBER + Role + Change role + Change + Change group role? + The role will be changed to \"%s\". Everyone in the group will be notified. + The role will be changed to \"%s\". The member will receive a new invitation. + Error removing member + Error changing role + member %s role: %s + your role: %s Group Connection direct