From e31e8a6def7dde287c5edb414d0a22eea503ef57 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:43:44 +0000 Subject: [PATCH] desktop, android: update types, context user picker, rework address settings, align previews UI (#6016) --- apps/ios/SimpleXChat/APITypes.swift | 2 + apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 83 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 90 +++--- .../simplex/common/views/chat/ChatView.kt | 41 ++- .../chat/ComposeContextProfilePickerView.kt | 281 ++++++++++++++++++ .../simplex/common/views/chat/ComposeView.kt | 37 ++- .../views/chat/group/GroupChatInfoView.kt | 4 +- .../common/views/chatlist/ChatPreviewView.kt | 132 ++++---- .../views/contacts/ContactPreviewView.kt | 2 +- .../common/views/helpers/AnimationUtils.kt | 2 + .../common/views/helpers/ChatInfoImage.kt | 7 +- .../simplex/common/views/helpers/ModalView.kt | 3 +- .../common/views/newchat/ConnectPlan.kt | 35 ++- .../common/views/newchat/NewChatView.kt | 21 +- .../views/usersettings/UserAddressView.kt | 241 +++++++++------ .../commonMain/resources/MR/base/strings.xml | 20 +- .../resources/MR/images/ic_chevron_down.svg | 1 + .../resources/MR/images/ic_chevron_up.svg | 1 + 19 files changed, 735 insertions(+), 270 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 601497ed39..aa0055fa70 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -758,6 +758,8 @@ public enum StoreError: Decodable, Hashable { case userContactLinkNotFound case contactRequestNotFound(contactRequestId: Int64) case contactRequestNotFoundByName(contactName: ContactName) + case invalidContactRequestEntity(contactRequestId: Int64) + case invalidBusinessChatContactRequest case groupNotFound(groupId: Int64) case groupNotFoundByName(groupName: GroupName) case groupMemberNameNotFound(groupId: Int64, groupMemberName: ContactName) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f612cb2cc7..9858b92350 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2142,7 +2142,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public struct PreparedGroup: Decodable, Hashable { - public var connLinkToConnect: CreatedConnLink? + public var connLinkToConnect: CreatedConnLink public var connLinkStartedConnection: Bool } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index b1ffa20b2e..a893c50de1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -398,8 +398,19 @@ object ChatModel { } } - fun updateChats(newChats: List) { - chats.replaceAll(newChats) + fun updateChats(newChats: List, keepingChatId: String? = null) { + if (keepingChatId != null) { + val chatToKeep = getChat(keepingChatId) + val indexToRemove = newChats.indexOfFirst { it.id == keepingChatId } + if (chatToKeep != null && indexToRemove != -1) { + val remainingNewChats = newChats.toMutableList().apply { removeAt(indexToRemove) } + chats.replaceAll(listOf(chatToKeep) + remainingNewChats) + } else { + chats.replaceAll(newChats) + } + } else { + chats.replaceAll(newChats) + } popChatCollector.clear() val cId = chatId.value @@ -438,14 +449,16 @@ object ChatModel { chatState.itemsRemoved(listOf(removed), chatItems.value) } - suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + suspend fun addChatItem(rhId: Long?, chatInfo: ChatInfo, cItem: ChatItem) { // updates membersRequireAttention - updateChatInfo(rhId, cInfo) - // mark chat non deleted - if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { - val updatedContact = cInfo.contact.copy(chatDeleted = false) - updateContact(rhId, updatedContact) + val cInfo = if (chatInfo is ChatInfo.Direct && chatInfo.chatDeleted) { + // mark chat non deleted + val updatedContact = chatInfo.contact.copy(chatDeleted = false) + ChatInfo.Direct(updatedContact) + } else { + chatInfo } + updateChatInfo(rhId, cInfo) // update chat list val i = getChatIndex(rhId, cInfo.id) val chat: Chat @@ -1502,7 +1515,13 @@ sealed class ChatInfo: SomeChat, NamedChat { if (contact.sendMsgToConnect) return null if (contact.nextAcceptContactRequest) { return generalGetString(MR.strings.cant_send_message_generic) to null } if (!contact.active) return generalGetString(MR.strings.cant_send_message_contact_deleted) to null - if (!contact.sndReady) return generalGetString(MR.strings.cant_send_message_contact_not_ready) to null + if (!contact.sndReady) { + return if (contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { + generalGetString(MR.strings.cant_send_message_request_is_sent) to null + } else { + generalGetString(MR.strings.cant_send_message_contact_not_ready) to null + } + } if (contact.activeConn?.connectionStats?.ratchetSyncSendProhibited == true) return generalGetString(MR.strings.cant_send_message_contact_not_synchronized) to null if (contact.activeConn?.connDisabled == true) return generalGetString(MR.strings.cant_send_message_contact_disabled) to null return null @@ -1532,6 +1551,8 @@ sealed class ChatInfo: SomeChat, NamedChat { return null } } + } else if (groupInfo.nextConnectPrepared) { + return null } else { return when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null @@ -1667,8 +1688,8 @@ data class Contact( val active get() = contactStatus == ContactStatus.Active override val nextConnect get() = sendMsgToConnect val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - val nextConnectPrepared get() = preparedContact != null && activeConn == null - val nextAcceptContactRequest get() = contactRequestId != null && activeConn == null + val nextConnectPrepared get() = preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) + val nextAcceptContactRequest get() = contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New) val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -1699,6 +1720,9 @@ data class Contact( true } + val isContactCard: Boolean = + activeConn == null && profile.contactLink != null && active + val contactConnIncognito = activeConn?.customUserProfileId != null @@ -1906,8 +1930,7 @@ data class GroupInfo ( override val createdAt: Instant, override val updatedAt: Instant, val chatTs: Instant?, - val connLinkToConnect: CreatedConnLink?, - val connLinkStartedConnection: Boolean, + val preparedGroup: PreparedGroup?, val uiThemes: ThemeModeOverrides? = null, val membersRequireAttention: Int, val chatTags: List, @@ -1919,7 +1942,7 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val nextConnect get() = nextConnectPrepared - val nextConnectPrepared = connLinkToConnect != null && !connLinkStartedConnection + val nextConnectPrepared = if (preparedGroup != null) !preparedGroup.connLinkStartedConnection else false override val chatDeleted get() = false override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -1946,6 +1969,13 @@ data class GroupInfo ( val canModerate: Boolean get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + val chatIconName: ImageResource + get() = when (businessChat?.chatType) { + null -> MR.images.ic_supervised_user_circle_filled + BusinessChatType.Business -> MR.images.ic_work_filled_padded + BusinessChatType.Customer -> MR.images.ic_account_circle_filled + } + fun groupFeatureEnabled(feature: GroupFeature): Boolean { val p = fullGroupPreferences return when (feature) { @@ -1972,8 +2002,7 @@ data class GroupInfo ( createdAt = Clock.System.now(), updatedAt = Clock.System.now(), chatTs = Clock.System.now(), - connLinkToConnect = null, - connLinkStartedConnection = false, + preparedGroup = null, uiThemes = null, membersRequireAttention = 0, chatTags = emptyList(), @@ -1983,6 +2012,12 @@ data class GroupInfo ( } } +@Serializable +data class PreparedGroup ( + val connLinkToConnect: CreatedConnLink, + val connLinkStartedConnection: Boolean +) + @Serializable data class GroupRef(val groupId: Long, val localDisplayName: String) @@ -2886,6 +2921,7 @@ data class ChatItem ( deletable = false, editable = false, userMention = false, + showGroupAsSender = false, ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, @@ -2911,6 +2947,7 @@ data class ChatItem ( deletable = false, editable = false, userMention = false, + showGroupAsSender = false ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, @@ -3056,7 +3093,8 @@ data class CIMeta ( val itemLive: Boolean?, val userMention: Boolean, val deletable: Boolean, - val editable: Boolean + val editable: Boolean, + val showGroupAsSender: Boolean ) { val timestampText: String get() = getTimestampText(itemTs, true) @@ -3095,6 +3133,7 @@ data class CIMeta ( deletable = deletable, editable = editable, userMention = false, + showGroupAsSender = false ) fun invalidJSON(): CIMeta = @@ -3114,7 +3153,8 @@ data class CIMeta ( itemLive = false, deletable = false, editable = false, - userMention = false + userMention = false, + showGroupAsSender = false ) } } @@ -3469,6 +3509,13 @@ sealed class CIContent: ItemContent { is InvalidJSON -> "invalid data" } + val hasMsgContent: Boolean get() = + if (msgContent != null) { + (msgContent as MsgContent).text.trim().isNotEmpty() + } else { + false + } + val showMemberName: Boolean get() = when (this) { is RcvMsgContent -> true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e511f30ff7..2f2ca408a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -591,7 +591,7 @@ object ChatController { } } - suspend fun changeActiveUser_(rhId: Long?, toUserId: Long?, viewPwd: String?) { + suspend fun changeActiveUser_(rhId: Long?, toUserId: Long?, viewPwd: String?, keepingChatId: String? = null) { val prevActiveUser = chatModel.currentUser.value val currentUser = changingActiveUserMutex.withLock { (if (toUserId != null) apiSetActiveUser(rhId, toUserId, viewPwd) else apiGetActiveUser(rhId)).also { @@ -604,20 +604,20 @@ object ChatController { val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - getUserChatData(rhId) + getUserChatData(rhId, keepingChatId = keepingChatId) val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null && currentUser != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) } } - suspend fun getUserChatData(rhId: Long?) { + suspend fun getUserChatData(rhId: Long?, keepingChatId: String? = null) { val hasUser = chatModel.currentUser.value != null chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None withContext(Dispatchers.Main) { val chats = apiGetChats(rhId) - chatModel.chatsContext.updateChats(chats) + chatModel.chatsContext.updateChats(chats, keepingChatId = keepingChatId) } chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() chatModel.activeChatTagFilter.value = null @@ -793,7 +793,7 @@ object ChatController { } suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean { - val r = sendCmd(null, CC.StartChat(mainApp = true, largeLinkData = false), ctrl) + val r = sendCmd(null, CC.StartChat(mainApp = true, largeLinkData = true), ctrl) when (r.result) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -1391,19 +1391,19 @@ object ChatController { } } - suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Contact? { + suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareContact") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData)) - if (r is API.Result && r.res is CR.NewPreparedContact) return r.res.contact + if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareContact bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_contact), "${r.responseType}: ${r.details}") return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): GroupInfo? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, groupShortLinkData)) - if (r is API.Result && r.res is CR.NewPreparedGroup) return r.res.groupInfo + if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") return null @@ -1425,7 +1425,7 @@ object ChatController { return null } - suspend fun apiConnectPreparedContact(rh: Long?, contactId: Long, incognito: Boolean, msg: MsgContent): Contact? { + suspend fun apiConnectPreparedContact(rh: Long?, contactId: Long, incognito: Boolean, msg: MsgContent?): Contact? { val r = sendCmd(rh, CC.APIConnectPreparedContact(contactId, incognito, msg)) if (r is API.Result && r.res is CR.StartedConnectionToContact) return r.res.contact Log.e(TAG, "apiConnectPreparedContact bad response: ${r.responseType} ${r.details}") @@ -1435,8 +1435,8 @@ object ChatController { return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean): GroupInfo? { - val r = sendCmd(rh, CC.APIConnectPreparedGroup(groupId, incognito)) + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { + val r = sendCmd(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") if (!(networkErrorAlert(r))) { @@ -1631,9 +1631,9 @@ object ChatController { return null } - suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { - val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } - val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) + suspend fun apiSetUserAddressSettings(rh: Long?, settings: AddressSettings): UserContactLinkRec? { + val userId = kotlin.runCatching { currentUserId("apiSetUserAddressSettings") }.getOrElse { return null } + val r = sendCmd(rh, CC.ApiSetAddressSettings(userId, settings)) if (r is API.Result && r.res is CR.UserContactLinkUpdated) return r.res.contactLink if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.UserContactLinkNotFound @@ -3493,8 +3493,8 @@ sealed class CC { class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() - class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent): CC() - class APIConnectPreparedGroup(val groupId: Long, val incognito: Boolean): CC() + class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() + class APIConnectPreparedGroup(val groupId: Long, val incognito: Boolean, val msg: MsgContent?): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connLink: CreatedConnLink): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() @@ -3512,7 +3512,7 @@ sealed class CC { class ApiShowMyAddress(val userId: Long): CC() class ApiAddMyAddressShortLink(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() - class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() + class ApiSetAddressSettings(val userId: Long, val addressSettings: AddressSettings): CC() class ApiGetCallInvitations: CC() class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() class ApiRejectCall(val contact: Contact): CC() @@ -3688,8 +3688,8 @@ sealed class CC { is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" - is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)} ${msg.cmdString}" - is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)}" + is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)} ${maybeContent(msg)}" + is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)} ${maybeContent(msg)}" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" @@ -3707,7 +3707,7 @@ sealed class CC { is ApiShowMyAddress -> "/_show_address $userId" is ApiAddMyAddressShortLink -> "/_short_link_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" - is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" + is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}" is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" is ApiGetCallInvitations -> "/_call get" @@ -3880,7 +3880,7 @@ sealed class CC { is ApiShowMyAddress -> "apiShowMyAddress" is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink" is ApiSetProfileAddress -> "apiSetProfileAddress" - is ApiAddressAutoAccept -> "apiAddressAutoAccept" + is ApiSetAddressSettings -> "apiSetAddressSettings" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" is ApiGetCallInvitations -> "apiGetCallInvitations" @@ -3950,6 +3950,14 @@ sealed class CC { private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) + private fun maybeContent(mc: MsgContent?): String { + return when { + mc is MsgContent.MCText && mc.text.isEmpty() -> "" + mc != null -> " " + mc.cmdString + else -> "" + } + } + companion object { fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) { null -> "${chatType.type}${id}" @@ -5913,8 +5921,7 @@ sealed class CR { @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR() - @Serializable @SerialName("newPreparedContact") class NewPreparedContact(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("newPreparedGroup") class NewPreparedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR() @Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR() @Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @@ -6101,8 +6108,7 @@ sealed class CR { is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionUserChanged -> "ConnectionUserChanged" is CRConnectionPlan -> "connectionPlan" - is NewPreparedContact -> "newPreparedContact" - is NewPreparedGroup -> "newPreparedGroup" + is NewPreparedChat -> "newPreparedChat" is ContactUserChanged -> "contactUserChanged" is GroupUserChanged -> "groupUserChanged" is SentConfirmation -> "sentConfirmation" @@ -6279,8 +6285,7 @@ sealed class CR { is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) is CRConnectionPlan -> withUser(user, "connLink: ${json.encodeToString(connLink)}\nconnectionPlan: ${json.encodeToString(connectionPlan)}") - is NewPreparedContact -> withUser(user, json.encodeToString(contact)) - is NewPreparedGroup -> withUser(user, json.encodeToString(groupInfo)) + is NewPreparedChat -> withUser(user, json.encodeToString(chat)) is ContactUserChanged -> withUser(user, "fromContact: ${json.encodeToString(fromContact)}\nnewUserId: ${json.encodeToString(newUser.userId)}\ntoContact: ${json.encodeToString(toContact)}") is GroupUserChanged -> withUser(user, "fromGroup: ${json.encodeToString(fromGroup)}\nnewUserId: ${json.encodeToString(newUser.userId)}\ntoGroup: ${json.encodeToString(toGroup)}") is SentConfirmation -> withUser(user, json.encodeToString(connection)) @@ -6621,25 +6626,18 @@ enum class RatchetSyncState { data class UserContactLinkRec( val connLinkContact: CreatedConnLink, val shortLinkDataSet: Boolean, - val autoAccept: AutoAccept? = null + val addressSettings: AddressSettings ) @Serializable -class AutoAccept(val businessAddress: Boolean, val acceptIncognito: Boolean, val autoReply: MsgContent?) { - companion object { - fun cmdString(autoAccept: AutoAccept?): String { - if (autoAccept == null) return "off" - var s = "on" - if (autoAccept.acceptIncognito) { - s += " incognito=on" - } else if (autoAccept.businessAddress) { - s += " business" - } - val msg = autoAccept.autoReply ?: return s - return s + " " + msg.cmdString - } - } -} +data class AddressSettings( + val businessAddress: Boolean, + val autoAccept: AutoAccept?, + val autoReply: MsgContent? +) + +@Serializable +data class AutoAccept(val acceptIncognito: Boolean) @Serializable data class GroupLink( @@ -6892,6 +6890,8 @@ sealed class StoreError { is UserContactLinkNotFound -> "userContactLinkNotFound" is ContactRequestNotFound -> "contactRequestNotFound $contactRequestId" is ContactRequestNotFoundByName -> "contactRequestNotFoundByName $contactName" + is InvalidContactRequestEntity -> "invalidContactRequestEntity $contactRequestId" + is InvalidBusinessChatContactRequest -> "invalidBusinessChatContactRequest" is GroupNotFound -> "groupNotFound $groupId" is GroupNotFoundByName -> "groupNotFoundByName $groupName" is GroupMemberNameNotFound -> "groupMemberNameNotFound $groupId $groupMemberName" @@ -6968,6 +6968,8 @@ sealed class StoreError { @Serializable @SerialName("userContactLinkNotFound") object UserContactLinkNotFound: StoreError() @Serializable @SerialName("contactRequestNotFound") class ContactRequestNotFound(val contactRequestId: Long): StoreError() @Serializable @SerialName("contactRequestNotFoundByName") class ContactRequestNotFoundByName(val contactName: String): StoreError() + @Serializable @SerialName("invalidContactRequestEntity") class InvalidContactRequestEntity(val contactRequestId: Long): StoreError() + @Serializable @SerialName("invalidBusinessChatContactRequest") object InvalidBusinessChatContactRequest: StoreError() @Serializable @SerialName("groupNotFound") class GroupNotFound(val groupId: Long): StoreError() @Serializable @SerialName("groupNotFoundByName") class GroupNotFoundByName(val groupName: String): StoreError() @Serializable @SerialName("groupMemberNameNotFound") class GroupMemberNameNotFound(val groupId: Long, val groupMemberName: String): StoreError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 522ba9ac40..6c7e775397 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -177,15 +177,10 @@ fun ChatView( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if ( - chatInfo is ChatInfo.Direct - && !chatInfo.contact.sndReady - && chatInfo.contact.active - && !chatInfo.contact.sendMsgToConnect - && !chatInfo.contact.nextAcceptContactRequest - ) { + val connectingText = connectingText(chatInfo) + if (connectingText != null) { Text( - generalGetString(MR.strings.contact_connection_pending), + connectingText, Modifier.padding(top = 4.dp), fontSize = 14.sp, color = MaterialTheme.colors.secondary @@ -714,6 +709,34 @@ fun ChatView( } } +private fun connectingText(chatInfo: ChatInfo): String? { + return when (chatInfo) { + is ChatInfo.Direct -> + if ( + !chatInfo.contact.sndReady + && chatInfo.contact.active + && !chatInfo.contact.sendMsgToConnect + && !chatInfo.contact.nextAcceptContactRequest + ) { + if (chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { + generalGetString(MR.strings.contact_should_accept) + } else { + generalGetString(MR.strings.contact_connection_pending) + } + } else { + null + } + + is ChatInfo.Group -> + when (chatInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) // TODO [short links] add member status to show transition from prepared group to started connection earlier? + else -> null + } + + else -> null + } +} + fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) { withBGApi { if (chatInfo is ChatInfo.Direct) { @@ -1705,7 +1728,7 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = shouldShowAvatar(item, listItem.nextItem) + val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } val itemSeparation: ItemSeparation val prevItemSeparationLargeGap: Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt new file mode 100644 index 0000000000..db02425d1f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -0,0 +1,281 @@ +package chat.simplex.common.views.chat + +import TextIconSpaced +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.IncognitoOptionImage +import chat.simplex.common.views.usersettings.IncognitoView +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +private val USER_ROW_AVATAR_SIZE = 42.dp +private val USER_ROW_VERTICAL_PADDING = 8.dp +private val USER_PICKER_ROW_SIZE = USER_ROW_AVATAR_SIZE + (USER_ROW_VERTICAL_PADDING * 2f) +private val MAX_USER_PICKER_HEIGHT = (USER_PICKER_ROW_SIZE * 4) + (USER_ROW_AVATAR_SIZE + USER_ROW_VERTICAL_PADDING - 4.dp) + +@Composable +fun ComposeContextProfilePickerView( + rhId: Long?, + chat: Chat, + currentUser: User +) { + val selectedUser = remember { mutableStateOf(currentUser) } + val incognitoDefault = chatModel.controller.appPrefs.incognito.get() + val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden } + val listExpanded = remember { mutableStateOf(false) } + + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val isVisible = remember { mutableStateOf(false) } + val offsetY = remember { Animatable(maxHeightInPx) } + + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = contextUserPickerAnimSpec() + ) + } + } + + @Composable + fun ExpandCollapseChevron() { + if (listExpanded.value) { + Icon( + painterResource( + MR.images.ic_chevron_down + ), + contentDescription = null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.secondary, + ) + } else { + Icon( + painterResource( + MR.images.ic_chevron_up + ), + contentDescription = null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.secondary, + ) + } + } + + fun changeProfile(newUser: User) { + withApi { + if (chat.chatInfo is ChatInfo.Direct) { + val updatedContact = chatModel.controller.apiChangePreparedContactUser(rhId, chat.chatInfo.contact.contactId, newUser.userId) + if (updatedContact != null) { + selectedUser.value = newUser + chatModel.controller.appPrefs.incognito.set(false) + listExpanded.value = false + chatModel.chatsContext.updateContact(rhId, updatedContact) + } + } else if (chat.chatInfo is ChatInfo.Group) { + val updatedGroup = chatModel.controller.apiChangePreparedGroupUser(rhId, chat.chatInfo.groupInfo.groupId, newUser.userId) + if (updatedGroup != null) { + selectedUser.value = newUser + chatModel.controller.appPrefs.incognito.set(false) + listExpanded.value = false + chatModel.chatsContext.updateGroup(rhId, updatedGroup) + } + } + chatModel.controller.changeActiveUser_( + rhId = newUser.remoteHostId, + toUserId = newUser.userId, + viewPwd = null, + keepingChatId = chat.id + ) + if (chatModel.currentUser.value?.userId != newUser.userId) { + AlertManager.shared.showAlertMsg(generalGetString( + MR.strings.switching_profile_error_title), + String.format(generalGetString(MR.strings.switching_profile_error_message), newUser.chatViewName) + ) + } + } + } + + @Composable + fun ProfilePickerUserOption(user: User) { + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .clickable(onClick = { + if (selectedUser.value.userId == user.userId) { + if (!incognitoDefault) { + listExpanded.value = !listExpanded.value + } else { + chatModel.controller.appPrefs.incognito.set(false) + listExpanded.value = false + } + } else { + changeProfile(user) + } + }) + .padding(horizontal = DEFAULT_PADDING_HALF, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage(size = USER_ROW_AVATAR_SIZE, image = user.image) + TextIconSpaced(false) + Text( + user.chatViewName, + modifier = Modifier.align(Alignment.CenterVertically), + fontWeight = if (selectedUser.value.userId == user.userId && !incognitoDefault) FontWeight.Medium else FontWeight.Normal + ) + + Spacer(Modifier.weight(1f)) + + if (selectedUser.value.userId == user.userId && !incognitoDefault) { + ExpandCollapseChevron() + } + } + } + + @Composable + fun IncognitoOption() { + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .clickable(onClick = { + if (incognitoDefault) { + listExpanded.value = !listExpanded.value + } else { + chatModel.controller.appPrefs.incognito.set(true) + listExpanded.value = false + } + }) + .padding(horizontal = DEFAULT_PADDING_HALF, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IncognitoOptionImage() + TextIconSpaced(false) + Text( + stringResource(MR.strings.incognito), + modifier = Modifier.align(Alignment.CenterVertically), + fontWeight = if (incognitoDefault) FontWeight.Medium else FontWeight.Normal + ) + Spacer(Modifier.padding(6.dp)) + Column(Modifier + .size(48.dp) + .clip(CircleShape) + .clickable( + onClick = { + if (ModalManager.end.isLastModalOpen(ModalViewId.CONTEXT_USER_PICKER_INCOGNITO)) { + ModalManager.end.closeModal() + } else { + ModalManager.end.showModal(id = ModalViewId.CONTEXT_USER_PICKER_INCOGNITO) { IncognitoView() } + } + } + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_info), + stringResource(MR.strings.incognito), + tint = MaterialTheme.colors.primary + ) + } + + Spacer(Modifier.weight(1f)) + + if (incognitoDefault) { + ExpandCollapseChevron() + } + } + } + + @Composable + fun ProfilePicker() { + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_USER_PICKER_HEIGHT) + .background(MaterialTheme.colors.surface), + reverseLayout = true, + maxHeight = remember { mutableStateOf(MAX_USER_PICKER_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + val otherUsers = users.filter { u -> u.userId != selectedUser.value.userId }.sortedByDescending { it.activeOrder } + + if (incognitoDefault) { + item { + IncognitoOption() + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + ) + ) + ProfilePickerUserOption(selectedUser.value) + } + } else { + item { + ProfilePickerUserOption(selectedUser.value) + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + ) + ) + IncognitoOption() + } + } + + items(otherUsers, key = { it.userId }) { user -> + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + ) + ) + ProfilePickerUserOption(user) + } + } + } + + @Composable + fun CurrentSelection() { + Column( + Modifier + .background(MaterialTheme.colors.surface), + ) { + Text( + generalGetString(MR.strings.context_user_picker_your_profile), + Modifier.padding(horizontal = 14.dp).padding(top = 8.dp), + color = MaterialTheme.colors.secondary + ) + + if (incognitoDefault) { + IncognitoOption() + } else { + ProfilePickerUserOption(selectedUser.value) + } + } + } + + if (listExpanded.value) { + ProfilePicker() + } else { + CurrentSelection() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 57373c95a3..62b160e595 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -511,25 +511,36 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() + chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } } suspend fun sendConnectPreparedContact() { val mc = checkLinkPreview() - // TODO [short links] use incognito default (incognito choice will be available via context profile picker) - val contact = chatModel.controller.apiConnectPreparedContact(chat.remoteHostId, chat.chatInfo.apiId, incognito = false, msg = mc) + val contact = chatModel.controller.apiConnectPreparedContact( + rh = chat.remoteHostId, + contactId = chat.chatInfo.apiId, + incognito = chatModel.controller.appPrefs.incognito.get(), + msg = mc + ) if (contact != null) { withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() + chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } } - suspend fun connectPreparedGroup() { - // TODO [short links] use incognito default (incognito choice will be available via context profile picker) - val groupInfo = chatModel.controller.apiConnectPreparedGroup(chat.remoteHostId, chat.chatInfo.apiId, incognito = false) + suspend fun sendConnectPreparedGroup() { + val mc = checkLinkPreview() + val groupInfo = chatModel.controller.apiConnectPreparedGroup( + rh = chat.remoteHostId, + groupId = chat.chatInfo.apiId, + incognito = chatModel.controller.appPrefs.incognito.get(), + msg = mc + ) if (groupInfo != null) { withContext(Dispatchers.Main) { chatsCtx.updateGroup(chat.remoteHostId, groupInfo) @@ -548,7 +559,7 @@ fun ComposeView( } else if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextConnectPrepared) { sendConnectPreparedContact() } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { - connectPreparedGroup() + sendConnectPreparedGroup() } } }) { @@ -1071,6 +1082,20 @@ fun ComposeView( } // TODO ^^^ (this shouldn't be here) + val currentUser = chatModel.currentUser.value + if (( + (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextConnectPrepared) + || (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) + ) + && currentUser != null + ) { + ComposeContextProfilePickerView( + rhId = rhId, + chat = chat, + currentUser = currentUser + ) + } + if ( chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextAcceptContactRequest diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 1a7192b22d..74069f25f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -592,7 +592,9 @@ fun ModalData.GroupChatInfoLayout( } } item { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + if (!groupInfo.nextConnectPrepared) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + } SectionView { ClearChatButton(clearChat) if (groupInfo.canDelete) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 63ab0440f2..8831db2cea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -137,45 +137,82 @@ fun ChatPreviewView( fun chatPreviewTitle() { val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } when (cInfo) { - is ChatInfo.Direct -> - Row(verticalAlignment = Alignment.CenterVertically) { - if (cInfo.contact.verified) { - VerifiedIcon() + is ChatInfo.Direct -> { + if (cInfo.contact.verified) { + VerifiedIcon() + } + val color = if (deleting) + MaterialTheme.colors.secondary + else if (cInfo.contact.nextAcceptContactRequest || cInfo.contact.sendMsgToConnect) { + MaterialTheme.colors.primary + } else if (!cInfo.contact.sndReady) { + MaterialTheme.colors.secondary + } else { + Color.Unspecified + } + chatPreviewTitleText(color = color) + } + is ChatInfo.Group -> { + val color = if (deleting) { + MaterialTheme.colors.secondary + } else { + when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemInvited -> if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary + GroupMemberStatus.MemAccepted, GroupMemberStatus.MemRejected -> MaterialTheme.colors.secondary + else -> if (cInfo.groupInfo.nextConnectPrepared) MaterialTheme.colors.primary else Color.Unspecified } - chatPreviewTitleText( - if (deleting) - MaterialTheme.colors.secondary - else if (cInfo.contact.nextAcceptContactRequest) { - MaterialTheme.colors.primary - } else { - Color.Unspecified - } - ) - } - is ChatInfo.Group -> - when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText( - if (inProgress || deleting) - MaterialTheme.colors.secondary - else - if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary - ) - GroupMemberStatus.MemAccepted, GroupMemberStatus.MemRejected -> chatPreviewTitleText(MaterialTheme.colors.secondary) - else -> chatPreviewTitleText( - if (deleting) - MaterialTheme.colors.secondary - else - Color.Unspecified - ) } + chatPreviewTitleText(color = color) + } else -> chatPreviewTitleText() } } + @Composable + fun chatPreviewInfoText(): Pair? { + return when (cInfo) { + is ChatInfo.Direct -> + if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) { + stringResource(MR.strings.contact_tap_to_connect) to MaterialTheme.colors.primary + } else if (cInfo.contact.sendMsgToConnect) { + stringResource(MR.strings.open_to_connect) to Color.Unspecified + } else if (cInfo.contact.nextAcceptContactRequest) { + stringResource(MR.strings.open_to_accept) to Color.Unspecified + } else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null && cInfo.contact.active) { + if (cInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { + stringResource(MR.strings.contact_should_accept) to Color.Unspecified + } else { + stringResource(MR.strings.contact_connection_pending) to Color.Unspecified + } + } else { + null + } + + is ChatInfo.Group -> + if (cInfo.groupInfo.nextConnectPrepared) { + stringResource(MR.strings.group_preview_open_to_join) to Color.Unspecified + } else { + when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemRejected -> stringResource(MR.strings.group_preview_rejected) to Color.Unspecified + GroupMemberStatus.MemInvited -> groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo) to Color.Unspecified + GroupMemberStatus.MemAccepted -> stringResource(MR.strings.group_connection_pending) to Color.Unspecified + GroupMemberStatus.MemPendingReview, GroupMemberStatus.MemPendingApproval -> + stringResource(MR.strings.reviewed_by_admins) to MaterialTheme.colors.secondary + else -> null + } + } + + else -> null + } + } + @Composable fun chatPreviewText() { + val previewText = chatPreviewInfoText() val ci = chat.chatItems.lastOrNull() - if (ci != null) { + if (ci?.content?.hasMsgContent != true && previewText != null) { + Text(previewText.first, color = previewText.second) + } else if (ci != null) { if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = when { @@ -195,6 +232,7 @@ fun ChatPreviewView( append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } + else -> null } @@ -203,7 +241,7 @@ fun ChatPreviewView( formattedText, sender = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null - cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName + cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null }, mentions = ci.mentions, @@ -227,33 +265,6 @@ fun ChatPreviewView( prefix = prefix ) } - } else { - when (cInfo) { - is ChatInfo.Direct -> - if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) { - Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary) - } else if (cInfo.contact.nextAcceptContactRequest) { - Text(stringResource(MR.strings.hold_or_open_to_connect)) - } else if (cInfo.contact.sendMsgToConnect) { - Text(stringResource(MR.strings.member_contact_send_direct_message)) - } else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null && cInfo.contact.active) { - Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) - } - is ChatInfo.Group -> - if (cInfo.groupInfo.nextConnectPrepared) { - Text(stringResource(MR.strings.group_preview_open_to_join)) - } else { - when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemRejected -> Text(stringResource(MR.strings.group_preview_rejected)) - GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo)) - GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary) - GroupMemberStatus.MemPendingReview, GroupMemberStatus.MemPendingApproval -> - Text(stringResource(MR.strings.reviewed_by_admins), color = MaterialTheme.colors.secondary) - else -> {} - } - } - else -> {} - } } } @@ -346,7 +357,10 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { - if (cInfo.contact.active && cInfo.contact.activeConn != null) { + if ( + cInfo.contact.active && + (cInfo.contact.activeConn?.connStatus == ConnStatus.Ready || cInfo.contact.activeConn?.connStatus == ConnStatus.SndReady) + ) { val descr = contactNetworkStatus?.statusString when (contactNetworkStatus) { is NetworkStatus.Connected -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index c02a35891b..90e23e1cb5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -40,7 +40,7 @@ fun ContactPreviewView( contactType == ContactType.CARD -> MaterialTheme.colors.primary contactType == ContactType.CONTACT_WITH_REQUEST -> MaterialTheme.colors.primary contactType == ContactType.REQUEST -> MaterialTheme.colors.primary - contactType == ContactType.RECENT && chat.chatInfo.incognito -> Indigo + contactType == ContactType.RECENT -> if (chat.chatInfo.nextConnect) MaterialTheme.colors.primary else Color.Unspecified else -> Color.Unspecified } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 078cdde9da..75f61dda04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -11,3 +11,5 @@ fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) + +fun contextUserPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index c3e97dd27b..72fab4990b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -31,12 +31,7 @@ import kotlin.math.max fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) { val icon = when (chatInfo) { - is ChatInfo.Group -> - when (chatInfo.groupInfo.businessChat?.chatType) { - BusinessChatType.Business -> MR.images.ic_work_filled_padded - BusinessChatType.Customer -> MR.images.ic_account_circle_filled - null -> MR.images.ic_supervised_user_circle_filled - } + is ChatInfo.Group -> chatInfo.groupInfo.chatIconName is ChatInfo.Local -> MR.images.ic_folder_filled else -> MR.images.ic_account_circle_filled } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index af207d1381..21520f5424 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -85,7 +85,8 @@ class ModalData(val keyboardCoversBar: Boolean = true) { } enum class ModalViewId { - SECONDARY_CHAT + SECONDARY_CHAT, + CONTEXT_USER_PICKER_INCOGNITO } class ModalManager(private val placement: ModalPlacement? = null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index e722d0223c..f6562acc72 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -382,6 +382,13 @@ fun askCurrentOrIncognitoProfileAlert( ) } +fun openChat_(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, chat: Chat) { + withBGApi { + close?.invoke() + openChat(secondaryChatsCtx = null, rhId, chat.chatInfo) + } +} + fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) { withBGApi { val c = chatModel.getContactChat(contact.contactId) @@ -466,16 +473,22 @@ fun showPrepareContactAlert( ) { AlertManager.privacySensitive.showOpenChatAlert( profileName = contactShortLinkData.profile.displayName, - profileImage = { ProfileImage(size = 72.dp, image = contactShortLinkData.profile.image) }, + profileImage = { + ProfileImage( + size = 72.dp, + image = contactShortLinkData.profile.image, + icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + ) + }, + confirmText = generalGetString(MR.strings.connect_plan_open_chat), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val contact = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData) - if (contact != null) { + val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData) + if (chat != null) { withContext(Dispatchers.Main) { - val chatInfo = ChatInfo.Direct(contact) - ChatController.chatModel.chatsContext.addChat(Chat(rhId, chatInfo, chatItems = listOf())) - openKnownContact(chatModel, rhId, close, contact) + ChatController.chatModel.chatsContext.addChat(chat) + openChat_(chatModel, rhId, close, chat) } } cleanup?.invoke() @@ -497,15 +510,15 @@ fun showPrepareGroupAlert( AlertManager.privacySensitive.showOpenChatAlert( profileName = groupShortLinkData.groupProfile.displayName, profileImage = { ProfileImage(size = 72.dp, image = groupShortLinkData.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled) }, + confirmText = generalGetString(MR.strings.connect_plan_open_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val groupInfo = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) - if (groupInfo != null) { + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) + if (chat != null) { withContext(Dispatchers.Main) { - val chatInfo = ChatInfo.Group(groupInfo, groupChatScope = null) - ChatController.chatModel.chatsContext.addChat(Chat(rhId, chatInfo, chatItems = listOf())) - openKnownGroup(chatModel, rhId, close, groupInfo) + ChatController.chatModel.chatsContext.addChat(chat) + openChat_(chatModel, rhId, close, chat) } } cleanup?.invoke() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index b0a2ce52d4..6c8f9f944c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -379,14 +379,7 @@ fun ActiveProfilePicker( } }, image = { - Spacer(Modifier.width(8.dp)) - Icon( - painterResource(MR.images.ic_theater_comedy_filled), - contentDescription = stringResource(MR.strings.incognito), - Modifier.size(32.dp), - tint = Indigo, - ) - Spacer(Modifier.width(2.dp)) + IncognitoOptionImage() }, onInfo = { ModalManager.start.showModal { IncognitoView() } }, ) @@ -550,6 +543,18 @@ fun ToggleShortLinkButton(short: MutableState) { ) } +@Composable +fun IncognitoOptionImage() { + Spacer(Modifier.width(8.dp)) + Icon( + painterResource(MR.images.ic_theater_comedy_filled), + contentDescription = stringResource(MR.strings.incognito), + Modifier.size(32.dp), + tint = Indigo, + ) + Spacer(Modifier.width(2.dp)) +} + @Composable fun AddContactLearnMoreButton() { IconButton( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 2ec9ebabb7..1b60c42f01 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -48,7 +48,7 @@ fun UserAddressView( progressIndicator = true withBGApi { try { - val u = chatModel.controller.apiSetProfileAddress(user?.value?.remoteHostId, on) + val u = chatModel.controller.apiSetProfileAddress(user.value?.remoteHostId, on) if (u != null) { chatModel.updateUser(u) } @@ -65,7 +65,11 @@ fun UserAddressView( progressIndicator = true val connReqContact = chatModel.controller.apiCreateUserAddress(user.value?.remoteHostId) if (connReqContact != null) { - chatModel.userAddress.value = UserContactLinkRec(connReqContact, shortLinkDataSet = connReqContact.connShortLink != null) + chatModel.userAddress.value = UserContactLinkRec( + connReqContact, + shortLinkDataSet = connReqContact.connShortLink != null, + addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null) + ) AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.share_address_with_contacts_question), @@ -139,7 +143,7 @@ fun UserAddressView( onConfirm = { progressIndicator = true withBGApi { - val u = chatModel.controller.apiDeleteUserAddress(user?.value?.remoteHostId) + val u = chatModel.controller.apiDeleteUserAddress(user.value?.remoteHostId) if (u != null) { chatModel.userAddress.value = null chatModel.updateUser(u) @@ -151,12 +155,12 @@ fun UserAddressView( destructive = true, ) }, - saveAas = { aas: AutoAcceptState, savedAAS: MutableState -> + saveAddressSettings = { settings: AddressSettingsState, savedSettings: MutableState -> withBGApi { - val address = chatModel.controller.userAddressAutoAccept(user?.value?.remoteHostId, aas.autoAccept) + val address = chatModel.controller.apiSetUserAddressSettings(user.value?.remoteHostId, settings.addressSettings) if (address != null) { chatModel.userAddress.value = address - savedAAS.value = aas + savedSettings.value = settings } } }, @@ -198,7 +202,7 @@ private fun UserAddressLayout( sendEmail: (UserContactLinkRec) -> Unit, setProfileAddress: (Boolean) -> Unit, deleteAddress: () -> Unit, - saveAas: (AutoAcceptState, MutableState) -> Unit, + saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, ) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) @@ -222,8 +226,8 @@ private fun UserAddressLayout( LearnMoreButton(learnMore) } } else { - val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } - val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } + val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } + val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } val showShortLink = remember { mutableStateOf(true) } SectionViewWithButton( @@ -233,20 +237,20 @@ private fun UserAddressLayout( SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) ShareAddressButton { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } // ShareViaEmailButton { sendEmail(userAddress) } - BusinessAddressToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } - AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAas) + BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) if (userAddress.connLinkContact.connShortLink == null) { AddShortLinkButton(text = stringResource(MR.strings.add_short_link), showAddShortLinkAlert) } else if (!userAddress.shortLinkDataSet) { AddShortLinkButton(text = stringResource(MR.strings.share_profile_via_link), showAddShortLinkAlert) } - if (autoAcceptState.value.business) { + if (addressSettingsState.value.businessAddress) { SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } } - SectionDividerSpaced(maxTopPadding = autoAcceptState.value.business) + SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { CreateOneTimeLinkButton() } @@ -330,14 +334,14 @@ private fun AddressSettingsButton( userAddress: UserContactLinkRec, shareViaProfile: MutableState, setProfileAddress: (Boolean) -> Unit, - saveAas: (AutoAcceptState, MutableState) -> Unit, + saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, ) { SettingsActionItem( painterResource(MR.images.ic_settings), stringResource(MR.strings.address_settings), click = { ModalManager.start.showCustomModal { close -> - UserAddressSettings(user, userAddress, shareViaProfile, setProfileAddress, saveAas, close = close) + UserAddressSettings(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings, close = close) } } ) @@ -349,20 +353,20 @@ private fun ModalData.UserAddressSettings( userAddress: UserContactLinkRec, shareViaProfile: MutableState, setProfileAddress: (Boolean) -> Unit, - saveAas: (AutoAcceptState, MutableState) -> Unit, + saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, close: () -> Unit ) { - val autoAcceptState = remember { stateGetOrPut("autoAcceptState") { (AutoAcceptState(userAddress)) } } - val autoAcceptStateSaved = remember { stateGetOrPut("autoAcceptStateSaved") { (autoAcceptState.value) } } + val addressSettingsState = remember { stateGetOrPut("autoAcceptState") { (AddressSettingsState(userAddress.addressSettings)) } } + val savedAddressSettingsState = remember { stateGetOrPut("autoAcceptStateSaved") { (addressSettingsState.value) } } - fun onClose(close: () -> Unit): Boolean = if (autoAcceptState.value == autoAcceptStateSaved.value) { + fun onClose(close: () -> Unit): Boolean = if (addressSettingsState.value == savedAddressSettingsState.value) { chatModel.centerPanelBackgroundClickHandler = null close() false } else { showUnsavedChangesAlert( save = { - saveAas(autoAcceptState.value, autoAcceptStateSaved) + saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) chatModel.centerPanelBackgroundClickHandler = null close() }, @@ -391,12 +395,20 @@ private fun ModalData.UserAddressSettings( ) { SectionView { ShareWithContactsButton(shareViaProfile, setProfileAddress) - AutoAcceptToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } + AutoAcceptToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + if (!chatModel.addressShortLinkDataSet && !addressSettingsState.value.businessAddress) { + AcceptIncognitoToggle(addressSettingsState) + } } + SectionDividerSpaced() - if (autoAcceptState.value.enable) { - SectionDividerSpaced() - AutoAcceptSection(autoAcceptState, autoAcceptStateSaved, saveAas) + SectionView(stringResource(MR.strings.address_welcome_message).uppercase()) { + AutoReplyEditor(addressSettingsState) + } + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + saveAddressSettingsButton(addressSettingsState.value == savedAddressSettingsState.value) { + saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } } } @@ -444,33 +456,53 @@ fun ShareWithContactsButton(shareViaProfile: MutableState, setProfileAd } @Composable -private fun BusinessAddressToggle(autoAcceptState: MutableState, saveAas: (AutoAcceptState) -> Unit) { +private fun BusinessAddressToggle(addressSettingsState: MutableState, saveAddressSettings: (AddressSettingsState) -> Unit) { PreferenceToggleWithIcon( stringResource(MR.strings.business_address), painterResource(MR.images.ic_work), - checked = autoAcceptState.value.business, - ) { ba -> - autoAcceptState.value = if (ba) - AutoAcceptState(enable = true, incognito = false, business = true, autoAcceptState.value.welcomeText) + checked = addressSettingsState.value.businessAddress, + ) { businessToggle -> + addressSettingsState.value = if (businessToggle) + AddressSettingsState( + businessAddress = true, + autoAccept = true, + autoAcceptIncognito = false, + autoReply = addressSettingsState.value.autoReply + ) else - AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, business = false, autoAcceptState.value.welcomeText) - saveAas(autoAcceptState.value) + AddressSettingsState( + businessAddress = false, + autoAccept = addressSettingsState.value.autoAccept, + autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito, + autoReply = addressSettingsState.value.autoReply + ) + saveAddressSettings(addressSettingsState.value) } } @Composable -private fun AutoAcceptToggle(autoAcceptState: MutableState, saveAas: (AutoAcceptState) -> Unit) { +private fun AutoAcceptToggle(addressSettingsState: MutableState, saveAddressSettings: (AddressSettingsState) -> Unit) { PreferenceToggleWithIcon( stringResource(MR.strings.auto_accept_contact), painterResource(MR.images.ic_check), - disabled = autoAcceptState.value.business, - checked = autoAcceptState.value.enable - ) { - autoAcceptState.value = if (!it) - AutoAcceptState() + disabled = addressSettingsState.value.businessAddress, + checked = addressSettingsState.value.autoAccept + ) { autoAcceptToggle -> + addressSettingsState.value = if (autoAcceptToggle) + AddressSettingsState( + businessAddress = addressSettingsState.value.businessAddress, + autoAccept = true, + autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito, + autoReply = addressSettingsState.value.autoReply + ) else - AutoAcceptState(it, autoAcceptState.value.incognito, autoAcceptState.value.business, autoAcceptState.value.welcomeText) - saveAas(autoAcceptState.value) + AddressSettingsState( + businessAddress = false, + autoAccept = false, + autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito, + autoReply = addressSettingsState.value.autoReply + ) + saveAddressSettings(addressSettingsState.value) } } @@ -485,103 +517,110 @@ private fun DeleteAddressButton(onClick: () -> Unit) { ) } -private class AutoAcceptState { - var enable: Boolean = false +private class AddressSettingsState { + var businessAddress: Boolean = false private set - var incognito: Boolean = false + var autoAccept: Boolean = false private set - var business: Boolean = false + var autoAcceptIncognito: Boolean = false private set - var welcomeText: String = "" + var autoReply: String = "" private set - constructor(enable: Boolean = false, incognito: Boolean = false, business: Boolean = false, welcomeText: String = "") { - this.enable = enable - this.incognito = incognito - this.business = business - this.welcomeText = welcomeText + constructor(businessAddress: Boolean = false, autoAccept: Boolean = false, autoAcceptIncognito: Boolean = false, autoReply: String = "") { + this.businessAddress = businessAddress + this.autoAccept = autoAccept + this.autoAcceptIncognito = autoAcceptIncognito + this.autoReply = autoReply } - constructor(contactLink: UserContactLinkRec) { - contactLink.autoAccept?.let { aa -> - enable = true - incognito = aa.acceptIncognito - business = aa.businessAddress - aa.autoReply?.let { msg -> - welcomeText = msg.text - } ?: run { - welcomeText = "" - } - } + constructor(settings: AddressSettings) { + this.businessAddress = settings.businessAddress + this.autoAccept = settings.autoAccept != null + this.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true + this.autoReply = settings.autoReply?.text ?: "" } - val autoAccept: AutoAccept? + val addressSettings: AddressSettings get() { - if (enable) { - var autoReply: MsgContent? = null - val s = welcomeText.trim() - if (s != "") { - autoReply = MsgContent.MCText(s) - } - return AutoAccept(business, incognito, autoReply) - } - return null + return AddressSettings( + businessAddress = this.businessAddress, + autoAccept = if (this.autoAccept) AutoAccept(acceptIncognito = this.autoAcceptIncognito) else null, + autoReply = if (this.autoReply.isEmpty()) null else MsgContent.MCText(this.autoReply) + ) } override fun equals(other: Any?): Boolean { - if (other !is AutoAcceptState) return false - return this.enable == other.enable && this.incognito == other.incognito && this.business == other.business && this.welcomeText == other.welcomeText + if (other !is AddressSettingsState) return false + return ( + this.businessAddress == other.businessAddress + && this.autoAccept == other.autoAccept + && this.autoAcceptIncognito == other.autoAcceptIncognito + && this.autoReply == other.autoReply + ) } override fun hashCode(): Int { - var result = enable.hashCode() - result = 31 * result + incognito.hashCode() - result = 31 * result + business.hashCode() - result = 31 * result + welcomeText.hashCode() + var result = businessAddress.hashCode() + result = 31 * result + autoAccept.hashCode() + result = 31 * result + autoAcceptIncognito.hashCode() + result = 31 * result + autoReply.hashCode() return result } } @Composable private fun AutoAcceptSection( - autoAcceptState: MutableState, - savedAutoAcceptState: MutableState, - saveAas: (AutoAcceptState, MutableState) -> Unit + addressSettingsState: MutableState, + savedAddressSettingsStatee: MutableState, + saveAddressSettings: (AddressSettingsState, MutableState) -> Unit ) { SectionView(stringResource(MR.strings.auto_accept_contact).uppercase()) { - if (!chatModel.addressShortLinkDataSet && !autoAcceptState.value.business) { - AcceptIncognitoToggle(autoAcceptState) + if (!chatModel.addressShortLinkDataSet && !addressSettingsState.value.businessAddress) { + AcceptIncognitoToggle(addressSettingsState) + } + AutoReplyEditor(addressSettingsState) + saveAddressSettingsButton(addressSettingsState.value == savedAddressSettingsStatee.value) { + saveAddressSettings(addressSettingsState.value, savedAddressSettingsStatee) } - WelcomeMessageEditor(autoAcceptState) - SaveAASButton(autoAcceptState.value == savedAutoAcceptState.value) { saveAas(autoAcceptState.value, savedAutoAcceptState) } } } @Composable -private fun AcceptIncognitoToggle(autoAcceptState: MutableState) { +private fun AcceptIncognitoToggle(addressSettingsState: MutableState) { PreferenceToggleWithIcon( stringResource(MR.strings.accept_contact_incognito_button), - if (autoAcceptState.value.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), - if (autoAcceptState.value.incognito) Indigo else MaterialTheme.colors.secondary, - checked = autoAcceptState.value.incognito, - ) { - autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, it, autoAcceptState.value.business, autoAcceptState.value.welcomeText) + if (addressSettingsState.value.autoAcceptIncognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), + if (addressSettingsState.value.autoAcceptIncognito) Indigo else MaterialTheme.colors.secondary, + checked = addressSettingsState.value.autoAcceptIncognito, + ) { incognitoToggle -> + addressSettingsState.value = AddressSettingsState( + businessAddress = addressSettingsState.value.businessAddress, + autoAccept = addressSettingsState.value.autoAccept, + autoAcceptIncognito = incognitoToggle, + autoReply = addressSettingsState.value.autoReply + ) } } @Composable -private fun WelcomeMessageEditor(autoAcceptState: MutableState) { - val welcomeText = rememberSaveable { mutableStateOf(autoAcceptState.value.welcomeText) } - TextEditor(welcomeText, Modifier.height(100.dp), placeholder = stringResource(MR.strings.enter_welcome_message_optional)) - LaunchedEffect(welcomeText.value) { - if (welcomeText.value != autoAcceptState.value.welcomeText) { - autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, autoAcceptState.value.business, welcomeText.value) +private fun AutoReplyEditor(addressSettingsState: MutableState) { + val autoReply = rememberSaveable { mutableStateOf(addressSettingsState.value.autoReply) } + TextEditor(autoReply, Modifier.height(100.dp), placeholder = stringResource(MR.strings.enter_welcome_message_optional)) + LaunchedEffect(autoReply.value) { + if (autoReply.value != addressSettingsState.value.autoReply) { + addressSettingsState.value = AddressSettingsState( + businessAddress = addressSettingsState.value.businessAddress, + autoAccept = addressSettingsState.value.autoAccept, + autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito, + autoReply = autoReply.value + ) } } } @Composable -private fun SaveAASButton(disabled: Boolean, onClick: () -> Unit) { +private fun saveAddressSettingsButton(disabled: Boolean, onClick: () -> Unit) { SectionItemView(onClick, disabled = disabled) { Text(stringResource(MR.strings.save_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } @@ -602,7 +641,7 @@ fun PreviewUserAddressLayoutNoAddress() { showAddShortLinkAlert = {}, share = { _ -> }, deleteAddress = {}, - saveAas = { _, _ -> }, + saveAddressSettings = { _, _ -> }, setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, @@ -631,12 +670,16 @@ fun PreviewUserAddressLayoutAddressCreated() { SimpleXTheme { UserAddressLayout( user = User.sampleData, - userAddress = UserContactLinkRec(CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null), shortLinkDataSet = false), + userAddress = UserContactLinkRec( + CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null), + shortLinkDataSet = false, + addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null) + ), createAddress = {}, showAddShortLinkAlert = {}, share = { _ -> }, deleteAddress = {}, - saveAas = { _, _ -> }, + saveAddressSettings = { _, _ -> }, setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 05bae2f200..4241505daf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -13,7 +13,8 @@ You will connect to all group members. Connect Connect incognito - Open + Open chat + Open group Invalid link Please check that SimpleX link is correct. @@ -428,9 +429,9 @@ Settings connecting… send to connect - open to join - you are invited to group - join as %s + Open to join + You are invited to group + Join as %s rejected connecting… Tap to start a new chat @@ -443,7 +444,9 @@ No chats No chats found Tap to Connect - hold or open to connect + Open to connect + Open to accept + contact should accept… Connect with %1$s? Search or paste SimpleX link Tap Create SimpleX address in the menu to create it later. @@ -523,6 +526,7 @@ You can\'t send messages! contact not ready + request is sent contact deleted not synchronized contact disabled @@ -855,6 +859,9 @@ No filtered contacts Your contacts + + Your profile + Scan code Incorrect security code! @@ -1055,9 +1062,10 @@ Stop sharing address? Stop sharing Auto-accept + Welcome message Enter welcome message… (optional) Save settings? - Save auto-accept settings + Save SimpleX address settings Delete address Invite friends Let\'s talk in SimpleX Chat diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg new file mode 100644 index 0000000000..b8416ff173 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg new file mode 100644 index 0000000000..ca6a17bbde --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg @@ -0,0 +1 @@ + \ No newline at end of file