From 53f3ee1f506850614bc70e1f5a1bcd8710007aea Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 18 Jul 2022 21:58:32 +0400 Subject: [PATCH] mobile: group invitations ui (#816) * ios: group invitations ui * fix * memberActive (crashes) * adjustments * android ui * android - memberActive * update group invitation item layout * update texts * typo * update layout * do not add contacts added via groups * filter contacts by conn_level * turn off address sanitizer * fix layout * android: filter on update chat * android adjustments * divider fix * android chat previews * ios previews Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 74 +++++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 22 ++- .../chat/simplex/app/views/chat/ChatView.kt | 19 +- .../views/chat/item/CIGroupInvitationView.kt | 162 ++++++++++++++++++ .../app/views/chat/item/ChatItemView.kt | 5 + .../views/chat/item/IntegrityErrorItemView.kt | 2 +- .../app/views/chatlist/ChatListNavLinkView.kt | 84 ++++++--- .../app/views/chatlist/ChatPreviewView.kt | 61 +++++-- .../app/src/main/res/values-ru/strings.xml | 17 ++ .../app/src/main/res/values/strings.xml | 17 ++ apps/ios/Shared/Model/ChatModel.swift | 10 +- apps/ios/Shared/Model/SimpleXAPI.swift | 23 +++ .../Shared/Views/Chat/ChatInfoToolbar.swift | 4 +- .../Views/Chat/ChatItem/CIFileView.swift | 6 +- .../Chat/ChatItem/CIGroupInvitationView.swift | 113 ++++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 6 + .../Views/ChatList/ChatListNavLink.swift | 78 +++++++-- .../Views/ChatList/ChatPreviewView.swift | 90 +++++++--- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 73 +++++++- src/Simplex/Chat.hs | 12 +- 21 files changed, 766 insertions(+), 116 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift 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 1b9b6a59c7..70eeb06226 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 @@ -73,12 +73,14 @@ class ChatModel(val controller: ChatController) { fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) - fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact)) + fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact) - private fun updateChat(cInfo: ChatInfo) { + fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) + + private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(cInfo.id)) { updateChatInfo(cInfo) - } else { + } else if (addMissing) { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) } } @@ -428,6 +430,9 @@ class Contact( override val fullName get() = profile.fullName override val image get() = profile.image + val isIndirectContact: Boolean get() = + activeConn.connLevel > 0 || viaGroup != null + companion object { val sampleData = Contact( contactId = 1, @@ -455,10 +460,10 @@ class ContactSubStatus( ) @Serializable -class Connection(val connId: Long, val connStatus: ConnStatus) { +class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int) { val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready) + val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0) } } @@ -487,6 +492,7 @@ class GroupInfo ( val groupId: Long, override val localDisplayName: String, val groupProfile: GroupProfile, + val membership: GroupMember, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -503,6 +509,7 @@ class GroupInfo ( groupId = 1, localDisplayName = "team", groupProfile = GroupProfile.sampleData, + membership = GroupMember.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -537,6 +544,20 @@ class GroupMember ( val memberContactId: Long? = null, var activeConn: Connection? = null ) { + val memberActive: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRemoved -> false + GroupMemberStatus.MemLeft -> false + GroupMemberStatus.MemGroupDeleted -> false + GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemIntroduced -> false + GroupMemberStatus.MemIntroInvited -> false + GroupMemberStatus.MemAccepted -> false + GroupMemberStatus.MemAnnounced -> false + GroupMemberStatus.MemConnected -> true + GroupMemberStatus.MemComplete -> true + GroupMemberStatus.MemCreator -> true + } + companion object { val sampleData = GroupMember( groupMemberId = 1, @@ -811,6 +832,15 @@ data class ChatItem ( quotedItem = null, file = null ) + + fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) = + ChatItem( + chatDir = CIDirection.DirectRcv(), + meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false), + content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin), + quotedItem = null, + file = null + ) } } @@ -901,6 +931,8 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } override val text: String get() = when(this) { is SndMsgContent -> msgContent.text @@ -910,6 +942,8 @@ sealed class CIContent: ItemContent { is SndCall -> status.text(duration) is RcvCall -> status.text(duration) is RcvIntegrityError -> msgError.text + is RcvGroupInvitation -> groupInvitation.text() + is SndGroupInvitation -> groupInvitation.text() } } @@ -1003,6 +1037,36 @@ sealed class MsgContent { } } +@Serializable +class CIGroupInvitation ( + val groupId: Long, + val groupMemberId: Long, + val localDisplayName: String, + val groupProfile: GroupProfile, + val status: CIGroupInvitationStatus + ) { + fun text(): String = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName) + + companion object { + fun getSample( + groupId: Long = 1, + groupMemberId: Long = 1, + localDisplayName: String = "team", + groupProfile: GroupProfile = GroupProfile.sampleData, + status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending + ): CIGroupInvitation = + CIGroupInvitation(groupId = groupId, groupMemberId = groupMemberId, localDisplayName = localDisplayName, groupProfile = groupProfile, status = status) + } +} + +@Serializable +enum class CIGroupInvitationStatus { + @SerialName("pending") Pending, + @SerialName("accepted") Accepted, + @SerialName("rejected") Rejected, + @SerialName("expired") Expired; +} + object MsgContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { 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 7f32bcba1b..76321bc6ce 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 @@ -509,7 +509,14 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager suspend fun apiReceiveFile(fileId: Long): AChatItem? { val r = sendCmd(CC.ReceiveFile(fileId)) if (r is CR.RcvFileAccepted) return r.chatItem - Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}") + Log.e(TAG, "apiReceiveFile bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiJoinGroup(groupId: Long): GroupInfo? { + val r = sendCmd(CC.ApiJoinGroup(groupId)) + if (r is CR.UserAcceptedGroupSent) return r.groupInfo + Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}") return null } @@ -599,6 +606,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager chatModel.upsertChatItem(cInfo, cItem) } } + is CR.ReceivedGroupInvitation -> { + chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf())) + // TODO NtfManager.shared.notifyGroupInvitation + } + is CR.UserJoinedGroup -> + chatModel.updateGroup(r.groupInfo) is CR.RcvFileStart -> chatItemSimpleUpdate(r.chatItem) is CR.RcvFileComplete -> @@ -674,6 +687,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + suspend fun joinGroup(groupId: Long) { + val groupInfo = apiJoinGroup(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/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 6d94fd8c74..14876b6206 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 @@ -108,6 +108,9 @@ fun ChatView(chatModel: ChatModel) { receiveFile = { fileId -> withApi { chatModel.controller.receiveFile(fileId) } }, + joinGroup = { groupId -> + withApi { chatModel.controller.joinGroup(groupId) } + }, startCall = { media -> val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { @@ -144,6 +147,7 @@ fun ChatLayout( openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit ) { @@ -171,7 +175,7 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall) + ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, joinGroup, acceptCall) } } } @@ -261,6 +265,7 @@ fun ChatItemsList( openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit ) { val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew }) @@ -299,11 +304,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } } else { // direct message @@ -314,7 +319,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) } } } @@ -384,8 +389,9 @@ fun PreviewChatLayout() { openDirectChat = {}, deleteMessage = { _, _ -> }, receiveFile = {}, + joinGroup = {}, startCall = {}, - acceptCall = { _ -> } + acceptCall = { _ -> } ) } } @@ -431,8 +437,9 @@ fun PreviewGroupChatLayout() { openDirectChat = {}, deleteMessage = { _, _ -> }, receiveFile = {}, + joinGroup = {}, startCall = {}, - acceptCall = { _ -> } + acceptCall = { _ -> } ) } } 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 new file mode 100644 index 0000000000..47785f450d --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt @@ -0,0 +1,162 @@ +package chat.simplex.app.views.chat.item + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SupervisedUserCircle +import androidx.compose.runtime.* +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.tooling.preview.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* + +@Composable +fun CIGroupInvitationView( + ci: ChatItem, + groupInvitation: CIGroupInvitation, + memberRole: GroupMemberRole, + joinGroup: (Long) -> Unit +) { + val sent = ci.chatDir.sent + val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending + + @Composable + fun groupInfoView() { + val p = groupInvitation.groupProfile + val iconColor = + if (action) MaterialTheme.colors.primary + else if (isSystemInDarkTheme()) FileDark else FileLight + + Row( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(top = 4.dp, bottom = 12.dp) + .padding(horizontal = 2.dp) + ) { + 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) + if (p.fullName != "" && p.displayName != p.fullName) { + Text(p.fullName, maxLines = 2) + } + } + } + } + + @Composable + fun groupInvitationText() { + when { + sent -> Text(stringResource(R.string.you_sent_group_invitation)) + !sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group)) + !sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group)) + !sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation)) + !sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired)) + } + } + + fun acceptInvitation() { + Log.d(TAG, "CIGroupInvitationView acceptInvitation") + joinGroup(groupInvitation.groupId) + } + + Surface( + modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier, + shape = RoundedCornerShape(18.dp), + color = if (sent) SentColorLight else ReceivedColorLight, + ) { + Box( + Modifier + .width(IntrinsicSize.Min) + .padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd + ) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + horizontalAlignment = Alignment.Start + ) { + groupInfoView() + Divider(Modifier.fillMaxWidth().padding(bottom = 8.dp)) + if (action) { + groupInvitationText() + Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary) + } else { + Box(Modifier.padding(end = 60.dp)) { + groupInvitationText() + } + } + } + Text( + ci.timestampText, + color = HighOrLowlight, + fontSize = 14.sp, + modifier = Modifier.padding(start = 3.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun PendingCIGroupInvitationViewPreview() { + SimpleXTheme { + CIGroupInvitationView( + ci = ChatItem.getGroupInvitationSample(), + groupInvitation = CIGroupInvitation.getSample(), + memberRole = GroupMemberRole.Admin, + joinGroup = {} + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun CIGroupInvitationViewAcceptedPreview() { + SimpleXTheme { + CIGroupInvitationView( + ci = ChatItem.getGroupInvitationSample(), + groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), + memberRole = GroupMemberRole.Admin, + joinGroup = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun CIGroupInvitationViewLongNamePreview() { + SimpleXTheme { + CIGroupInvitationView( + ci = ChatItem.getGroupInvitationSample(), + groupInvitation = CIGroupInvitation.getSample( + groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"), + status = CIGroupInvitationStatus.Accepted + ), + memberRole = GroupMemberRole.Admin, + joinGroup = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 8ff0593d65..b98560a839 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -39,6 +39,7 @@ fun ChatItemView( useLinkPreviews: Boolean, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, + joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit ) { val context = LocalContext.current @@ -146,6 +147,8 @@ fun ChatItemView( is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) + is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) + is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) } } } @@ -209,6 +212,7 @@ fun PreviewChatItemView() { cxt = LocalContext.current, deleteMessage = { _, _ -> }, receiveFile = {}, + joinGroup = {}, acceptCall = { _ -> } ) } @@ -227,6 +231,7 @@ fun PreviewChatItemViewDeletedContent() { cxt = LocalContext.current, deleteMessage = { _, _ -> }, receiveFile = {}, + joinGroup = {}, acceptCall = { _ -> } ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt index 1f8e40c25b..ccafc6f2aa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt @@ -56,7 +56,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) { name = "Dark Mode" ) @Composable -fun IntegrityErrorItemViewView() { +fun IntegrityErrorItemViewPreview() { SimpleXTheme { IntegrityErrorItemView( ChatItem.getDeletedContentSampleData() 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 db30a923c4..0418c0444e 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 @@ -1,7 +1,6 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -39,7 +38,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, stopped) }, - click = { openOrPendingChat(chat.chatInfo, chatModel) }, + click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, stopped @@ -47,8 +46,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Group -> ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, stopped) }, - click = { openOrPendingChat(chat.chatInfo, chatModel) }, - dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) }, + click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, + dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, stopped ) @@ -71,7 +70,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } -fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) { +fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { if (chatInfo.ready) { withApi { openChat(chatInfo, chatModel) } } else { @@ -79,6 +78,14 @@ fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) { } } +fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { + when (groupInfo.membership.memberStatus) { + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel) + GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() + else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) } + } +} + suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { @@ -122,27 +129,40 @@ 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 +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 + ) + } } - ItemAction( - stringResource(R.string.clear_verb), - Icons.Outlined.Restore, - onClick = { - clearChatDialog(chat.chatInfo, chatModel) - showMenu.value = false - }, - color = WarningOrange - ) } @Composable @@ -287,6 +307,22 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } +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) } } + ) +} + +fun groupInvitationAcceptedAlert() { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.joining_group), + generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member) + ) +} + @Composable fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, 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 1ce4177e25..baa310bcc0 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 @@ -27,8 +27,50 @@ import chat.simplex.app.views.helpers.badgeLayout @Composable fun ChatPreviewView(chat: Chat, stopped: Boolean) { + val cInfo = chat.chatInfo + + @Composable + fun chatPreviewTitleColor(): Color { + return when (cInfo) { + is ChatInfo.Direct -> + 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 + } + else -> Color.Unspecified + } + } + + @Composable + fun chatPreviewText() { + val ci = chat.chatItems.lastOrNull() + if (ci != null) { + MarkdownText( + ci.text, ci.formattedText, ci.memberDisplayName, + metaText = ci.timestampText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), + ) + } else { + when (cInfo) { + is ChatInfo.Direct -> + if (!cInfo.ready) { + Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight) + } + is ChatInfo.Group -> + if (cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemAccepted) { + Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight) + } + else -> {} + } + } + } + Row { - val cInfo = chat.chatInfo ChatInfoImage(cInfo, size = 72.dp) Column( modifier = Modifier @@ -41,22 +83,9 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, - color = if (cInfo.ready) Color.Unspecified else HighOrLowlight + color = chatPreviewTitleColor() ) - if (cInfo.ready) { - val ci = chat.chatItems.lastOrNull() - if (ci != null) { - MarkdownText( - ci.text, ci.formattedText, ci.memberDisplayName, - metaText = ci.timestampText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), - ) - } - } else { - Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight) - } + 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 1d048026ad..95c541d329 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -110,6 +110,7 @@ Этот текст можно найти в Настройках Ваши чаты соединяется… + соединяется… Прикрепить @@ -484,4 +485,20 @@ Удалить архив Дата создания %1$s Удалить архив чата? + + + приглашение в группу %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 bdcf8e66bb..a2d4cd03b6 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -110,6 +110,7 @@ This text is available in settings Your chats connecting… + connecting… Attach @@ -486,4 +487,20 @@ Delete archive Created on %1$s Delete chat archive? + + + invitation to group %1$s + Join group? + You are invited to group. Join to connect with group members. + Join + Joining group + You joined this group. Connecting to inviting group member. + + + You sent group invitation + You are invited to group + Tap to join + You joined this group + You rejected group invitation + Group invitation expired diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fd0234332f..10a6a037a3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -76,13 +76,17 @@ final class ChatModel: ObservableObject { } func updateContact(_ contact: Contact) { - updateChat(.direct(contact: contact)) + updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact()) } - private func updateChat(_ cInfo: ChatInfo) { + func updateGroup(_ groupInfo: GroupInfo) { + updateChat(.group(groupInfo: groupInfo)) + } + + private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { if hasChat(cInfo.id) { updateChatInfo(cInfo) - } else { + } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a22fdecad4..2b1cd0535d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -526,6 +526,21 @@ func apiNewGroup(_ gp: GroupProfile) throws -> GroupInfo { throw r } +func joinGroup(groupId: Int64) async { + do { + let groupInfo = try await apiJoinGroup(groupId: groupId) + DispatchQueue.main.async { ChatModel.shared.updateGroup(groupInfo) } + } catch let error { + logger.error("joinGroup error: \(responseError(error))") + } +} + +func apiJoinGroup(groupId: Int64) async throws -> GroupInfo { + let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) + if case let .userAcceptedGroupSent(groupInfo) = r { return groupInfo } + throw r +} + func initializeChat(start: Bool) throws { logger.debug("initializeChat") do { @@ -695,6 +710,14 @@ func processReceivedMsg(_ res: ChatResponse) async { // currently only broadcast deletion of rcv message can be received, and only this case should happen _ = m.upsertChatItem(cInfo, cItem) } + case let .receivedGroupInvitation(groupInfo, _, _): + m.addChat(Chat( + chatInfo: .group(groupInfo: groupInfo), + chatItems: [] + )) + // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? + case let .userJoinedGroup(groupInfo): + m.updateGroup(groupInfo) case let .rcvFileStart(aChatItem): chatItemSimpleUpdate(aChatItem) case let .rcvFileComplete(aChatItem): diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 6e3fcfd96d..f0780c92de 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -9,8 +9,8 @@ import SwiftUI import SimpleXChat -private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9) -private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 ) +let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9) +let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 ) struct ChatInfoToolbar: View { @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 31b90e2ee3..7ccc272e8b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -56,13 +56,13 @@ struct CIFileView: View { } func fileAction() { - logger.debug("CIFileView processFile") + logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { case .rcvInvitation: if fileSizeValid() { Task { - logger.debug("CIFileView processFile - in .rcvInvitation, in Task") + logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") await receiveFile(fileId: file.fileId) } } else { @@ -78,7 +78,7 @@ struct CIFileView: View { message: "File will be received when your contact is online, please wait or check later!" ) case .rcvComplete: - logger.debug("CIFileView processFile - in .rcvComplete") + logger.debug("CIFileView fileAction - in .rcvComplete") if let filePath = getLoadedFilePath(file){ let url = URL(fileURLWithPath: filePath) showShareSheet(items: [url]) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift new file mode 100644 index 0000000000..803ff19939 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -0,0 +1,113 @@ +// +// CIGroupInvitationView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 15.07.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CIGroupInvitationView: View { + @Environment(\.colorScheme) var colorScheme + var chatItem: ChatItem + var groupInvitation: CIGroupInvitation + var memberRole: GroupMemberRole + @State private var frameWidth: CGFloat = 0 + + var body: some View { + let action = !chatItem.chatDir.sent && groupInvitation.status == .pending + let v = ZStack(alignment: .bottomTrailing) { + VStack(alignment: .leading) { + groupInfoView(action) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) + + Divider().frame(width: frameWidth) + + if action { + groupInvitationText().overlay(DetermineWidth()) + Text("Tap to join") + .foregroundColor(.accentColor) + .font(.callout) + } else { + groupInvitationText() + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } + .padding(.bottom, 2) + chatItem.timestampText + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(chatItemFrameColor(chatItem, colorScheme)) + .cornerRadius(18) + .textSelection(.disabled) + .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + + if action { + v.onTapGesture { acceptInvitation() } + } else { + v + } + } + + private func groupInfoView(_ action: Bool) -> some View { + HStack(alignment: .top) { + ProfileImage( + iconName: "person.2.circle.fill", + color: action ? .accentColor : Color(uiColor: .tertiaryLabel) + ) + .frame(width: 44, height: 44) + .padding(.trailing, 4) + VStack(alignment: .leading) { + let p = groupInvitation.groupProfile + Text(p.displayName).font(.headline).lineLimit(2) + if p.fullName != "" && p.displayName != p.fullName { + Text(p.fullName).font(.subheadline).lineLimit(2) + } + } + .frame(minHeight: 44) + } + } + + private func groupInvitationText() -> some View { + Text(groupInvitationStr()) + .font(.callout) + } + + private func groupInvitationStr() -> LocalizedStringKey { + if chatItem.chatDir.sent { + return "You sent group invitation" + } else { + switch groupInvitation.status { + case .pending: return "You are invited to group" + case .accepted: return "You joined this group" + case .rejected: return "You rejected group invitation" + case .expired: return "Group invitation expired" + } + } + } + + private func acceptInvitation() { + Task { + logger.debug("acceptInvitation") + await joinGroup(groupId: groupInvitation.groupId) + } + } +} + +struct CIGroupInvitationView_Previews: PreviewProvider { + static var previews: some View { + Group { + CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin) + CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 62edc343ac..ce5f68dc2b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -24,6 +24,8 @@ struct ChatItemView: View { case let .sndCall(status, duration): callItemView(status, duration) case let .rcvCall(status, duration): callItemView(status, duration) case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember) + case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) } } @@ -42,6 +44,10 @@ struct ChatItemView: View { private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration) } + + private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { + CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole) + } } struct ChatItemView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index eedf579a10..020162812a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -71,30 +71,54 @@ struct ChatListNavLink: View { } } - private func groupNavLink(_ groupInfo: GroupInfo) -> some View { - NavLinkPlain( + @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 + disabled: !groupInfo.ready // TODO group has to be accessible for member in other statuses as well, e.g. if he was removed ) - .swipeActions(edge: .leading) { - if chat.chatStats.unreadCount > 0 { - markReadButton() - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - clearChatButton() - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) - } label: { - Label("Delete", systemImage: "trash") - } - } .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()) +// } + default: + v.swipeActions(edge: .leading) { + if chat.chatStats.unreadCount > 0 { + markReadButton() + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + clearChatButton() + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + + private func joinGroupButton() -> some View { + Button { + Task { await joinGroup(groupId: chat.chatInfo.apiId) } + } label: { + Label("Join", systemImage: "iphone.and.arrow.forward") + } + .tint(Color.accentColor) } private func markReadButton() -> some View { @@ -243,6 +267,24 @@ 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"), + message: Text("You joined this group. Connecting to inviting group member.") + ) + } + private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Delete pending connection"), diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index d1490eaf0c..215bff294c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -24,11 +24,7 @@ struct ChatPreviewView: View { VStack(spacing: 0) { HStack(alignment: .top) { - Text(chat.chatInfo.chatViewName) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) - .frame(maxHeight: .infinity, alignment: .topLeading) + chatPreviewTitle() Spacer() (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt)) .font(.subheadline) @@ -41,27 +37,7 @@ struct ChatPreviewView: View { .padding(.horizontal, 8) ZStack(alignment: .topTrailing) { - if let cItem = cItem { - (itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true)) - .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding(.leading, 8) - .padding(.trailing, 36) - .padding(.bottom, 4) - if unread > 0 { - Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 4) - .frame(minWidth: 18, minHeight: 18) - .background(Color.accentColor) - .cornerRadius(10) - } - } else if case let .direct(contact) = chat.chatInfo, !contact.ready { - Text("Connecting...") - .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) - } + chatPreviewText(cItem, unread) if case .direct = chat.chatInfo { chatStatusImage() .padding(.top, 24) @@ -75,6 +51,68 @@ struct ChatPreviewView: View { } } + @ViewBuilder private func chatPreviewTitle() -> some View { + let v = Text(chat.chatInfo.chatViewName) + .font(.title3) + .fontWeight(.bold) + .frame(maxHeight: .infinity, alignment: .topLeading) + switch (chat.chatInfo) { + case .direct: + v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary) + case .group(groupInfo: let groupInfo): + switch (groupInfo.membership.memberStatus) { + case .memInvited: + v.foregroundColor(.accentColor) + case .memAccepted: + v.foregroundColor(.secondary) + default: + v.foregroundColor(.primary) + } + default: + v.foregroundColor(.primary) + } + } + + @ViewBuilder private func chatPreviewText(_ cItem: ChatItem?, _ unread: Int) -> some View { + if let cItem = cItem { + ZStack(alignment: .topTrailing) { + (itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true)) + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding(.leading, 8) + .padding(.trailing, 36) + .padding(.bottom, 4) + if unread > 0 { + Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(Color.accentColor) + .cornerRadius(10) + } + } + } else { + switch (chat.chatInfo) { + case let .direct(contact): + if !contact.ready { + connectingText() + } + case let .group(groupInfo): + if groupInfo.membership.memberStatus == .memAccepted { + connectingText() + } + default: EmptyView() + } + } + } + + @ViewBuilder private func connectingText() -> some View { + Text("Connecting...") + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } + private func itemStatusMark(_ cItem: ChatItem) -> Text { switch cItem.meta.itemStatus { case .sndErrorAuth: diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1ab30531ff..81c1d4f67c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -297,6 +298,7 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; + 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -589,6 +591,7 @@ 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, 5C029EA72837DBB3004A9677 /* CICallItemView.swift */, 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */, + 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */, ); path = ChatItem; sourceTree = ""; @@ -838,6 +841,7 @@ 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 96d97241de..7d5393bc07 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -219,7 +219,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { var localDisplayName: ContactName public var profile: Profile public var activeConn: Connection - var viaGroup: Int64? + public var viaGroup: Int64? var createdAt: Date var updatedAt: Date @@ -230,6 +230,10 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public func isIndirectContact() -> Bool { + return activeConn.connLevel > 0 || viaGroup != nil + } + public static let sampleData = Contact( contactId: 1, localDisplayName: "alice", @@ -255,12 +259,14 @@ public struct ContactSubStatus: Decodable { public struct Connection: Decodable { var connId: Int64 var connStatus: ConnStatus + var connLevel: Int public var id: ChatId { get { ":\(connId)" } } static let sampleData = Connection( connId: 1, - connStatus: .ready + connStatus: .ready, + connLevel: 0 ) } @@ -376,9 +382,10 @@ public struct Group: Decodable { } public struct GroupInfo: Identifiable, Decodable, NamedChat { - var groupId: Int64 + public var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile + public var membership: GroupMember var createdAt: Date var updatedAt: Date @@ -393,6 +400,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { groupId: 1, localDisplayName: "team", groupProfile: GroupProfile.sampleData, + membership: GroupMember.sampleData, createdAt: .now, updatedAt: .now ) @@ -409,7 +417,7 @@ public struct GroupProfile: Codable, NamedChat { public var fullName: String public var image: String? - static let sampleData = GroupProfile( + public static let sampleData = GroupProfile( displayName: "team", fullName: "My Team" ) @@ -421,7 +429,7 @@ public struct GroupMember: Decodable { var memberId: String var memberRole: GroupMemberRole var memberCategory: GroupMemberCategory - var memberStatus: GroupMemberStatus + public var memberStatus: GroupMemberStatus var invitedBy: InvitedBy var localDisplayName: ContactName public var memberProfile: Profile @@ -445,6 +453,24 @@ public struct GroupMember: Decodable { } } + public var memberActive: Bool { + get { + switch self.memberStatus { + case .memRemoved: return false + case .memLeft: return false + case .memGroupDeleted: return false + case .memInvited: return false + case .memIntroduced: return false + case .memIntroInvited: return false + case .memAccepted: return false + case .memAnnounced: return false + case .memConnected: return true + case .memComplete: return true + case .memCreator: return true + } + } + } + public static let sampleData = GroupMember( groupMemberId: 1, groupId: 1, @@ -624,6 +650,16 @@ public struct ChatItem: Identifiable, Decodable { file: nil ) } + + public static func getGroupInvitationSample (_ status: CIGroupInvitationStatus = .pending) -> ChatItem { + ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, false, false, false), + content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: status), memberRole: .admin), + quotedItem: nil, + file: nil + ) + } } public enum CIDirection: Decodable { @@ -706,6 +742,8 @@ public enum CIContent: Decodable, ItemContent { case sndCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) + case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) public var text: String { get { @@ -717,6 +755,8 @@ public enum CIContent: Decodable, ItemContent { case let .sndCall(status, duration): return status.text(duration) case let .rcvCall(status, duration): return status.text(duration) case let .rcvIntegrityError(msgError): return msgError.text + case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text() + case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text() } } } @@ -1027,3 +1067,26 @@ public enum MsgErrorType: Decodable { } } } + +public struct CIGroupInvitation: Decodable { + public var groupId: Int64 + public var groupMemberId: Int64 + public var localDisplayName: GroupName + public var groupProfile: GroupProfile + public var status: CIGroupInvitationStatus + + func text() -> String { + String.localizedStringWithFormat(NSLocalizedString("invitation to group %@", comment: "group name"), groupProfile.displayName) + } + + public static func getSample(groupId: Int64 = 1, groupMemberId: Int64 = 1, localDisplayName: GroupName = "team", groupProfile: GroupProfile = GroupProfile.sampleData, status: CIGroupInvitationStatus = .pending) -> CIGroupInvitation { + CIGroupInvitation(groupId: groupId, groupMemberId: groupMemberId, localDisplayName: localDisplayName, groupProfile: groupProfile, status: status) + } +} + +public enum CIGroupInvitationStatus: String, Decodable { + case pending + case accepted + case rejected + case expired +} diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 76cabe9b15..560b80bfba 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -700,15 +700,15 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \db -> getGroupInvitation db user groupId + ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember) + agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership:: GroupMember) withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted - updateGroupMemberStatus db userId (membership g) GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user - pure $ CRUserAcceptedGroupSent g + pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}} where updateCIGroupInvitationStatus user@User {userId} = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId @@ -1380,11 +1380,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage sendPendingGroupMessages m conn case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup gInfo + toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do - toView $ CRJoinedGroupMember gInfo m + toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m