diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 2f76241c00..2078e9048a 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -396,7 +396,7 @@ struct ChatInfoView: View { if let domain = contact.profile.contactDomain, contact.profile.contactDomainVerified != nil || domain.proof != nil { SimplexNameView( - simplexName: "@\(domain.shortName)", + simplexName: "@\(domain.domain)", verified: contact.profile.contactDomainVerified, verify: { do { @@ -1384,28 +1384,51 @@ struct SimplexNameView: View { @State private var showSpinner = false var body: some View { - HStack(spacing: 6) { - Text(simplexName) - .font(verified == true ? .subheadline : .system(.subheadline, design: .monospaced)) - .foregroundColor(verified == true ? theme.colors.primary : theme.colors.secondary) - indicator() - } - .padding(.bottom, 2) - .onAppear { if autoVerify && verified == nil { runVerify(manual: false) } } + content + .padding(.bottom, 2) + .onAppear { if autoVerify && verified == nil { runVerify(manual: false) } } } - @ViewBuilder private func indicator() -> some View { + private var nameText: Text { + Text(simplexName) + .font(.subheadline) + .foregroundColor(verified == true ? theme.colors.primary : theme.colors.secondary) + } + + // Size the inline check/cross to the name's cap height so it reads like a capital letter, not an oversized glyph. + private var iconFont: Font { .system(size: UIFont.preferredFont(forTextStyle: .subheadline).capHeight) } + + @ViewBuilder private var content: some View { if showSpinner { - ProgressView() + HStack(spacing: 6) { + nameText + ProgressView() + } } else if verified == true { - Image(systemName: "checkmark") + HStack(alignment: .firstTextBaseline, spacing: 4) { + nameText + Image(systemName: "checkmark").font(iconFont).foregroundColor(theme.colors.primary) + .alignmentGuide(.firstTextBaseline) { $0[.bottom] - $0.height * 0.15 } + } + .contentShape(Rectangle()) + .onTapGesture { + UIPasteboard.general.string = simplexName + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + } } else if verified == false { - Image(systemName: "xmark") - .foregroundColor(.red) - .onTapGesture { runVerify(manual: true) } + HStack(alignment: .firstTextBaseline, spacing: 4) { + nameText + Image(systemName: "xmark").font(iconFont).foregroundColor(.red) + .alignmentGuide(.firstTextBaseline) { $0[.bottom] - $0.height * 0.15 } + } + .contentShape(Rectangle()) + .onTapGesture { runVerify(manual: true) } } else { - Button { runVerify(manual: true) } label: { - Text("Verify name").font(.subheadline).foregroundColor(theme.colors.primary) + HStack(spacing: 6) { + nameText + Button { runVerify(manual: true) } label: { + Text("Verify name").font(.subheadline).foregroundColor(theme.colors.primary) + } } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 358bf6c7d3..e5370bcf32 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -353,6 +353,17 @@ struct GroupChatInfoView: View { } ) } + if let claim = groupInfo.businessChat?.businessDomain, + groupInfo.groupDomainVerified != nil || claim.proof != nil { + // A business presents as a contact, so the name retains its .simplex suffix. The tick comes from + // groupDomainVerified (set at connect); its domain proof is not received on the wire yet, so + // re-verification is not wired. + SimplexNameView( + simplexName: "@\(claim.domain)", + verified: groupInfo.groupDomainVerified, + verify: { nil } + ) + } if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage, let url = URL(string: webPage) { Link(destination: url) { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 13caf135e9..8dbadc9fd3 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -193,7 +193,7 @@ struct UserAddressView: View { Section { NavigationLink { - let simplexName = if let d = chatModel.currentUser?.profile.contactDomain?.shortName { "@\(d)" } else { "" } + let simplexName = if let d = chatModel.currentUser?.profile.contactDomain?.domain { "@\(d)" } else { "" } SetSimplexDomainView( title: "Your SimpleX name", footer: "Let people connect to you via name registered with your SimpleX address.", diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d85999a7e7..eeda535994 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2857,6 +2857,7 @@ public struct BusinessChatInfo: Decodable, Hashable { public var chatType: BusinessChatType public var businessId: String public var customerId: String + public var businessDomain: SimplexDomainClaim? } public enum BusinessChatType: String, Codable, Hashable { 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 fd093f5761..b959b02272 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 @@ -2488,6 +2488,7 @@ data class BusinessChatInfo ( val chatType: BusinessChatType, val businessId: String, val customerId: String, + val businessDomain: SimplexDomainClaim? = null, ) @Serializable 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 80b7d828ba..5c9993eae5 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 @@ -6896,7 +6896,7 @@ sealed class CR { is Invitation -> withUser(user, "connLinkInvitation: ${json.encodeToString(connLinkInvitation)}\nconnection: $connection") 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 CRConnectionPlan -> withUser(user, "connLink: ${json.encodeToString(connLink)}\nplanSimplexName: $planSimplexName\notherSimplexName: $otherSimplexName\nconnectionPlan: ${json.encodeToString(connectionPlan)}") 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)}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index e43cc357a0..bfcbcdefac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -760,7 +760,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { val domain = contact.profile.contactDomain if (domain != null && (contact.profile.contactDomainVerified != null || domain.proof != null)) { SimplexNameView( - simplexName = "@${domain.shortName}", + simplexName = "@${domain.domain}", verified = contact.profile.contactDomainVerified, verify = { val rhId = chatModel.remoteHostId() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt index ccee6a9035..397778b8ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -7,8 +7,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.ImageResource import chat.simplex.common.model.SimplexNameInfo import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF @@ -61,34 +63,53 @@ fun SimplexNameView( if (chatModel.controller.appPrefs.privacyVerifySimplexNames.get() && verified == null) runVerify(manual = false) } + val clipboard = LocalClipboardManager.current + val nameStyle = MaterialTheme.typography.body2.copy( + color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) ) { - Text( - simplexName, - style = MaterialTheme.typography.body2.copy( - color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - fontFamily = if (verified == true) FontFamily.Default else FontFamily.Monospace - ) - ) when { - showSpinner.value -> + showSpinner.value -> { + Text(simplexName, style = nameStyle) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary) + } verified == true -> - Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground) + SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_check_filled, MaterialTheme.colors.primary) { + clipboard.setText(AnnotatedString(simplexName)) + showToast(generalGetString(MR.strings.copied)) + } verified == false -> - Icon( - painterResource(MR.images.ic_close), null, tint = Color.Red, - modifier = Modifier.size(18.dp).clickable { runVerify(manual = true) } - ) - else -> + SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, Color.Red) { runVerify(manual = true) } + else -> { + Text(simplexName, style = nameStyle) Text( stringResource(MR.strings.verify_simplex_name_action), color = MaterialTheme.colors.primary, modifier = Modifier.clickable { runVerify(manual = true) } ) + } } } } + +// The check/cross drawable is centered in its box with ~27% padding top and bottom, so its glyph bottom sits at +// ~73% of the box height. Align that line with the text baseline so the glyph rests on the baseline; the box is +// sized so the visible glyph is about the name's cap height. Only the icon is tinted, never the name. +@Composable +private fun SimplexNameWithIcon(name: String, style: TextStyle, icon: ImageResource, tint: Color, onClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.clickable { onClick() } + ) { + Text(name, Modifier.alignByBaseline(), style = style) + Icon( + painterResource(icon), null, + Modifier.size(22.dp).alignBy { it.measuredHeight * 73 / 100 }, + tint = tint + ) + } +} 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 c54ca148d4..4423f1dfbc 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 @@ -988,6 +988,17 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } ) } + val businessClaim = groupInfo.businessChat?.businessDomain + if (businessClaim != null && (groupInfo.groupDomainVerified != null || businessClaim.proof != null)) { + // A business presents as a contact, so the name retains its .simplex suffix. The tick comes from + // groupDomainVerified (set at connect); its domain proof is not received on the wire yet, so + // re-verification is not wired. + SimplexNameView( + simplexName = "@${businessClaim.domain}", + verified = groupInfo.groupDomainVerified, + verify = { null } + ) + } val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage if (webPage != null) { val uriHandler = LocalUriHandler.current 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 9d0efad152..7523ea40a0 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 @@ -369,7 +369,7 @@ private fun UserAddressLayout( stringResource(MR.strings.your_simplex_name), click = { ModalManager.start.showCustomModal { close -> - val domain = user?.profile?.contactDomain?.shortName + val domain = user?.profile?.contactDomain?.domain SetSimplexDomainView( title = generalGetString(MR.strings.set_simplex_name), footer = generalGetString(MR.strings.set_user_simplex_name_footer), diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index ce5c834001..79af05066b 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -468,6 +468,7 @@ TIMEOUT: - chatType: [BusinessChatType](#businesschattype) - businessId: string - customerId: string +- businessDomain: [SimplexDomainClaim](#simplexdomainclaim)? --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index dcd9a5581f..63f74f5814 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -289,6 +289,7 @@ export interface BusinessChatInfo { chatType: BusinessChatType businessId: string customerId: string + businessDomain?: SimplexDomainClaim } export enum BusinessChatType { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 0856aaf772..b3edf77111 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -204,6 +204,7 @@ class BusinessChatInfo(TypedDict): chatType: "BusinessChatType" businessId: str customerId: str + businessDomain: NotRequired["SimplexDomainClaim"] BusinessChatType = Literal["business", "customer"] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 18ca626a46..49e91704aa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2072,7 +2072,7 @@ processChatCommand cxt nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing Nothing + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing verifiedDomain hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2100,7 +2100,7 @@ processChatCommand cxt nm = \case APIPrepareGroup userId ccLink direct verifiedDomain groupSLinkData -> withUserId userId $ \user -> do let GroupShortLinkData {groupProfile = GroupProfile {description}} = groupSLinkData welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId (True <$ verifiedDomain) + (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId verifiedDomain void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -4813,14 +4813,14 @@ processChatCommand cxt nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId a $ SRGroup gId scope (sendAsGroup' gInfo scope) _ -> throwCmdError "not supported" - preparedGroupFromLink :: User -> CreatedLinkContact -> DirectLink -> GroupShortLinkData -> Maybe SharedMsgId -> Maybe Bool -> CM (GroupInfo, Maybe GroupMember) - preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId nameVerified = do + preparedGroupFromLink :: User -> CreatedLinkContact -> DirectLink -> GroupShortLinkData -> Maybe SharedMsgId -> Maybe SimplexDomain -> CM (GroupInfo, Maybe GroupMember) + preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId verifiedDomain = do let GroupShortLinkData {groupProfile = gp, publicGroupData = publicGroupData_} = groupSLinkData publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ useRelays = not direct subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ nameVerified + withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ verifiedDomain getSharedMsgId :: CM SharedMsgId getSharedMsgId = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 77edc99f05..894a17968b 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1046,7 +1046,7 @@ acceptBusinessJoinRequestAsync -- This refers to the "title member" that defines the group name and profile. -- This coincides with fromMember to be current user when accepting the connecting user, -- but it will be different when inviting somebody else. - business = Just $ BusinessChatInfo {chatType = BCBusiness, businessId = userMemberId, customerId = memberId}, + business = Just $ BusinessChatInfo {chatType = BCBusiness, businessId = userMemberId, customerId = memberId, businessDomain = Nothing}, groupSize = Just 1 } subMode <- chatReadVar subscriptionMode diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d6f51fadc3..b10b7ad239 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -238,7 +238,7 @@ import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRel import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexNameInfo (..), SimplexNameType (..), UserId) +import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexDomain, SimplexNameInfo (..), SimplexNameType (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import qualified Simplex.FileTransfer.Description as FD import Simplex.Messaging.Encoding (smpDecode, smpEncode) @@ -647,8 +647,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> Maybe Bool -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ verified_ = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> Maybe SimplexDomain -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ verifiedDomain = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs @@ -667,7 +667,11 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b forM_ hostMember_ $ \hostMember -> when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember g <- getGroupInfo db cxt user groupId - g' <- liftIO $ maybe (pure g) (setGroupDomainVerified db user g) verified_ + -- a business has no domain in its profile, so set it out-of-band; a channel already has it (createGroup_), just verify + g' <- liftIO $ case verifiedDomain of + Just d | business -> setPreparedGroupDomain db user g d + Just _ -> setGroupDomainVerified db user g True + Nothing -> pure g pure (g', hostMember_) where insertHost_ currentTs groupId groupLDN = do @@ -691,7 +695,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b insertedRowId db setGroupBusinessChatInfo :: GroupId -> GroupMember -> GroupMember -> IO () setGroupBusinessChatInfo groupId membership hostMember = do - let businessChatInfo = Just BusinessChatInfo {chatType = BCBusiness, businessId = memberId' hostMember, customerId = memberId' membership} + let businessChatInfo = Just BusinessChatInfo {chatType = BCBusiness, businessId = memberId' hostMember, customerId = memberId' membership, businessDomain = Nothing} updateBusinessChatInfo db groupId businessChatInfo updateBusinessChatInfo :: DB.Connection -> GroupId -> Maybe BusinessChatInfo -> IO () @@ -2714,22 +2718,28 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, (groupType_, groupLink_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink} -> (Just groupType, Just groupLink) Nothing -> (Nothing, Nothing) + -- group_domain is owned by publicGroup; when the incoming profile has no publicGroup (a business, whose domain + -- is set out-of-band) the CASE leaves the stored group_domain unchanged instead of clearing it. updateGroupProfile_ currentTs = - DB.execute - db - [sql| - UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, - group_type = ?, group_link = ?, - group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, - preferences = ?, member_admission = ?, updated_at = ? - WHERE group_profile_id IN ( - SELECT group_profile_id - FROM groups - WHERE user_id = ? AND group_id = ? - ) - |] - ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. publicGroupAccessRow publicGroup :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) + let (groupWebPage_, groupDomain_, domainWebPage_, allowEmbedding_, groupDomainProof_) = publicGroupAccessRow publicGroup + in DB.execute + db + [sql| + UPDATE group_profiles + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, + group_type = ?, group_link = ?, + group_web_page = ?, group_domain = CASE WHEN ? THEN ? ELSE group_domain END, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, + preferences = ?, member_admission = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + |] + ( (newName, fullName, shortDescr, description, image, groupType_, groupLink_) + :. (groupWebPage_, isJust publicGroup, groupDomain_, domainWebPage_, allowEmbedding_, groupDomainProof_) + :. (groupPreferences, memberAdmission, currentTs, userId, groupId) + ) updateGroup_ ldn currentTs = do DB.execute db @@ -2745,6 +2755,19 @@ setGroupDomainVerified db User {userId} g@GroupInfo {groupId} verified = do (BI verified, userId, groupId) pure g {groupDomainVerified = Just verified} +-- A business group has no publicGroup claim, so the domain it was connected by (from its address) is written +-- directly to group_domain and marked verified, so it is found by the local name search (getGroupToConnect). +setPreparedGroupDomain :: DB.Connection -> User -> GroupInfo -> SimplexDomain -> IO GroupInfo +setPreparedGroupDomain db user@User {userId} g@GroupInfo {groupId} domain = do + DB.execute + db + [sql| + UPDATE group_profiles SET group_domain = ? + WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) + |] + (domain, userId, groupId) + setGroupDomainVerified db user g True + updateGroupPreferences :: DB.Connection -> User -> GroupInfo -> GroupPreferences -> IO GroupInfo updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} ps = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 0d01b2ac1d..36a3a0c545 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -699,7 +699,7 @@ toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayN publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) groupKeys = toGroupKeys publicGroupId_ groupKeysRow groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} - businessChat = toBusinessChatInfo businessRow + businessChat = toBusinessChatInfo (toPublicGroupAccess accessRow >>= groupDomainClaim) businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, groupDomainVerified = unBI <$> groupDomainVerified} @@ -783,9 +783,9 @@ rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. domainRow) = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} -toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo -toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} -toBusinessChatInfo _ = Nothing +toBusinessChatInfo :: Maybe SimplexDomainClaim -> BusinessChatInfoRow -> Maybe BusinessChatInfo +toBusinessChatInfo businessDomain (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId, businessDomain} +toBusinessChatInfo _ _ = Nothing groupInfoQuery :: Query groupInfoQuery = groupInfoQueryFields <> " " <> groupInfoQueryFrom diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index edf4aab33a..0dc27eb9e9 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1050,7 +1050,9 @@ data MemberInfo = MemberInfo data BusinessChatInfo = BusinessChatInfo { chatType :: BusinessChatType, businessId :: MemberId, - customerId :: MemberId + customerId :: MemberId, + -- TODO [names] sent in protocol in GroupInvitation + businessDomain :: Maybe SimplexDomainClaim } deriving (Eq, Show) diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index cc46a65543..1de347e026 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -211,7 +211,7 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "bob" bobProfile $ \bob -> do (channelLink, _) <- prepareChannel1Relay "biz" alice cath alice ##> "/ad" - (contactLink, _) <- getContactLinks alice True + (contactLink, fullLink) <- getContactLinks alice True registerName reg bizName (contactAndChannelNameRecord "biz" (T.pack contactLink) (T.pack channelLink)) alice ##> "/auto_accept on business" alice <## "auto_accept on, business" @@ -219,7 +219,25 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> alice <## "new contact address set" bob ##> "/_connect plan 1 biz.simplex" bob <## "business address: ok to connect" - _ <- getTermLine bob -- contact short link data (JSON, printed in test view) + contactSLinkData <- getTermLine bob -- contact short link data (JSON, printed in test view) bob <## "You can also join channel #biz" + -- preparing the business by name saves its domain on the group, so it is then found by local name search + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> contactLink <> " domain=biz.simplex " <> contactSLinkData) + bob <## "#alice: group is prepared" + -- host changes its profile so the handshake's group-profile write fires; it must not wipe the saved domain + alice ##> "/p alice Alice Biz" + alice <## "user bio changed to Alice Biz (your 0 contacts are notified)" + bob ##> "/_connect plan 1 @biz.simplex resolve=never" + bob <## "business address: known prepared business #alice" + bob ##> "/_connect group #1" + bob <## "#alice: connection started" + alice <## "#bob (Bob): accepting business address request..." + bob <## "#alice: joining the group..." + alice <## "#bob: bob_1 joined the group" + bob <## "#alice: you joined the group" + -- after fully connecting, the business must still be found by local name search + bob ##> "/_connect plan 1 @biz.simplex resolve=never" + bob <## "business address: known business #alice" + bob <## "use #alice to send messages" where bizName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "biz" [])