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 70eeb0622..8013beab0 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 @@ -293,6 +293,7 @@ interface SomeChat { val id: ChatId val apiId: Long val ready: Boolean + val sendMsgEnabled: Boolean val createdAt: Instant val updatedAt: Instant } @@ -345,6 +346,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contact.id override val apiId get() = contact.apiId override val ready get() = contact.ready + override val sendMsgEnabled get() = contact.sendMsgEnabled override val createdAt get() = contact.createdAt override val updatedAt get() = contact.updatedAt override val displayName get() = contact.displayName @@ -363,6 +365,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = groupInfo.id override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready + override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val createdAt get() = groupInfo.createdAt override val updatedAt get() = groupInfo.updatedAt override val displayName get() = groupInfo.displayName @@ -381,6 +384,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactRequest.id override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready + override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val createdAt get() = contactRequest.createdAt override val updatedAt get() = contactRequest.updatedAt override val displayName get() = contactRequest.displayName @@ -399,6 +403,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactConnection.id override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready + override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val createdAt get() = contactConnection.createdAt override val updatedAt get() = contactConnection.updatedAt override val displayName get() = contactConnection.displayName @@ -426,6 +431,7 @@ class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready + override val sendMsgEnabled get() = true override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image @@ -500,10 +506,18 @@ class GroupInfo ( override val id get() = "#$groupId" override val apiId get() = groupId override val ready get() = true + override val sendMsgEnabled get() = membership.memberActive override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image + val canDelete: Boolean + get() { + val s = membership.memberStatus + return membership.memberRole == GroupMemberRole.Owner + || (s == GroupMemberStatus.MemRemoved || s == GroupMemberStatus.MemLeft || s == GroupMemberStatus.MemGroupDeleted || s == GroupMemberStatus.MemInvited) + } + companion object { val sampleData = GroupInfo( groupId = 1, @@ -648,6 +662,7 @@ class UserContactRequest ( override val id get() = "<@$contactRequestId" override val apiId get() = contactRequestId override val ready get() = true + override val sendMsgEnabled get() = false override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image @@ -676,6 +691,7 @@ class PendingContactConnection( override val id get () = ":$pccConnId" override val apiId get() = pccConnId override val ready get() = false + override val sendMsgEnabled get() = false override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) override val displayName: String get() { val initiated = pccConnStatus.initiated 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 76321bc6c..96766e10a 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 @@ -377,6 +377,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true + r is CR.GroupDeletedUser && type == ChatType.Group -> return true r is CR.ChatCmdError -> { val e = r.chatError if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { @@ -520,6 +521,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { + val r = sendCmd(CC.ApiLeaveGroup(groupId)) + if (r is CR.LeftMemberUser) return r.groupInfo + Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") + return null + } + fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") @@ -694,6 +702,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + suspend fun leaveGroup(groupId: Long) { + val groupInfo = apiLeaveGroup(groupId) + if (groupInfo != null) { + chatModel.updateGroup(groupInfo) + } + } + private fun chatItemSimpleUpdate(aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 50ac85621..d145e88fc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -29,16 +29,16 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) { ChatInfoLayout( chat, close = close, - deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, + deleteContact = { deleteChatDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) } ) } } -fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.delete_contact__question), - text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + title = generalGetString(R.string.delete_chat_question), + text = generalGetString(R.string.delete_chat_all_messages_deleted_cannot_undo_warning), confirmText = generalGetString(R.string.delete_verb), onConfirm = { withApi { @@ -72,6 +72,18 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit ) } +// TODO move to GroupChatInfoView +fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.leave_group_question), + text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), + confirmText = generalGetString(R.string.leave_group_button), + onConfirm = { + withApi { chatModel.controller.leaveGroup(groupInfo.groupId) } + } + ) +} + @Composable fun ChatInfoLayout( chat: Chat, 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 14876b620..0c151ec68 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 @@ -75,10 +75,12 @@ fun ChatView(chatModel: ChatModel) { chat, composeState, composeView = { - ComposeView( - chatModel, chat, composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } - ) + if (chat.chatInfo.sendMsgEnabled) { + ComposeView( + chatModel, chat, composeState, attachmentOption, + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + ) + } }, attachmentOption, scope, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt index 47785f450..76f198a78 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,9 +50,9 @@ fun CIGroupInvitationView( ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, color = iconColor) Spacer(Modifier.padding(horizontal = 4.dp)) Column { - Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2) + Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis) if (p.fullName != "" && p.displayName != p.fullName) { - Text(p.fullName, maxLines = 2) + Text(p.fullName, maxLines = 2, overflow = TextOverflow.Ellipsis) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 0418c0444..a2861a579 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -17,8 +17,7 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.WarningOrange -import chat.simplex.app.views.chat.clearChatDialog -import chat.simplex.app.views.chat.deleteContactDialog +import chat.simplex.app.views.chat.* import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay @@ -98,16 +97,66 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { @Composable fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (showMarkRead) { - ItemAction( - stringResource(R.string.mark_read), - Icons.Outlined.Check, - onClick = { - markChatRead(chat, chatModel) - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) - showMenu.value = false - } - ) + MarkReadChatAction(chat, chatModel, showMenu) } + ClearChatAction(chat, chatModel, showMenu) + DeleteChatAction(chat, chatModel, showMenu) +} + +@Composable +fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { + when (groupInfo.membership.memberStatus) { + GroupMemberStatus.MemInvited -> { + ItemAction( + stringResource(R.string.join_group_button), + Icons.Outlined.Login, + onClick = { + withApi { chatModel.controller.joinGroup(groupInfo.groupId) } + showMenu.value = false + } + ) + if (groupInfo.canDelete) { + DeleteChatAction(chat, chatModel, showMenu) + } + } + else -> { + if (showMarkRead) { + MarkReadChatAction(chat, chatModel, showMenu) + } + ClearChatAction(chat, chatModel, showMenu) + if (groupInfo.membership.memberStatus != GroupMemberStatus.MemLeft) { + ItemAction( + stringResource(R.string.leave_group_button), + Icons.Outlined.Logout, + onClick = { + leaveGroupDialog(groupInfo, chatModel) + showMenu.value = false + }, + color = Color.Red + ) + } + if (groupInfo.canDelete) { + DeleteChatAction(chat, chatModel, showMenu) + } + } + } +} + +@Composable +fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(R.string.mark_read), + Icons.Outlined.Check, + onClick = { + markChatRead(chat, chatModel) + chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + showMenu.value = false + } + ) +} + +@Composable +fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(R.string.clear_verb), Icons.Outlined.Restore, @@ -117,54 +166,21 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(R.string.delete_verb), Icons.Outlined.Delete, onClick = { - deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel) + deleteChatDialog(chat.chatInfo, chatModel) showMenu.value = false }, color = Color.Red ) } -@Composable -fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { - when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> - ItemAction( - stringResource(R.string.join_button), - Icons.Outlined.Login, - onClick = { - withApi { chatModel.controller.joinGroup(groupInfo.groupId) } - showMenu.value = false - } - ) - else -> { - if (showMarkRead) { - ItemAction( - stringResource(R.string.mark_read), - Icons.Outlined.Check, - onClick = { - markChatRead(chat, chatModel) - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) - showMenu.value = false - } - ) - } - ItemAction( - stringResource(R.string.clear_verb), - Icons.Outlined.Restore, - onClick = { - clearChatDialog(chat.chatInfo, chatModel) - showMenu.value = false - }, - color = WarningOrange - ) - } - } -} - @Composable fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( @@ -311,11 +327,24 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) AlertManager.shared.showAlertDialog( title = generalGetString(R.string.join_group_question), text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members), - confirmText = generalGetString(R.string.join_button), - onConfirm = { withApi { chatModel.controller.joinGroup(groupInfo.groupId) } } + confirmText = generalGetString(R.string.join_group_button), + onConfirm = { withApi { chatModel.controller.joinGroup(groupInfo.groupId) } }, + dismissText = generalGetString(R.string.delete_verb), + onDismiss = { deleteGroup(groupInfo, chatModel) } ) } +fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { + withApi { + val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) + if (r) { + chatModel.removeChat(groupInfo.id) + chatModel.chatId.value = null + chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id) + } + } +} + fun groupInvitationAcceptedAlert() { AlertManager.shared.showAlertMsg( generalGetString(R.string.joining_group), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index baa310bcc..a2bfcffd4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -30,17 +30,41 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { val cInfo = chat.chatInfo @Composable - fun chatPreviewTitleColor(): Color { - return when (cInfo) { + fun chatPreviewTitleText(color: Color = Color.Unspecified) { + Text( + cInfo.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = color + ) + } + + @Composable + fun chatPreviewTitle() { + when (cInfo) { is ChatInfo.Direct -> - if (cInfo.ready) Color.Unspecified else HighOrLowlight + chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight) is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> MaterialTheme.colors.primary - GroupMemberStatus.MemAccepted -> HighOrLowlight - else -> Color.Unspecified + GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary) + GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight) + GroupMemberStatus.MemLeft -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.group_left_description), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = HighOrLowlight + ) + chatPreviewTitleText() + } + else -> chatPreviewTitleText() } - else -> Color.Unspecified + else -> chatPreviewTitleText() } } @@ -77,14 +101,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { .padding(horizontal = 8.dp) .weight(1F) ) { - Text( - cInfo.chatViewName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h3, - fontWeight = FontWeight.Bold, - color = chatPreviewTitleColor() - ) + chatPreviewTitle() chatPreviewText() } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) 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 95c541d32..24b17d38a 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -138,8 +138,8 @@ Ошибка сохранения файла - Удалить контакт? - Контакт и все сообщения будут удалены - это действие нельзя отменить! + Удалить чат? + Чат и все сообщения будут удалены - это действие нельзя отменить! Удалить контакт Соединение с сервером установлено Соединение с сервером не установлено @@ -490,9 +490,13 @@ приглашение в группу %1$s Вступить в группу? Вы приглашены в группу. Вступите, чтобы соединиться с членами группы. - Вступить + Вступить Вступление в группу Вы вступили в эту группу. Устанавливается соединение с пригласившем членом группы. + Выйти + Выйти из группы + Вы перестанете получать сообщения от этой группы. История чата будет сохранена. + [покинута] Вы отправили приглашение в группу diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index a2d4cd03b..5d6f51a6a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -138,8 +138,8 @@ Error saving file - Delete contact? - Contact and all messages will be deleted - this cannot be undone! + Delete chat? + Chat and all messages will be deleted - this cannot be undone! Delete contact Connected Disconnected @@ -492,9 +492,13 @@ invitation to group %1$s Join group? You are invited to group. Join to connect with group members. - Join + Join Joining group You joined this group. Connecting to inviting group member. + Leave + Leave group? + You will stop receiving messages from this group. Chat history will be preserved. + [left] You sent group invitation diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2b1cd0535..fca5a67e8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -350,9 +350,20 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } + if case .group = type, case .groupDeletedUser = r { return } throw r } +func deleteChat(_ chat: Chat) async { + do { + let cInfo = chat.chatInfo + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId) + DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } + } catch { + logger.error("deleteChat apiDeleteChat error: \(responseError(error))") + } +} + func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo } @@ -541,6 +552,21 @@ func apiJoinGroup(groupId: Int64) async throws -> GroupInfo { throw r } +func leaveGroup(groupId: Int64) async { + do { + let groupInfo = try await apiLeaveGroup(groupId: groupId) + DispatchQueue.main.async { ChatModel.shared.updateGroup(groupInfo) } + } catch let error { + logger.error("leaveGroup error: \(responseError(error))") + } +} + +func apiLeaveGroup(groupId: Int64) async throws -> GroupInfo { + let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) + if case let .leftMemberUser(groupInfo) = r { return groupInfo } + throw r +} + func initializeChat(start: Bool) throws { logger.debug("initializeChat") do { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index f0780c92d..c1d6ea2d5 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -32,6 +32,7 @@ struct ChatInfoToolbar: View { Text(cInfo.fullName).font(.subheadline) } } + .frame(width: 180) } .foregroundColor(.primary) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1fdb140a0..6b3e827d7 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -84,6 +84,7 @@ struct ChatView: View { composeState: $composeState, keyboardVisible: $keyboardVisible ) + .disabled(!chat.chatInfo.sendMsgEnabled) } .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 020162812..e12de4a8d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -14,6 +14,7 @@ struct ChatListNavLink: View { @State var chat: Chat @Binding var showChatInfo: Bool @State private var showContactRequestDialog = false + @State private var showJoinGroupDialog = false var body: some View { switch chat.chatInfo { @@ -53,7 +54,7 @@ struct ChatListNavLink: View { Button(role: .destructive) { AlertManager.shared.showAlert( contact.ready - ? deleteContactAlert(contact) + ? deleteChatAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) } label: { @@ -72,29 +73,39 @@ struct ChatListNavLink: View { } @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { - let v = NavLinkPlain( - tag: chat.chatInfo.id, - selection: $chatModel.chatId, - destination: { chatView() }, - label: { ChatPreviewView(chat: chat) }, - disabled: !groupInfo.ready // TODO group has to be accessible for member in other statuses as well, e.g. if he was removed - ) - .frame(height: 80) - switch (groupInfo.membership.memberStatus) { case .memInvited: - v.swipeActions(edge: .trailing, allowsFullSwipe: true) { - joinGroupButton() - } -// .onTapGesture { -// AlertManager.shared.showAlert(acceptGroupInvitationAlert(groupInfo)) -// } -// case .memAccepted: -// v.onTapGesture { -// AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) -// } + ChatPreviewView(chat: chat) + .frame(height: 80) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + joinGroupButton() + } + .swipeActions(edge: .trailing) { + if groupInfo.canDelete() { + deleteGroupChatButton(groupInfo) + } + } + .onTapGesture { showJoinGroupDialog = true } + .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { + Button("Join group") { Task { await joinGroup(groupId: groupInfo.groupId) } } + Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } + } + case .memAccepted: + ChatPreviewView(chat: chat) + .frame(height: 80) + .onTapGesture { + AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) + } default: - v.swipeActions(edge: .leading) { + NavLinkPlain( + tag: chat.chatInfo.id, + selection: $chatModel.chatId, + destination: { chatView() }, + label: { ChatPreviewView(chat: chat) }, + disabled: !groupInfo.ready + ) + .frame(height: 80) + .swipeActions(edge: .leading) { if chat.chatStats.unreadCount > 0 { markReadButton() } @@ -103,10 +114,18 @@ struct ChatListNavLink: View { clearChatButton() } .swipeActions(edge: .trailing) { - Button(role: .destructive) { - AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) - } label: { - Label("Delete", systemImage: "trash") + if (groupInfo.membership.memberStatus != .memLeft) { + Button { + AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) + } label: { + Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") + } + .tint(Color.indigo) + } + } + .swipeActions(edge: .trailing) { + if groupInfo.canDelete() { + deleteGroupChatButton(groupInfo) } } } @@ -116,7 +135,7 @@ struct ChatListNavLink: View { Button { Task { await joinGroup(groupId: chat.chatInfo.apiId) } } label: { - Label("Join", systemImage: "iphone.and.arrow.forward") + Label("Join", systemImage: "ipad.and.arrow.forward") } .tint(Color.accentColor) } @@ -139,6 +158,14 @@ struct ChatListNavLink: View { .tint(Color.orange) } + @ViewBuilder private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View { + Button(role: .destructive) { + AlertManager.shared.showAlert(deleteChatAlert(.group(groupInfo: groupInfo))) + } label: { + Label("Delete", systemImage: "trash") + } + } + private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -155,7 +182,7 @@ struct ChatListNavLink: View { .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept contact") { Task { await acceptContactRequest(contactRequest) } } - Button("Reject contact (sender NOT notified)") { Task { await rejectContactRequest(contactRequest) } } + Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } } } @@ -184,21 +211,12 @@ struct ChatListNavLink: View { } } - private func deleteContactAlert(_ contact: Contact) -> Alert { + private func deleteChatAlert(_ chatInfo: ChatInfo) -> Alert { Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), + title: Text("Delete chat?"), + message: Text("Chat and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteChat(type: .direct, id: contact.apiId) - DispatchQueue.main.async { - chatModel.removeChat(contact.id) - } - } catch let error { - logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(responseError(error))") - } - } + Task { await deleteChat(chat) } }, secondaryButton: .cancel() ) @@ -215,10 +233,14 @@ struct ChatListNavLink: View { ) } - private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { + private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( - title: Text("Delete group"), - message: Text("Group deletion is not supported") + title: Text("Leave group?"), + message: Text("You will stop receiving messages from this group. Chat history will be preserved."), + primaryButton: .destructive(Text("Leave")) { + Task { await leaveGroup(groupId: groupInfo.groupId) } + }, + secondaryButton: .cancel() ) } @@ -267,17 +289,6 @@ struct ChatListNavLink: View { ) } - private func acceptGroupInvitationAlert(_ groupInfo: GroupInfo) -> Alert { - Alert( - title: Text("Join group?"), - message: Text("You are invited to group. Join to connect with group members."), - primaryButton: .default(Text("Join")) { - Task { await joinGroup(groupId: groupInfo.groupId) } - }, - secondaryButton: .cancel() - ) - } - private func groupInvitationAcceptedAlert() -> Alert { Alert( title: Text("Joining group"), diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 215bff294..457721cf2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -55,6 +55,7 @@ struct ChatPreviewView: View { let v = Text(chat.chatInfo.chatViewName) .font(.title3) .fontWeight(.bold) + .lineLimit(1) .frame(maxHeight: .infinity, alignment: .topLeading) switch (chat.chatInfo) { case .direct: @@ -65,11 +66,21 @@ struct ChatPreviewView: View { v.foregroundColor(.accentColor) case .memAccepted: v.foregroundColor(.secondary) - default: - v.foregroundColor(.primary) + case .memLeft: + HStack { + Text(NSLocalizedString("[left]", comment: "group left description")) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.secondary) + Text(chat.chatInfo.chatViewName) + .font(.title3) + .fontWeight(.bold) + .lineLimit(1) + } + .frame(maxHeight: .infinity, alignment: .topLeading) + default: v } - default: - v.foregroundColor(.primary) + default: v } } @@ -95,19 +106,21 @@ struct ChatPreviewView: View { switch (chat.chatInfo) { case let .direct(contact): if !contact.ready { - connectingText() + chatPreviewInfoText("Connecting...") } case let .group(groupInfo): - if groupInfo.membership.memberStatus == .memAccepted { - connectingText() + switch (groupInfo.membership.memberStatus) { + case .memInvited: chatPreviewInfoText("You are invited to group") + case .memAccepted: chatPreviewInfoText("Connecting...") + default: EmptyView() } default: EmptyView() } } } - @ViewBuilder private func connectingText() -> some View { - Text("Connecting...") + @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { + Text(text) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding([.leading, .trailing], 8) .padding(.bottom, 4) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7d5393bc0..59abdad2b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -165,6 +165,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var sendMsgEnabled: Bool { + get { + switch self { + case let .direct(contact): return contact.sendMsgEnabled + case let .group(groupInfo): return groupInfo.sendMsgEnabled + case let .contactRequest(contactRequest): return contactRequest.sendMsgEnabled + case let .contactConnection(contactConnection): return contactConnection.sendMsgEnabled + } + } + } + var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt @@ -226,6 +237,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } + public var sendMsgEnabled: Bool { get { true } } public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } @@ -283,6 +295,7 @@ public struct UserContactRequest: Decodable, NamedChat { public var id: ChatId { get { "<@\(contactRequestId)" } } public var apiId: Int64 { get { contactRequestId } } var ready: Bool { get { true } } + public var sendMsgEnabled: Bool { get { false } } public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } @@ -307,6 +320,7 @@ public struct PendingContactConnection: Decodable, NamedChat { public var id: ChatId { get { ":\(pccConnId)" } } public var apiId: Int64 { get { pccConnId } } var ready: Bool { get { false } } + public var sendMsgEnabled: Bool { get { false } } var localDisplayName: String { get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } } @@ -392,10 +406,16 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "#\(groupId)" } } var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } + public var sendMsgEnabled: Bool { get { membership.memberActive } } public var displayName: String { get { groupProfile.displayName } } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } + public func canDelete() -> Bool { + let s = membership.memberStatus + return membership.memberRole == .owner || (s == .memRemoved || s == .memLeft || s == .memGroupDeleted || s == .memInvited) + } + static let sampleData = GroupInfo( groupId: 1, localDisplayName: "team", diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 560b80bfb..3276c770a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -734,9 +734,10 @@ processChatCommand = \case Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId withChatLock . procCmd $ do void $ sendGroupMessage gInfo members XGrpLeave + -- TODO delete direct connections that were unused mapM_ deleteMemberConnection members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser gInfo + pure $ CRLeftMemberUser gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> CRGroupMembers <$> withUser (\user -> withStore (\db -> getGroup db user groupId)) AddMember gName cName memRole -> withUser $ \user@User {userId} -> do (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db userId cName