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 93bf784d7c..c247a99572 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 @@ -1555,7 +1555,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo if (cInfo.incognito) { IncognitoImage(size = 36.dp * fontSizeSqrtMultiplier, Indigo) } - ChatInfoImage(cInfo, size = imageSize * fontSizeSqrtMultiplier, iconColor) + ChatInfoImage(cInfo, size = imageSize, iconColor, scaled = true) Column( Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -2793,7 +2793,7 @@ val MEMBER_IMAGE_SIZE: Dp = 37.dp @Composable fun MemberImage(member: GroupMember) { - MemberProfileImage(MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, member, backgroundColor = MaterialTheme.colors.background) + MemberProfileImage(MEMBER_IMAGE_SIZE, member, backgroundColor = MaterialTheme.colors.background, scaled = true) } @Composable 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 9bdbd7fc24..cbfb2498da 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,9 +352,7 @@ fun ContactCheckRow( } } else null ) { - BadgedProfileImage(36.dp, if (contact.active) contact.profile.localBadge else null) { - ProfileImage(size = 36.dp, contact.image) - } + ProfileImage(size = 36.dp, contact.image, badge = if (contact.active) contact.profile.localBadge else null) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) Text( contact.chatViewName, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 49dd43c1c8..6309455417 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -901,19 +901,21 @@ fun MemberProfileImage( color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, async: Boolean = false, - tappableBadge: Boolean = false + tappableBadge: Boolean = false, + scaled: Boolean = false ) { val badge = mem.memberProfile.localBadge - BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) { - ProfileImage( - size = size, - image = mem.image, - color = color, - backgroundColor = backgroundColor, - blurred = mem.blocked, - async = async - ) - } + ProfileImage( + size = size, + image = mem.image, + color = color, + backgroundColor = backgroundColor, + blurred = mem.blocked, + async = async, + badge = badge, + onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null, + scaled = scaled + ) } fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 6680ef99bc..d8396a15ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -128,7 +128,7 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - MemberProfileImage(size = imageSize * fontSizeSqrtMultiplier, member, iconColor) + MemberProfileImage(size = imageSize, member, iconColor, scaled = true) Column( Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally 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 8a56c40eb2..6ad9d0241f 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, tappableBadge = true) + ChatInfoImage(cInfo, size = 72.dp, tappableBadge = true, scaled = 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/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 901761f65c..475bb05518 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -21,7 +21,7 @@ import chat.simplex.res.MR @Composable fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { Row { - ChatInfoImage(contactRequest, size = 72.dp * fontSizeSqrtMultiplier) + ChatInfoImage(contactRequest, size = 72.dp, scaled = true) Column( modifier = Modifier .padding(start = 8.dp, end = 8.sp.toDp()) 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 a40fda277d..7a2c6b0cee 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 @@ -103,9 +103,7 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { ProfileImage(size = 42.dp, chat.chatInfo.image, icon = MR.images.ic_supervised_user_circle_filled) } else { 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) - } + ProfileImage(size = 42.dp, chat.chatInfo.image, badge = if (ct?.active == true) ct.profile.localBadge else null) } Text( chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 47c35dbd92..2558cd6430 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -464,10 +464,7 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - val avatarSize = 54.dp * fontSizeSqrtMultiplier - BadgedProfileImage(avatarSize, u.profile.localBadge) { - ProfileImage(image = u.image, size = avatarSize) - } + ProfileImage(image = u.image, size = 54.dp, badge = u.profile.localBadge, scaled = true) Text( u.displayName, modifier = Modifier 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 2482336656..85b42fe041 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 @@ -28,9 +28,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlin.math.max +import kotlin.math.roundToInt @Composable -fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false, tappableBadge: Boolean = false) { +fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false, tappableBadge: Boolean = false, scaled: Boolean = false) { val icon = when (chatInfo) { is ChatInfo.Group -> chatInfo.groupInfo.chatIconName @@ -44,9 +45,15 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme 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) - } + ProfileImage( + size, + chatInfo.image, + icon, + if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor, + badge = badge, + onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null, + scaled = scaled + ) } @Composable @@ -60,6 +67,9 @@ fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVa } } +// `size` is the design size; `scaled = true` renders the avatar (and badge) at size * fontSizeSqrtMultiplier. +// The badge is measured but only the avatar's size is reported, so it overflows bottom-right +// and has no effect on the avatar's own layout or anything around it. @Composable fun ProfileImage( size: Dp, @@ -68,8 +78,33 @@ fun ProfileImage( color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, blurred: Boolean = false, - async: Boolean = false + async: Boolean = false, + badge: LocalBadge? = null, + onBadgeClick: (() -> Unit)? = null, + scaled: Boolean = false ) { + val sz = if (scaled) size * fontSizeSqrtMultiplier else size + if (badge == null) { + ProfileImageBox(sz, image, icon, color, backgroundColor, blurred, async) + } else { + Layout(content = { + ProfileImageBox(sz, image, icon, color, backgroundColor, blurred, async) + ProfileBadge(badgeWidthRatio(size) * sz, badge, onBadgeClick) + }) { measurables, constraints -> + val avatar = measurables[0].measure(constraints) + val bdg = measurables[1].measure(Constraints()) + layout(avatar.width, avatar.height) { + avatar.place(0, 0) + // badge center sits 0.33 * size right and down of the avatar center + val off = (0.33f * avatar.width).toInt() + bdg.place(x = avatar.width / 2 + off - bdg.width / 2, y = avatar.height / 2 + off - bdg.height / 2) + } + } + } +} + +@Composable +private fun ProfileImageBox(size: Dp, image: String?, icon: ImageResource, color: Color, backgroundColor: Color?, blurred: Boolean, async: Boolean) { Box(Modifier.size(size)) { if (image == null) { val iconToReplace = when (icon) { @@ -116,29 +151,6 @@ fun ProfileImage( } } -// Overlays a supporter badge on an avatar with zero layout impact: a custom Layout measures the badge but -// reports only the avatar's size, so the badge overflows bottom-right (not clamped) and nothing around it shifts. -@Composable -fun BadgedProfileImage(size: Dp, badge: LocalBadge?, onBadgeClick: (() -> Unit)? = null, avatar: @Composable () -> Unit) { - if (badge == null) { - avatar() - return - } - Layout(content = { - avatar() - ProfileBadge(size, badge, onBadgeClick) - }) { measurables, constraints -> - val a = measurables[0].measure(constraints) - val b = measurables[1].measure(Constraints()) - layout(a.width, a.height) { - a.place(0, 0) - // phone center sits 0.33*S right and down of the avatar center, overflowing the avatar bounds - val off = (0.33f * a.width).toInt() - b.place(x = a.width / 2 + off - b.width / 2, y = a.height / 2 + off - b.height / 2) - } - } -} - // tones the glyph down: slightly desaturated and dimmed, so the gradient is less bright against the avatar private val badgeColorFilter = ColorFilter.colorMatrix( ColorMatrix().apply { @@ -156,14 +168,28 @@ private val badgeColorFilter = ColorFilter.colorMatrix( } ) -// the phone glyph (or warning triangle) scales inversely to the avatar so it stays readable when the avatar is small. +// Badge width as a fraction of the avatar design size, one entry per size in use - tune each placement here. +// Values match the original inverse-scaling formula 0.2133 * (1 + 0.5 * clamp((192 - s) / 156, 0, 1)): +// the smaller the avatar, the bigger the badge relative to it, so it stays readable at small sizes. +private fun badgeWidthRatio(size: Dp): Float = when (size.value.roundToInt()) { + 36 -> 0.32f // AddGroupMembersView (contact rows when adding members), ChatItemInfoView (member row in message info) + 37 -> 0.3192f // MEMBER_IMAGE_SIZE - member avatar next to group message bubbles + 38 -> 0.3185f // ChannelMembersView, ChannelRelaysView (member rows) + 40 -> 0.3172f // ChatInfoToolbarTitle (chat header), MemberSupportChatToolbarTitle (member support chat header) + 42 -> 0.3158f // ContactPreviewView (contacts list), ShareListNavLinkView (share-to list), MEMBER_ROW_AVATAR_SIZE (group info / member support member rows) + 54 -> 0.3076f // UserPicker (user switcher) + 57 -> 0.3056f // ChatItemInfoView (forwarded-from chat) + 60 -> 0.3035f // AddGroupMembersView toolbar (group avatar - shows no badge currently) + 72 -> 0.2953f // ChatPreviewView (chat list), ContactRequestView (contact request in chat list) + 138 -> 0.2502f // alertProfileImageSize - open-chat and scan-link alerts + 192 -> 0.2133f // ChatInfoView (contact info), GroupMemberInfoView (member info) + else -> 0.2133f +} + @Composable -private fun ProfileBadge(size: Dp, badge: LocalBadge, onBadgeClick: (() -> Unit)?) { - val s = size.value - val mult = 1f + 0.5f * ((192f - s) / 156f).coerceIn(0f, 1f) - // phone height ~0.28*S; set width only (= 0.7617*height) and let the height follow the glyph's aspect ratio - val phoneW = 0.28f * 0.7617f * size * mult - val mod = Modifier.width(phoneW).let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } +private fun ProfileBadge(width: Dp, badge: LocalBadge, onBadgeClick: (() -> Unit)?) { + // width only - the height follows the glyph's intrinsic aspect ratio + val mod = Modifier.width(width).let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } if (badge.status == BadgeStatus.Failed) { Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) } else { 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 ff96d46df0..eb19fe0bf5 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,13 +468,12 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: profileName = contact.profile.displayName, profileFullName = contact.profile.fullName, profileImage = { - BadgedProfileImage(alertProfileImageSize, if (contact.active) contact.profile.localBadge else null) { - ProfileImage( - size = alertProfileImageSize, - image = contact.profile.image, - icon = contact.chatIconName - ) - } + ProfileImage( + size = alertProfileImageSize, + image = contact.profile.image, + icon = contact.chatIconName, + badge = if (contact.active) contact.profile.localBadge else null + ) }, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), onConfirm = { @@ -626,16 +625,15 @@ fun showPrepareContactAlert( profileName = contactShortLinkData.profile.displayName, profileFullName = contactShortLinkData.profile.fullName, profileImage = { - 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 - ) - } + 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, + badge = contactShortLinkData.localBadge + ) }, 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 324a215c12..ff5ee13018 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1942,7 +1942,7 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - linkProfile <- presentUserBadge' incognitoProfile user $ userProfileDirect user incognitoProfile Nothing True + linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True let userData = contactShortLinkData linkProfile Nothing userLinkData = UserInvLinkData userData -- TODO [certs rcv] @@ -1965,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 =<< presentUserBadge user (userProfileDirect user Nothing Nothing True) + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1986,7 +1986,7 @@ processChatCommand cxt nm = \case let short = isJust $ connShortLink' =<< connLinkInv userLinkData_ <- if short - then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser (userProfileDirect newUser Nothing Nothing True) + then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (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 @@ -2265,7 +2265,7 @@ processChatCommand cxt nm = \case userData <- if isTrue userChatRelay then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) - else (`contactShortLinkData` Nothing) <$> presentUserBadge user (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (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 @@ -3147,7 +3147,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile - p <- presentUserBadge' (incognitoMembershipProfile gInfo) user $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True dm <- encodeConnInfo $ XInfo p (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3525,7 +3525,7 @@ processChatCommand cxt nm = \case conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' joinPreparedConn conn incognitoProfile chatV joinPreparedConn conn incognitoProfile chatV = do - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileDirect user incognitoProfile Nothing True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3673,7 +3673,7 @@ processChatCommand cxt nm = \case joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- - presentUserBadge' incognitoProfile user $ case gInfo_ of + presentUserBadge user incognitoProfile $ case gInfo_ of Just gInfo_' -> let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile @@ -3756,7 +3756,7 @@ processChatCommand cxt nm = \case -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do - p <- presentUserBadge user' mergedProfile' + p <- presentUserBadge user' Nothing mergedProfile' pure (ConnectionId connId, Nothing, XInfo p) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = @@ -3765,7 +3765,7 @@ 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 - shortLinkProfile <- presentUserBadge user $ userProfileDirect user Nothing Nothing True + shortLinkProfile <- presentUserBadge user Nothing $ 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 @@ -3788,7 +3788,7 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - p <- presentUserBadge' incognitoProfile user mergedProfile' + p <- presentUserBadge user incognitoProfile mergedProfile' void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' @@ -4668,7 +4668,7 @@ addUserBadge user cred@(BadgeCredential _ _ info) = do Right conn | not (connIncognito conn) -> do let ct' = updateMergedPreferences user' ct - p <- presentUserBadge user' $ userProfileDirect user' Nothing (Just ct') False + p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView _ -> pure () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 2e5b8ccd63..b1a425f51e 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -907,7 +907,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId Just conn@Connection {customUserProfileId} -> do incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId pure (ct, conn, ExistingIncognito <$> incognitoProfile) - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend -- TODO [certs rcv] (ct,conn,) . fst <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) @@ -920,7 +920,7 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV @@ -1897,22 +1897,16 @@ sendDirectContactMessages' user ct events = do pure sndMsgs' -- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. --- only own credentials present (peers carry proofs, which are not re-presented). callers must not present on incognito sends. -presentUserBadge :: User -> Profile -> CM Profile -presentUserBadge User {profile = LocalProfile {localBadge}} p = case localBadge of - Just (OwnBadge cred _) -> do +-- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. +presentUserBadge :: User -> Maybe i -> Profile -> CM Profile +presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of + (Nothing, Just (OwnBadge cred _)) -> do key <- asks $ badgePublicKey . config liftIO (badgeProof key cred PHTest) >>= \case Right proof -> pure p {badge = Just proof} Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) _ -> pure p --- gated on the send's incognito profile: an incognito identity must never carry the user's badge -presentUserBadge' :: Maybe a -> User -> Profile -> CM Profile -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 @@ -2132,7 +2126,7 @@ sendGroupMessages user gInfo scope asGroup members events = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented - profileUpdate <- presentUserBadge user $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p + profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 34400f324c..45908d97f6 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -438,7 +438,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) profileToSend <- - presentUserBadge' incognitoProfile user $ case gInfo_ of + presentUserBadge user incognitoProfile $ case gInfo_ of Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability @@ -556,7 +556,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId - p <- presentUserBadge' incognitoProfile user $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True + p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do @@ -567,7 +567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -799,7 +799,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" @@ -814,7 +814,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile <- presentUserBadge' (incognitoMembershipProfile gInfo) user $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -922,7 +922,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend <- presentUserBadge' incognitoProfile user $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected @@ -3087,7 +3087,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile <- presentUserBadge' (incognitoMembershipProfile gInfo) user $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index bb68baaeec..279a09e718 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -27,7 +27,7 @@ import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (CM, ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options @@ -454,13 +454,6 @@ userName :: TestCC -> IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser --- run an internal CM action against a test client's controller and active user (for functions with no command surface) -runCCUser :: HasCallStack => TestCC -> (User -> CM a) -> IO a -runCCUser TestCC {chatController = cc@ChatController {currentUser}} action = - readTVarIO currentUser >>= \case - Just user -> runReaderT (runExceptT (action user)) cc >>= either (error . show) pure - Nothing -> error "withCCUser: no current user" - testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChat = testChatCfgOpts testCfg testOpts