mobile: leave & delete group; ios: fix group preview interaction (#819)

This commit is contained in:
JRoberts
2022-07-19 18:21:15 +04:00
committed by GitHub
parent cf1f921aed
commit 0e84e131cd
16 changed files with 319 additions and 146 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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<Boolean>, 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<Boolean>, 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<Boolean>) {
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<Boolean>) {
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
@@ -117,54 +166,21 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
},
color = WarningOrange
)
}
@Composable
fun DeleteChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
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<Boolean>, 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<Boolean>) {
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),

View File

@@ -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)

View File

@@ -138,8 +138,8 @@
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="delete_chat_question">Удалить чат?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Чат и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="button_delete_contact">Удалить контакт</string>
<string name="icon_descr_server_status_connected">Соединение с сервером установлено</string>
<string name="icon_descr_server_status_disconnected">Соединение с сервером не установлено</string>
@@ -490,9 +490,13 @@
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Вступить в группу?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Вы приглашены в группу. Вступите, чтобы соединиться с членами группы.</string>
<string name="join_button">Вступить</string>
<string name="join_group_button">Вступить</string>
<string name="joining_group">Вступление в группу</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Вы вступили в эту группу. Устанавливается соединение с пригласившем членом группы.</string>
<string name="leave_group_button">Выйти</string>
<string name="leave_group_question">Выйти из группы</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Вы перестанете получать сообщения от этой группы. История чата будет сохранена.</string>
<string name="group_left_description">[покинута]</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>

View File

@@ -138,8 +138,8 @@
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_chat_question">Delete chat?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Chat and all messages will be deleted - this cannot be undone!</string>
<string name="button_delete_contact">Delete contact</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -492,9 +492,13 @@
<string name="group_invitation_item_description">invitation to group <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Join group?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">You are invited to group. Join to connect with group members.</string>
<string name="join_button">Join</string>
<string name="join_group_button">Join</string>
<string name="joining_group">Joining group</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">You joined this group. Connecting to inviting group member.</string>
<string name="leave_group_button">Leave</string>
<string name="leave_group_question">Leave group?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">You will stop receiving messages from this group. Chat history will be preserved.</string>
<string name="group_left_description">[left]</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>

View File

@@ -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 {

View File

@@ -32,6 +32,7 @@ struct ChatInfoToolbar: View {
Text(cInfo.fullName).font(.subheadline)
}
}
.frame(width: 180)
}
.foregroundColor(.primary)
}

View File

@@ -84,6 +84,7 @@ struct ChatView: View {
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!chat.chatInfo.sendMsgEnabled)
}
.navigationTitle(cInfo.chatViewName)
.navigationBarTitleDisplayMode(.inline)

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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",

View File

@@ -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