From 64a4c0c9eef5a510afa9ffef306d74a41dd17bf3 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:40:02 +0000 Subject: [PATCH] update UI, tests --- .../chat/simplex/common/model/ChatModel.kt | 13 ++++--- .../simplex/common/views/chat/ChatView.kt | 3 +- .../views/chat/group/AddGroupMembersView.kt | 4 ++- .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../views/chatlist/ShareListNavLinkView.kt | 5 ++- .../common/views/helpers/ChatInfoImage.kt | 7 +++- .../common/views/newchat/ConnectPlan.kt | 30 +++++++++------- src/Simplex/Chat/Library/Commands.hs | 33 +++++++++-------- src/Simplex/Chat/Library/Internal.hs | 13 ++++++- src/Simplex/Chat/Protocol.hs | 6 +++- tests/ChatTests/Profiles.hs | 35 +++++++++++++++++++ 11 files changed, 112 insertions(+), 39 deletions(-) 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 a28dad2bed..8cd25f81ba 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 @@ -1994,7 +1994,10 @@ data class Profile( override val localAlias : String = "", val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + // opaque badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge), + // but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof + val badge: JsonElement? = null ): NamedChat { val profileViewName: String get() { @@ -2330,7 +2333,9 @@ enum class MemberCriteria { data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, - val business: Boolean + val business: Boolean, + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + val localBadge: LocalBadge? = null ) @Serializable @@ -2779,7 +2784,7 @@ class UserContactRequest ( val contactRequestId: Long, val cReqChatVRange: VersionRange, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -2805,7 +2810,7 @@ class UserContactRequest ( contactRequestId = 1, cReqChatVRange = VersionRange(1, 1), localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) 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 f42969a73f..93bf784d7c 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 @@ -2051,7 +2051,8 @@ fun BoxScope.ChatItemsList( } Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { val member = cItem.chatDir.groupMember - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + // zIndex draws the avatar (and its badge overflow) above the message bubble tail + Box(Modifier.zIndex(1f).clickable { showMemberInfo(chatInfo.groupInfo, member) }) { MemberImage(member) } Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 9298b600e9..9bdbd7fc24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -352,7 +352,9 @@ fun ContactCheckRow( } } else null ) { - ProfileImage(size = 36.dp, contact.image) + BadgedProfileImage(36.dp, if (contact.active) contact.profile.localBadge else null) { + ProfileImage(size = 36.dp, contact.image) + } Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) Text( contact.chatViewName, 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 d749865e10..8a56c40eb2 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 @@ -389,7 +389,7 @@ fun ChatPreviewView( Box(contentAlignment = Alignment.Center) { Row { Box(contentAlignment = Alignment.BottomEnd) { - ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) + ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier, tappableBadge = true) Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { chatPreviewImageOverlayIcon() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 47668c4fb3..a40fda277d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -102,7 +102,10 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { } else if (chat.chatInfo is ChatInfo.Group) { ProfileImage(size = 42.dp, chat.chatInfo.image, icon = MR.images.ic_supervised_user_circle_filled) } else { - ProfileImage(size = 42.dp, chat.chatInfo.image) + val ct = (chat.chatInfo as? ChatInfo.Direct)?.contact + BadgedProfileImage(42.dp, if (ct?.active == true) ct.profile.localBadge else null) { + ProfileImage(size = 42.dp, chat.chatInfo.image) + } } Text( chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 02b30c1a6c..1a95c4b29e 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 @@ -38,7 +38,12 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme is ChatInfo.Direct -> chatInfo.contact.chatIconName else -> MR.images.ic_account_circle_filled } - val badge = if (chatInfo is ChatInfo.Direct) chatInfo.contact.profile.localBadge else null + // a deleted (inactive) contact shows the inactive icon instead of the badge + val badge = when { + chatInfo is ChatInfo.Direct && chatInfo.contact.active -> chatInfo.contact.profile.localBadge + chatInfo is ChatInfo.ContactRequest -> chatInfo.contactRequest.profile.localBadge + else -> null + } BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) { ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor) } 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 9fd5dd5b4a..ff96d46df0 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 @@ -468,11 +468,13 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: profileName = contact.profile.displayName, profileFullName = contact.profile.fullName, profileImage = { - ProfileImage( - size = alertProfileImageSize, - image = contact.profile.image, - icon = contact.chatIconName - ) + BadgedProfileImage(alertProfileImageSize, if (contact.active) contact.profile.localBadge else null) { + ProfileImage( + size = alertProfileImageSize, + image = contact.profile.image, + icon = contact.chatIconName + ) + } }, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), onConfirm = { @@ -624,14 +626,16 @@ fun showPrepareContactAlert( profileName = contactShortLinkData.profile.displayName, profileFullName = contactShortLinkData.profile.fullName, profileImage = { - ProfileImage( - size = alertProfileImageSize, - image = contactShortLinkData.profile.image, - icon = - if (contactShortLinkData.business) MR.images.ic_work_filled_padded - else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube - else MR.images.ic_account_circle_filled - ) + BadgedProfileImage(alertProfileImageSize, contactShortLinkData.localBadge) { + ProfileImage( + size = alertProfileImageSize, + image = contactShortLinkData.profile.image, + icon = + if (contactShortLinkData.business) MR.images.ic_work_filled_padded + else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube + else MR.images.ic_account_circle_filled + ) + } }, information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8156722c9f..324a215c12 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1942,7 +1942,8 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing + linkProfile <- presentUserBadge' incognitoProfile user $ userProfileDirect user incognitoProfile Nothing True + let userData = contactShortLinkData linkProfile Nothing userLinkData = UserInvLinkData userData -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode @@ -1964,7 +1965,7 @@ processChatCommand cxt nm = \case updatePCCIncognito db user conn (Just pId) sLnk pure $ CRConnectionIncognitoUpdated user conn' (Just incognitoProfile) (ConnNew, Just pId, False) -> do - sLnk <- updatePCCShortLinkData conn $ userProfileDirect user Nothing Nothing True + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1983,9 +1984,10 @@ processChatCommand cxt nm = \case recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let short = isJust $ connShortLink' =<< connLinkInv - userLinkData_ - | short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing - | otherwise = Nothing + userLinkData_ <- + if short + then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser (userProfileDirect newUser Nothing Nothing True) + else pure Nothing -- TODO [certs rcv] (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -2260,10 +2262,11 @@ processChatCommand cxt nm = \case Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode -- TODO [relays] relay: add identity, key to link data? - let userData - | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) - | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + userData <- + if isTrue userChatRelay + then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user (userProfileDirect user Nothing Nothing True) + let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -3762,9 +3765,9 @@ processChatCommand cxt nm = \case setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - let shortLinkProfile = userProfileDirect user Nothing Nothing True - -- TODO [short links] do not save address to server if data did not change, spinners, error handling - userData + shortLinkProfile <- presentUserBadge user $ userProfileDirect user Nothing Nothing True + -- TODO [short links] do not save address to server if data did not change, spinners, error handling + let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} @@ -4072,7 +4075,7 @@ processChatCommand cxt nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ov = verifyLinkOwner rootKey [] l sig_ invitationReqAndPlan cReq (Just l') contactSLinkData_ ov where @@ -4099,7 +4102,7 @@ processChatCommand cxt nm = \case withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ plan <- contactRequestPlan user cReq contactSLinkData_ ov @@ -4268,7 +4271,7 @@ processChatCommand cxt nm = \case contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings - contactData = ContactShortLinkData p msg business + contactData = ContactShortLinkData p msg business Nothing in encodeShortLinkData contactData relayShortLinkData :: Profile -> UserLinkData relayShortLinkData Profile {displayName, fullName, shortDescr, image} = diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3689a908eb..2e5b8ccd63 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,7 +53,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) -import Simplex.Chat.Badges (Badge (..), BadgePresHeader (..), LocalBadge (..), badgeProof) +import Simplex.Chat.Badges (Badge (..), BadgePresHeader (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -1913,6 +1913,17 @@ presentUserBadge' incognitoProfile user p = case incognitoProfile of Just _ -> pure p Nothing -> presentUserBadge user p +-- receiving side of contact/invitation link data: verify the badge proof from the link profile +-- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) +linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData +linkDataBadge cld@ContactShortLinkData {profile = Profile {badge}} = case badge of + Nothing -> pure cld + Just b@(BadgeProof _ _ info) -> do + key <- asks $ badgePublicKey . config + verified <- liftIO $ verifyBadge key b + now <- liftIO getCurrentTime + pure (cld :: ContactShortLinkData) {localBadge = Just $ ShownBadge info (mkBadgeStatus now verified info)} + sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9b8f6da766..f8ccaa74e7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -49,6 +49,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) +import Simplex.Chat.Badges (LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types @@ -1483,7 +1484,10 @@ instance FromField (ChatMessage 'Json) where data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, - business :: Bool + business :: Bool, + -- set by the receiving client for the UI: the link profile's badge, verified and crypto-free. + -- never part of the published link data (the link carries the proof inside profile). + localBadge :: Maybe LocalBadge } deriving (Show) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index c9218672e7..365f658c77 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -51,6 +51,7 @@ chatProfileTests = do it "supporter badge sent to member joining via group link" testUserBadgeGroupLink it "expired supporter badge shows as expired" testUserBadgeExpired it "incognito connection does not carry supporter badge" testUserBadgeIncognito + it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "retry connecting via contact link" testRetryConnectingViaContactLink @@ -290,6 +291,40 @@ testUserBadgeGroupLink ps = do bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo +testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () +testUserBadgeContactAddress ps = do + Right (sk, pk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKey = pk}) aliceProfile bobProfile (test sk pk) ps + where + test sk pk alice bob = do + addTestBadge alice =<< issueTestBadge sk pk Nothing + alice ##> "/ad" + (shortLink, cLink) <- getContactLinks alice True + -- the address link data carries the badge proof; the connect plan returns it verified, without crypto + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"proof\":" + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + testUserBadgeExpired :: HasCallStack => TestParams -> IO () testUserBadgeExpired ps = do Right (sk, pk) <- bbsKeyGen