update UI, tests

This commit is contained in:
Evgeny @ SimpleX Chat
2026-06-11 16:40:02 +00:00
parent 57b465c630
commit 64a4c0c9ee
11 changed files with 112 additions and 39 deletions
@@ -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()
)
@@ -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)) {
@@ -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,
@@ -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()
}
@@ -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,
@@ -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)
}
@@ -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),
+18 -15
View File
@@ -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} =
+12 -1
View File
@@ -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
+5 -1
View File
@@ -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)
+35
View File
@@ -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