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 990bd1a0d..1443c4286 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 @@ -486,6 +486,24 @@ data class Chat ( val chatItems: List, val chatStats: ChatStats = ChatStats(), ) { + val userCanSend: Boolean + get() = when (chatInfo) { + is ChatInfo.Direct -> true + is ChatInfo.Group -> { + val m = chatInfo.groupInfo.membership + m.memberActive && m.memberRole >= GroupMemberRole.Member + } + else -> false + } + + val userIsObserver: Boolean get() = when(chatInfo) { + is ChatInfo.Group -> { + val m = chatInfo.groupInfo.membership + m.memberActive && m.memberRole == GroupMemberRole.Observer + } + else -> false + } + val id: String get() = chatInfo.id @Serializable @@ -932,7 +950,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole } + GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Observer } } val memberIncognito = memberProfile.profileId != memberContactProfileId @@ -963,11 +981,13 @@ class GroupMemberRef( @Serializable enum class GroupMemberRole(val memberRole: String) { - @SerialName("member") Member("member"), // order matters in comparisons + @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("member") Member("member"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); val text: String get() = when (this) { + Observer -> generalGetString(R.string.group_member_role_observer) Member -> generalGetString(R.string.group_member_role_member) Admin -> generalGetString(R.string.group_member_role_admin) Owner -> generalGetString(R.string.group_member_role_owner) 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 7b836452f..5e6868496 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 @@ -549,7 +549,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun testSMPServer(smpServer: String): SMPTestFailure? { val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") } - val r = sendCmd(CC.TestSMPServer(userId, smpServer)) + val r = sendCmd(CC.APITestSMPServer(userId, smpServer)) return when (r) { is CR.SmpTestResult -> r.smpTestFailure else -> { @@ -1060,9 +1060,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } - suspend fun apiCreateGroupLink(groupId: Long): String? { - return when (val r = sendCmd(CC.APICreateGroupLink(groupId))) { - is CR.GroupLinkCreated -> r.connReqContact + suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) { + is CR.GroupLinkCreated -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(R.string.error_creating_link_for_group), r) @@ -1072,6 +1072,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) { + is CR.GroupLink -> r.connReqContact to r.memberRole + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiGroupLinkMemberRole", generalGetString(R.string.error_updating_link_for_group), r) + } + null + } + } + } + suspend fun apiDeleteGroupLink(groupId: Long): Boolean { return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) { is CR.GroupLinkDeleted -> true @@ -1084,9 +1096,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } - suspend fun apiGetGroupLink(groupId: Long): String? { + suspend fun apiGetGroupLink(groupId: Long): Pair? { return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) { - is CR.GroupLink -> r.connReqContact + is CR.GroupLink -> r.connReqContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") null @@ -1343,6 +1355,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (active(r.user)) { chatModel.updateGroup(r.toGroup) } + is CR.MemberRole -> + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.RcvFileStart -> chatItemSimpleUpdate(r.user, r.chatItem) is CR.RcvFileComplete -> @@ -1752,12 +1768,13 @@ sealed class CC { class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() - class APICreateGroupLink(val groupId: Long): CC() + class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC() + class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() class APIGetUserSMPServers(val userId: Long): CC() class APISetUserSMPServers(val userId: Long, val smpServers: List): CC() - class TestSMPServer(val userId: Long, val smpServer: String): CC() + class APITestSMPServer(val userId: Long, val smpServer: String): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -1827,12 +1844,13 @@ sealed class CC { is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" - is APICreateGroupLink -> "/_create link #$groupId" + is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}" + is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" is APIGetUserSMPServers -> "/_smp $userId" is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}" - is TestSMPServer -> "/smp test $userId $smpServer" + is APITestSMPServer -> "/_smp test $userId $smpServer" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" @@ -1904,11 +1922,12 @@ sealed class CC { is ApiListMembers -> "apiListMembers" is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" is APICreateGroupLink -> "apiCreateGroupLink" + is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" is APIGetUserSMPServers -> "apiGetUserSMPServers" is APISetUserSMPServers -> "apiSetUserSMPServers" - is TestSMPServer -> "testSMPServer" + is APITestSMPServer -> "testSMPServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "/apiSetNetworkConfig" @@ -2925,8 +2944,8 @@ sealed class CR { @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR() - @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() - @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() + @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR() // receiving file events @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR() @@ -3129,8 +3148,8 @@ sealed class CR { is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) - is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") - is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") + is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") + is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt index 873d3f313..7590ecdcf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -17,6 +17,7 @@ enum class DefaultTheme { val DEFAULT_PADDING = 16.dp val DEFAULT_SPACE_AFTER_ICON = 4.dp val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2 +val DEFAULT_BOTTOM_PADDING = 48.dp val DarkColorPalette = darkColors( primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 424bb978a..8641b4a08 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -83,6 +83,8 @@ fun TerminalLayout( liveMessageAlertShown = SharedPreference(get = { false }, set = {}), needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, + userIsObserver = false, + userCanSend = true, allowVoiceToContact = {}, sendMessage = sendCommand, sendLiveMessage = null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 2e3f1a7e1..1952cd80c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -37,6 +37,7 @@ import androidx.webkit.WebViewClientCompat import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.DEFAULT_BOTTOM_PADDING import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.helpers.withApi @@ -240,7 +241,7 @@ private fun ActiveCallOverlayLayout( CallInfoView(call, alignment = Alignment.CenterHorizontally) } Spacer(Modifier.fillMaxHeight().weight(1f)) - Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) { + Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { IconButton(onClick = dismiss) { Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index c9cb6d6f4..07b3792a2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -152,9 +152,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { } } else if (chat.chatInfo is ChatInfo.Group) { setGroupMembers(chat.chatInfo.groupInfo, chatModel) - var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + var groupLink = link?.first + var groupLinkMemberRole = link?.second ModalManager.shared.showModalCloseable(true) { close -> - GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close) + GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, { + groupLink = it.first; + groupLinkMemberRole = it.second + }, close) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 8e5a135e4..7d0ebe38c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -17,6 +17,7 @@ import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* @@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import chat.simplex.app.* @@ -645,6 +647,9 @@ fun ComposeView( chatModel.sharedContent.value = null } + val userCanSend = rememberUpdatedState(chat.userCanSend) + val userIsObserver = rememberUpdatedState(chat.userIsObserver) + Column { contextItemView() when { @@ -656,11 +661,11 @@ fun ComposeView( modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom, ) { - IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) { + IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) { Icon( Icons.Filled.AttachFile, contentDescription = stringResource(R.string.attach), - tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight, + tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else HighOrLowlight, modifier = Modifier .size(28.dp) .clip(CircleShape) @@ -698,6 +703,13 @@ fun ComposeView( } } + LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) { + if (!chat.userCanSend) { + clearCurrentDraft() + clearState() + } + } + val activity = LocalContext.current as Activity DisposableEffect(Unit) { val orientation = activity.resources.configuration.orientation @@ -733,6 +745,8 @@ fun ComposeView( needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, sendMessage = { sendMessage() resetLinkPreview() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index d7d555ad4..d213296a3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -60,6 +60,8 @@ fun SendMsgView( liveMessageAlertShown: SharedPreference, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, + userIsObserver: Boolean, + userCanSend: Boolean, allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, sendLiveMessage: (suspend () -> Unit)? = null, @@ -74,10 +76,18 @@ fun SendMsgView( val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - NativeKeyboard(composeState, textStyle, showDeleteTextButton, onMessageChange) + NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) // Disable clicks on text field - if (cs.preview is ComposePreview.VoicePreview) { - Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { })) + if (cs.preview is ComposePreview.VoicePreview || !userCanSend) { + Box(Modifier + .matchParentSize() + .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.observer_cant_send_message_title), + text = generalGetString(R.string.observer_cant_send_message_desc) + ) + }) + ) } if (showDeleteTextButton.value) { DeleteTextButton(composeState) @@ -99,11 +109,11 @@ fun SendMsgView( Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } when { - needToAllowVoiceToContact || !allowedVoiceByPrefs -> { - DisallowedVoiceButton { + needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> { + DisallowedVoiceButton(userCanSend) { if (needToAllowVoiceToContact) { showNeedToAllowVoiceAlert(allowVoiceToContact) - } else { + } else if (!allowedVoiceByPrefs) { showDisabledVoiceAlert(isDirectChat) } } @@ -118,7 +128,7 @@ fun SendMsgView( && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) && cs.contextItem is ComposeContextItem.NoContextItem) { Spacer(Modifier.width(10.dp)) - StartLiveMessageButton { + StartLiveMessageButton(userCanSend) { if (composeState.value.preview is ComposePreview.NoPreview) { startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) } @@ -173,6 +183,7 @@ private fun NativeKeyboard( composeState: MutableState, textStyle: MutableState, showDeleteTextButton: MutableState, + userIsObserver: Boolean, onMessageChange: (String) -> Unit ) { val cs = composeState.value @@ -253,15 +264,22 @@ private fun NativeKeyboard( showDeleteTextButton.value = it.lineCount >= 4 } if (composeState.value.preview is ComposePreview.VoicePreview) { - Text( - generalGetString(R.string.voice_message_send_text), - Modifier.padding(padding), - color = HighOrLowlight, - style = textStyle.value.copy(fontStyle = FontStyle.Italic) - ) + ComposeOverlay(R.string.voice_message_send_text, textStyle, padding) + } else if (userIsObserver) { + ComposeOverlay(R.string.you_are_observer, textStyle, padding) } } +@Composable +private fun ComposeOverlay(textId: Int, textStyle: MutableState, padding: PaddingValues) { + Text( + generalGetString(textId), + Modifier.padding(padding), + color = HighOrLowlight, + style = textStyle.value.copy(fontStyle = FontStyle.Italic) + ) +} + @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( @@ -322,8 +340,8 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex } @Composable -private fun DisallowedVoiceButton(onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp)) { +private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp), enabled = enabled) { Icon( Icons.Outlined.KeyboardVoice, stringResource(R.string.icon_descr_record_voice_message), @@ -454,13 +472,13 @@ private fun SendMsgButton( } @Composable -private fun StartLiveMessageButton(onClick: () -> Unit) { +private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier.requiredSize(36.dp) .clickable( onClick = onClick, - enabled = true, + enabled = enabled, role = Role.Button, interactionSource = interactionSource, indication = rememberRipple(bounded = false, radius = 24.dp) @@ -470,7 +488,7 @@ private fun StartLiveMessageButton(onClick: () -> Unit) { Icon( Icons.Filled.Bolt, stringResource(R.string.icon_descr_send_message), - tint = MaterialTheme.colors.primary, + tint = if (enabled) MaterialTheme.colors.primary else HighOrLowlight, modifier = Modifier .size(36.dp) .padding(4.dp) @@ -571,6 +589,8 @@ fun PreviewSendMsgView() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, onMessageChange = { _ -> }, @@ -599,6 +619,8 @@ fun PreviewSendMsgViewEditing() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, onMessageChange = { _ -> }, @@ -627,6 +649,8 @@ fun PreviewSendMsgViewInProgress() { liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, allowVoiceToContact = {}, sendMessage = {}, onMessageChange = { _ -> }, 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 1ff21e665..753b2746b 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 @@ -166,7 +166,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Unit, close: () -> Unit) { +fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit, close: () -> Unit) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() @@ -95,9 +95,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, manageGroupLink = { - withApi { - ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) } - } + ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } } ) } @@ -300,6 +298,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) { verticalAlignment = Alignment.CenterVertically ) { Row( + Modifier.weight(1f).padding(end = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt index 8dd10d4c8..1b20eb7a8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt @@ -1,6 +1,9 @@ package chat.simplex.app.views.chat.group +import SectionItemView import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -15,22 +18,26 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.GroupInfo +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode @Composable -fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) { +fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit) { var groupLink by rememberSaveable { mutableStateOf(connReqContact) } + val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } val cxt = LocalContext.current fun createLink() { creatingLink = true withApi { - groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) - onGroupLinkUpdated(groupLink) + val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) + if (link != null) { + groupLink = link.first + groupLinkMemberRole.value = link.second + onGroupLinkUpdated(groupLink to groupLinkMemberRole.value) + } creatingLink = false } } @@ -41,9 +48,24 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St } GroupLinkLayout( groupLink = groupLink, + groupInfo, + groupLinkMemberRole, creatingLink, createLink = ::createLink, share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) }, + updateLink = { + val role = groupLinkMemberRole.value + if (role != null) { + withBGApi { + val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role) + if (link != null) { + groupLink = link.first + groupLinkMemberRole.value = link.second + onGroupLinkUpdated(groupLink to groupLinkMemberRole.value) + } + } + } + }, deleteLink = { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.delete_link_question), @@ -54,7 +76,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) if (r) { groupLink = null - onGroupLinkUpdated(null) + onGroupLinkUpdated(null to null) } } } @@ -69,13 +91,18 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St @Composable fun GroupLinkLayout( groupLink: String?, + groupInfo: GroupInfo, + groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, share: () -> Unit, + updateLink: () -> Unit, deleteLink: () -> Unit ) { Column( - Modifier.padding(horizontal = DEFAULT_PADDING), + Modifier + .verticalScroll(rememberScrollState()) + .padding(start = DEFAULT_PADDING, bottom = DEFAULT_BOTTOM_PADDING, end = DEFAULT_PADDING), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top ) { @@ -93,7 +120,17 @@ fun GroupLinkLayout( if (groupLink == null) { SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink) } else { - QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f)) +// SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) { +// RoleSelectionRow(groupInfo, groupLinkMemberRole) +// } + var initialLaunch by remember { mutableStateOf(true) } + LaunchedEffect(groupLinkMemberRole.value) { + if (!initialLaunch) { + updateLink() + } + initialLaunch = false + } + QRCode(groupLink, Modifier.aspectRatio(1f)) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -116,6 +153,25 @@ fun GroupLinkLayout( } } +@Composable +private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text } + ExposedDropDownSettingRow( + generalGetString(R.string.initial_member_role), + values, + selectedRole, + icon = null, + enabled = rememberUpdatedState(enabled), + onSelected = { selectedRole.value = it } + ) + } +} + @Composable fun ProgressIndicator() { Box( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index 22ea4c2db..b0e2524d1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -154,7 +154,7 @@ fun DatabaseLayout( val operationsDisabled = !stopped || progressIndicator Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp), + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = DEFAULT_BOTTOM_PADDING), horizontalAlignment = Alignment.Start, ) { AppBarTitle(stringResource(R.string.your_chat_database)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index 3d7a4e3c1..ce8d52a31 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -85,13 +85,14 @@ fun SectionItemView( click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier .fillMaxWidth() .sizeIn(minHeight = minHeight) Row( - if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING), + if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding), verticalAlignment = Alignment.CenterVertically ) { content() diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 4028dc75a..57dd21a77 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -222,6 +222,9 @@ Only 10 images can be sent at the same time Decoding error The image cannot be decoded. Please, try a different image or contact developers. + you are observer + You can\'t send messages! + Please contact group admin. Image @@ -868,6 +871,7 @@ changing address… + observer member admin owner @@ -890,6 +894,7 @@ No contacts to add New member role + Initial role Expand role selection Invite to group Skip inviting members @@ -919,6 +924,7 @@ You can share a link or a QR code - anybody will be able to join the group. You won\'t lose members of the group if you later delete it. All group members will remain connected. Error creating group link + Error updating group link Error deleting group link Only group owners can change group preferences. diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8b4c3f4f3..adec3f54f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -545,6 +545,25 @@ final class Chat: ObservableObject, Identifiable { self.chatStats = chatStats } + var userCanSend: Bool { + switch chatInfo { + case .direct: return true + case let .group(groupInfo): + let m = groupInfo.membership + return m.memberActive && m.memberRole >= .member + default: return false + } + } + + var userIsObserver: Bool { + switch chatInfo { + case let .group(groupInfo): + let m = groupInfo.membership + return m.memberActive && m.memberRole == .observer + default: return false + } + } + var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 866b09a64..e91b33173 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -344,7 +344,7 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws { func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } - let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) + let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer)) if case let .smpTestResult(_, testFailure) = r { if let t = testFailure { return .failure(t) @@ -868,9 +868,15 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r } -func apiCreateGroupLink(_ groupId: Int64) async throws -> String { - let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) - if case let .groupLinkCreated(_, _, connReq) = r { return connReq } +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { + let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) + if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) } + throw r +} + +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { + let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) + if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) } throw r } @@ -880,11 +886,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { throw r } -func apiGetGroupLink(_ groupId: Int64) throws -> String? { +func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connReq): - return connReq + case let .groupLink(_, _, connReq, memberRole): + return (connReq, memberRole) case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r @@ -1180,6 +1186,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) { m.updateGroup(toGroup) } + case let .memberRole(user, groupInfo, _, _, _, _): + if active(user) { + m.updateGroup(groupInfo) + } case let .rcvFileStart(user, aChatItem): chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index baac736d1..6b3bb48ed 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -258,36 +258,52 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.attachmentDisabled) + .disabled(composeState.attachmentDisabled || !chat.userCanSend) .frame(width: 25, height: 25) .padding(.bottom, 12) .padding(.leading, 12) - SendMessageView( - composeState: $composeState, - sendMessage: { - sendMessage() - resetLinkPreview() - }, - sendLiveMessage: sendLiveMessage, - updateLiveMessage: updateLiveMessage, - cancelLiveMessage: { - composeState.liveMessage = nil - chatModel.removeLiveDummy() - }, - voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), - showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, - startVoiceMessageRecording: { - Task { - await startVoiceMessageRecording() - } - }, - finishVoiceMessageRecording: finishVoiceMessageRecording, - allowVoiceMessagesToContact: allowVoiceMessagesToContact, - onImagesAdded: { images in if !images.isEmpty { chosenImages = images }}, - keyboardVisible: $keyboardVisible - ) - .padding(.trailing, 12) - .background(.background) + ZStack(alignment: .leading) { + SendMessageView( + composeState: $composeState, + sendMessage: { + sendMessage() + resetLinkPreview() + }, + sendLiveMessage: sendLiveMessage, + updateLiveMessage: updateLiveMessage, + cancelLiveMessage: { + composeState.liveMessage = nil + chatModel.removeLiveDummy() + }, + voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), + showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, + startVoiceMessageRecording: { + Task { + await startVoiceMessageRecording() + } + }, + finishVoiceMessageRecording: finishVoiceMessageRecording, + allowVoiceMessagesToContact: allowVoiceMessagesToContact, + onImagesAdded: { images in if !images.isEmpty { chosenImages = images }}, + keyboardVisible: $keyboardVisible + ) + .padding(.trailing, 12) + .background(.background) + .disabled(!chat.userCanSend) + + if chat.userIsObserver { + Text("you are observer") + .italic() + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } + } } } .onChange(of: composeState.message) { _ in @@ -299,6 +315,13 @@ struct ComposeView: View { } } } + .onChange(of: chat.userCanSend) { canSend in + if !canSend { + cancelCurrentVoiceRecording() + clearCurrentDraft() + clearState() + } + } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index e2ce4fc64..613ae37b4 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -140,7 +140,7 @@ struct AddGroupMembersView: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole { + if role <= groupInfo.membership.memberRole && role != .observer { Text(role.text) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index bea381bdc..569be904a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -17,6 +17,7 @@ struct GroupChatInfoView: View { @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var showAddMembersSheet: Bool = false @State private var connectionStats: ConnectionStats? @State private var connectionCode: String? @@ -107,7 +108,9 @@ struct GroupChatInfoView: View { } .onAppear { do { - groupLink = try apiGetGroupLink(groupInfo.groupId) + if let link = try apiGetGroupLink(groupInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } } catch let error { logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } @@ -187,7 +190,7 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink) + GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) .navigationBarTitle("Group link") .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index e280e9f4b..0c4c1c839 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? + @Binding var groupLinkMemberRole: GroupMemberRole @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -33,6 +34,15 @@ struct GroupLinkView: View { Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") .padding(.bottom) if let groupLink = groupLink { +// HStack { +// Text("Initial role") +// Picker("Initial role", selection: $groupLinkMemberRole) { +// ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in +// Text(role.text) +// } +// } +// } +// .frame(maxWidth: .infinity, alignment: .leading) QRCode(uri: groupLink) HStack { Button { @@ -85,6 +95,16 @@ struct GroupLinkView: View { return Alert(title: Text(title), message: Text(error)) } } + .onChange(of: groupLinkMemberRole) { _ in + Task { + do { + _ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole) + } catch let error { + let a = getErrorAlert(error, "Error updating group link") + alert = .error(title: a.title, error: a.message) + } + } + } .onAppear { if groupLink == nil && !creatingLink { createGroupLink() @@ -100,7 +120,7 @@ struct GroupLinkView: View { let link = try await apiCreateGroupLink(groupId) await MainActor.run { creatingLink = false - groupLink = link + (groupLink, groupLinkMemberRole) = link } } catch let error { logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))") @@ -120,8 +140,8 @@ struct GroupLinkView_Previews: PreviewProvider { @State var noGroupLink: String? = nil return Group { - GroupLinkView(groupId: 1, groupLink: $groupLink) - GroupLinkView(groupId: 1, groupLink: $noGroupLink) + GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) + GroupLinkView(groupId: 1, groupLink: $noGroupLink, groupLinkMemberRole: Binding.constant(.member)) } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5a562df10..d7b14ac7e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -46,12 +46,13 @@ public enum ChatCommand { case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) + case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) case apiGetUserSMPServers(userId: Int64) case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg]) - case testSMPServer(userId: Int64, smpServer: String) + case apiTestSMPServer(userId: Int64, smpServer: String) case apiSetChatItemTTL(userId: Int64, seconds: Int64?) case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) @@ -134,12 +135,13 @@ public enum ChatCommand { case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" - case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" + case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)" + case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))" - case let .testSMPServer(userId, smpServer): return "/smp test \(userId) \(smpServer)" + case let .apiTestSMPServer(userId, smpServer): return "/_smp test \(userId) \(smpServer)" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" @@ -228,11 +230,12 @@ public enum ChatCommand { case .apiListMembers: return "apiListMembers" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" case .apiCreateGroupLink: return "apiCreateGroupLink" + case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" case .apiGetUserSMPServers: return "apiGetUserSMPServers" case .apiSetUserSMPServers: return "apiSetUserSMPServers" - case .testSMPServer: return "testSMPServer" + case .apiTestSMPServer: return "testSMPServer" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" @@ -391,8 +394,8 @@ public enum ChatResponse: Decodable, Error { case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) case groupRemoved(user: User, groupInfo: GroupInfo) // unused case groupUpdated(user: User, toGroup: GroupInfo) - case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) - case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) + case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) case groupLinkDeleted(user: User, groupInfo: GroupInfo) // receiving file events case rcvFileAccepted(user: User, chatItem: AChatItem) @@ -606,8 +609,8 @@ public enum ChatResponse: Decodable, Error { case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") - case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e175944d4..51eb76d9d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1517,7 +1517,7 @@ public struct GroupMember: Identifiable, Decodable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole } + return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .observer } } public var memberIncognito: Bool { @@ -1546,6 +1546,7 @@ public struct GroupMemberRef: Decodable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { + case observer = "observer" case member = "member" case admin = "admin" case owner = "owner" @@ -1554,6 +1555,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec public var text: String { switch self { + case .observer: return NSLocalizedString("observer", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") @@ -1562,9 +1564,10 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec private var comparisonValue: Int { switch self { - case .member: return 0 - case .admin: return 1 - case .owner: return 2 + case .observer: return 0 + case .member: return 1 + case .admin: return 2 + case .owner: return 3 } } diff --git a/package.yaml b/package.yaml index 6eb0f968e..2f5ea385a 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 4.5.3.1 +version: 4.5.4.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b7d7df91e..82e8b6fd7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 4.5.3.1 +version: 4.5.4.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -84,6 +84,7 @@ library Simplex.Chat.Migrations.M20230118_recreate_smp_servers Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id + Simplex.Chat.Migrations.M20230303_group_link_role Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1649c4f4f..8d7b3dc44 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -15,7 +15,7 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) -import Control.Concurrent.STM (retry, stateTVar) +import Control.Concurrent.STM (retry) import Control.Logger.Simple import Control.Monad.Except import Control.Monad.IO.Unlift @@ -824,8 +824,10 @@ processChatCommand = \case ok user SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig - TestSMPServer userId smpServer -> withUserId userId $ \user -> + APITestSMPServer userId smpServer -> withUserId userId $ \user -> CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) + TestSMPServer smpServer -> withUser $ \User {userId} -> + processChatCommand $ APITestSMPServer userId smpServer APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do checkSameUser userId user checkStoreNotChanged $ @@ -1189,25 +1191,36 @@ processChatCommand = \case CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} - APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do + APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId assertUserGroupRole gInfo GRAdmin + when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) let crClientData = encodeJSON $ CRDataGroup groupLinkId (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData - withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId - pure $ CRGroupLinkCreated user gInfo cReq + withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole + pure $ CRGroupLinkCreated user gInfo cReq mRole + APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do + gInfo <- withStore $ \db -> getGroupInfo db user groupId + (groupLinkId, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo + assertUserGroupRole gInfo GRAdmin + when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' + when (mRole' /= mRole) $ withStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' + pure $ CRGroupLink user gInfo groupLink mRole' APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId - groupLink <- withStore $ \db -> getGroupLink db user gInfo - pure $ CRGroupLink user gInfo groupLink - CreateGroupLink gName -> withUser $ \user -> do + (_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo + pure $ CRGroupLink user gInfo groupLink mRole + CreateGroupLink gName mRole -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APICreateGroupLink groupId + processChatCommand $ APICreateGroupLink groupId mRole + GroupLinkMemberRole gName mRole -> withUser $ \user -> do + groupId <- withStore $ \db -> getGroupIdByName db user gName + processChatCommand $ APIGroupLinkMemberRole groupId mRole DeleteGroupLink gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIDeleteGroupLink groupId @@ -2211,7 +2224,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_) -> do + Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do forM_ mc_ $ \mc -> do (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) @@ -2219,7 +2232,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ groupId_ $ \groupId -> do gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct GRMember groupConnIds + withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds _ -> pure () Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -2576,7 +2589,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq@UserContactRequest {localDisplayName} -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_) -> + Just (UserContactLink {autoAccept}, groupId_, _) -> case autoAccept of Just AutoAccept {acceptIncognito} -> case groupId_ of Nothing -> do @@ -3211,9 +3224,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) mergeContacts :: Contact -> Contact -> m () - mergeContacts to from = do - withStore' $ \db -> mergeContactRecords db userId to from - toView $ CRContactsMerged user to from + mergeContacts c1 c2 = do + withStore' $ \db -> mergeContactRecords db userId c1 c2 + toView $ CRContactsMerged user c1 c2 saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do @@ -4000,7 +4013,8 @@ chatCommandP = "/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/smp_servers" $> GetUserSMPServers, "/smp default" $> SetUserSMPServers (SMPServersConfig []), - "/smp test " *> (TestSMPServer <$> A.decimal <* A.space <*> strP), + "/_smp test " *> (APITestSMPServer <$> A.decimal <* A.space <*> strP), + "/smp test " *> (TestSMPServer <$> strP), "/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP), "/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/_smp " *> (APIGetUserSMPServers <$> A.decimal), @@ -4035,13 +4049,14 @@ chatCommandP = "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, + ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress, ("/help messages" <|> "/hm") $> ChatHelp HSMessages, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), - ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), + ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRAdmin)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), @@ -4056,10 +4071,12 @@ chatCommandP = ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), - "/_create link #" *> (APICreateGroupLink <$> A.decimal), + "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), + "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayName), + "/create link #" *> (CreateGroupLink <$> displayName <*> (memberRole <|> pure GRMember)), + "/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayName), "/show link #" *> (ShowGroupLink <$> displayName), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), @@ -4169,8 +4186,7 @@ chatCommandP = [ " owner" $> GROwner, " admin" $> GRAdmin, " member" $> GRMember, - -- " observer" $> GRObserver, - pure GRAdmin + " observer" $> GRObserver ] chatNameP = ChatName <$> chatTypeP <*> displayName chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c6a560d52..85511660f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -170,7 +170,7 @@ data ChatController = ChatController logFilePath :: Maybe FilePath } -data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSMarkdown | HSMessages | HSSettings deriving (Show, Generic) instance ToJSON HelpSection where @@ -237,14 +237,16 @@ data ChatCommand | APILeaveGroup GroupId | APIListMembers GroupId | APIUpdateGroupProfile GroupId GroupProfile - | APICreateGroupLink GroupId + | APICreateGroupLink GroupId GroupMemberRole + | APIGroupLinkMemberRole GroupId GroupMemberRole | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId | APIGetUserSMPServers UserId | GetUserSMPServers | APISetUserSMPServers UserId SMPServersConfig | SetUserSMPServers SMPServersConfig - | TestSMPServer UserId SMPServerWithAuth + | APITestSMPServer UserId SMPServerWithAuth + | TestSMPServer SMPServerWithAuth | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId @@ -316,7 +318,8 @@ data ChatCommand | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) - | CreateGroupLink GroupName + | CreateGroupLink GroupName GroupMemberRole + | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName | ShowGroupLink GroupName | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text} @@ -454,8 +457,8 @@ data ChatResponse | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} - | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} - | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} @@ -684,6 +687,7 @@ data ChatErrorType | CEContactDisabled {contact :: Contact} | CEConnectionDisabled {connection :: Connection} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} + | CEGroupMemberInitialRole {groupInfo :: GroupInfo, initialRole :: GroupMemberRole} | CEContactIncognitoCantInvite | CEGroupIncognitoCantInvite | CEGroupContactRole {contactName :: ContactName} diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index b9650b36b..7334b32a8 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -6,6 +6,7 @@ module Simplex.Chat.Help chatHelpInfo, filesHelpInfo, groupsHelpInfo, + contactsHelpInfo, myAddressHelpInfo, messagesHelpInfo, markdownInfo, @@ -84,7 +85,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["messages", "files", "groups", "address", "settings"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "settings"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -100,13 +101,15 @@ filesHelpInfo = map styleMarkdown [ green "File transfer commands:", - indent <> highlight "/file @ " <> " - send file to contact", - indent <> highlight "/file # " <> " - send file to group", - indent <> highlight "/image [] " <> " - send file as image to @contact or #group", - indent <> highlight "/freceive []" <> " - accept to receive file", - indent <> highlight "/fforward [] " <> " - forward received file to @contact or #group", - indent <> highlight "/fcancel " <> " - cancel sending / receiving file", - indent <> highlight "/fstatus " <> " - show file transfer status", + indent <> highlight "/file @ " <> " - send file to contact", + indent <> highlight "/file # " <> " - send file to group", + indent <> highlight "/image [] " <> " - send file as image to @contact or #group", + indent <> highlight "/freceive [] " <> " - accept to receive file", + indent <> highlight "/fforward [] " <> " - forward received file to @contact or #group", + indent <> highlight "/fcancel " <> " - cancel sending / receiving file", + indent <> highlight "/fstatus " <> " - show file transfer status", + indent <> highlight "/imgf @ " <> " - forward received image to contact", + indent <> highlight "/imgf # " <> " - forward received image to group", "", "The commands may be abbreviated: " <> listHighlight ["/f", "/img", "/fr", "/ff", "/fc", "/fs"] ] @@ -115,34 +118,92 @@ groupsHelpInfo :: [StyledString] groupsHelpInfo = map styleMarkdown - [ green "Group management commands:", - indent <> highlight "/group [] " <> " - create group", - indent <> highlight "/add []" <> " - add contact to group, roles: " <> highlight "owner" <> ", " <> highlight "admin" <> " (default), " <> highlight "member", - indent <> highlight "/join " <> " - accept group invitation", - indent <> highlight "/remove " <> " - remove member from group", - indent <> highlight "/leave " <> " - leave group", - indent <> highlight "/delete " <> " - delete group", - indent <> highlight "/members " <> " - list group members", - indent <> highlight "/gp " <> " - view group profile", - indent <> highlight "/gp [] " <> " - update group profile", - indent <> highlight "/group_descr [] " <> " - update/remove group description", - indent <> highlight "/groups " <> " - list groups", - indent <> highlight "# " <> " - send message to group", - indent <> highlight "/create link # " <> " - create public group link", + [ green "Group commands:", + indent <> highlight "/group [] " <> " - create group", + indent <> highlight "/add [] " <> " - add contact to group, roles: " <> highlight "owner" <> ", " <> highlight "admin" <> " (default), " <> highlight "member", + indent <> highlight "/join " <> " - accept group invitation", + indent <> highlight "/members " <> " - list group members", + indent <> highlight "/remove " <> " - remove member from group", + indent <> highlight "/leave " <> " - leave group", + indent <> highlight "/clear # " <> " - clear all messages in the group locally", + indent <> highlight "/delete # " <> " - delete group and all messages", + indent <> highlight "/gp " <> " - view group profile", + indent <> highlight "/gp [] " <> " - update group profile names", + indent <> highlight "/group_descr [] " <> " - update/remove group description", + indent <> highlight "/groups " <> " - list groups", + indent <> highlight "# " <> " - send message to group", + "", + green "Public group links:", + indent <> highlight "/create link # [role] " <> " - create public group link (with optional role, default: member)", + indent <> highlight "/set link role # role " <> " - change role assigned to the users joining via the link (member/observer)", + indent <> highlight "/show link # " <> " - show public group link and initial member role", + indent <> highlight "/delete link # " <> " - delete link to join the group (does NOT delete any members)", + "", + green "Mute group messages:", + indent <> highlight "/mute # " <> " - do not show contact's messages", + indent <> highlight "/unmute # " <> " - show contact's messages", + "", + green "Group member connection and security:", + indent <> highlight "/code # " <> " - show member's security code", + indent <> highlight "/verify # " <> " - verify member's security code", + indent <> highlight "/verify # " <> " - clear security code verification", + indent <> highlight "/info # " <> " - info about member connection", + indent <> highlight "/switch # " <> " - switch receiving messages to another SMP relay", + "", + green "Group chat preferences:", + indent <> highlight "/set voice # on/off " <> " - enable/disable voice messages", + indent <> highlight "/set delete # on/off " <> " - enable/disable full message deletion", + indent <> highlight "/set direct # on/off " <> " - enable/disable direct messages to other members", + indent <> highlight "/set disappear # on