diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json new file mode 100644 index 0000000000..9d066d386e --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-investor.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json new file mode 100644 index 0000000000..b8b9a000d6 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-legend.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json new file mode 100644 index 0000000000..443575f1c7 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-supporter.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index e158b9374f..00c8d7070b 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -47,7 +47,7 @@ struct ChatInfoToolbar: View { } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) - (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) + NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline) .lineLimit(1) .if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in VStack(spacing: 0) { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..b21def7944 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -374,25 +374,17 @@ struct ChatInfoView: View { // show actual display name, alias can be edited in this view let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if contact.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = cInfo.nameBadge + // the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it + // sits low; raise it by half the cap-height difference to center it with the capitals + let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2 + let nameText = contact.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(cInfo.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 639de1dbc9..75a5baafee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,6 +16,7 @@ struct CIFileView: View { @EnvironmentObject var theme: AppTheme let file: CIFile? let edited: Bool + let senderProfile: LocalProfile? var smallViewSize: CGFloat? var body: some View { @@ -85,7 +86,7 @@ struct CIFileView: View { if let file = file { switch (file.fileStatus) { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -93,7 +94,7 @@ struct CIFileView: View { } } } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary) + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) AlertManager.shared.showAlertMsg( title: "Large file!", message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." @@ -165,7 +166,7 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } else { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) @@ -227,9 +228,9 @@ struct CIFileView: View { } } -func fileSizeValid(_ file: CIFile?) -> Bool { +func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool { if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol) + return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b56f1f9f2a..972e9c4ec6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -14,6 +14,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + let senderProfile: LocalProfile? var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat @@ -51,10 +52,18 @@ struct CIImageView: View { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: - Task { - if let user = m.currentUser { - await receiveFile(user: user, fileId: file.fileId) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } case .rcvAccepted: switch file.fileProtocol { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index e1172dab92..912fde4043 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -16,6 +16,7 @@ import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem + private let senderProfile: LocalProfile? private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @@ -35,8 +36,9 @@ struct CIVideoView: View { private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 } @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 - init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { + init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { self.chatItem = chatItem + self.senderProfile = senderProfile self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth @@ -421,10 +423,18 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { - Task { - if let user = m.currentUser { - await receiveFile(user, file.fileId, false, false) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user, file.fileId, false, false) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d09289c1d5..372c7df8a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -127,7 +127,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -142,7 +142,7 @@ struct FramedItemView: View { ciMsgContentView(chatItem) } case let .video(text, _, duration): - CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -349,7 +349,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..bd0e549d38 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -387,23 +387,31 @@ struct ChatItemInfoView: View { Text("you") .italic() .foregroundColor(theme.colors.onBackground) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { VStack(alignment: .leading) { - Text(groupMember.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground), + groupMember.nameBadge + ) + .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else { - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } } @@ -451,7 +459,7 @@ struct ChatItemInfoView: View { HStack{ MemberProfileImage(member, size: 30) .padding(.trailing, 2) - Text(member.chatViewName) + NameWithBadge(Text(member.chatViewName), member.nameBadge) .lineLimit(1) Spacer() if sentViaProxy == true { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 66148034df..efe26fdf89 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -981,8 +981,8 @@ struct ChatView: View { let v = VStack(spacing: 8) { ChatInfoImage(chat: chat, size: alertProfileImageSize) - Text(chat.chatInfo.displayName) - .font(.title3) + let badge = chat.chatInfo.nameBadge + NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -2003,7 +2003,7 @@ struct ChatView: View { Group { if #available(iOS 16.0, *) { MemberLayout(spacing: 16, msgWidth: msgWidth) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2012,7 +2012,7 @@ struct ChatView: View { } } else { HStack(spacing: 16) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2026,7 +2026,7 @@ struct ChatView: View { alignment: chatItem.chatDir.sent ? .trailing : .leading ) } else { - Text(memberNames(member, prevMember, memCount)) + NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1) .lineLimit(2) } } @@ -2311,7 +2311,7 @@ struct ChatView: View { } else { saveButton(file: fileSource) } - } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) { downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5242923258..e308a145b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1247,7 +1247,9 @@ struct ComposeView: View { } private var maxFileSize: Int64 { - getMaxFileSize(.xftp) + // the user's active badge raises the limit, but not in incognito chats where no badge is presented + let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault + return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile) } // Spec: spec/client/compose.md#sendLiveMessage diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 6b18c0c5ef..b59fd51fe8 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View { HStack{ ProfileImage(imageStr: contact.image, size: 30) .padding(.trailing, 2) - Text(ChatInfo.direct(contact: contact).chatViewName) - .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(ChatInfo.direct(contact: contact).chatViewName) + .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground), + contact.active ? contact.profile.localBadge : nil + ) + .lineLimit(1) Spacer() Image(systemName: icon) .foregroundColor(iconColor) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index abcadc6c3f..50144e2bc5 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -56,7 +56,7 @@ struct ChannelMembersView: View { MemberProfileImage(member, size: 38) .padding(.trailing, 2) VStack(alignment: .leading) { - displayName + NameWithBadge(displayName, member.nameBadge) .lineLimit(1) if user { Text("you") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0a448a2772..da895b325c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -502,7 +502,7 @@ struct GroupChatInfoView: View { // TODO server connection status VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) .lineLimit(1) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index dc14c7520b..28693e8d8a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -522,25 +522,14 @@ struct GroupMemberInfoView: View { // show alias if set, alias cannot be edited in this view let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if mem.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = mem.nameBadge + let nameText = mem.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(mem.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index 23001e64bf..74cb702d21 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View { MemberProfileImage(groupMember, size: imageSize) .padding(.trailing, 4) let t = Text(groupMember.chatViewName).font(.headline) - (groupMember.verified ? memberVerifiedShield + t : t) + NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline) .lineLimit(1) } .foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 880933985c..0263a39a90 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -172,7 +172,7 @@ struct MemberSupportView: View { .padding(.trailing, 2) VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) Text(memberStatus(member)) .lineLimit(1) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 243d804685..a6e7fc5870 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -173,7 +173,9 @@ struct ChatPreviewView: View { : !contact.sndReady ? theme.colors.secondary : nil - previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color) + NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3) + .lineLimit(1) + .frame(alignment: .topLeading) case let .group(groupInfo, _): let color = if deleting { theme.colors.secondary @@ -424,11 +426,11 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -436,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) + CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 9276bbfc78..341bc10655 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -22,12 +22,16 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(contactRequest.chatViewName) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(theme.colors.primary) - .padding(.leading, 8) - .frame(alignment: .topLeading) + NameWithBadge( + Text(contactRequest.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(theme.colors.primary), + chat.chatInfo.nameBadge, + .title3 + ) + .padding(.leading, 8) + .frame(alignment: .topLeading) Spacer() formatTimestampText(contactRequest.updatedAt) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 63d28e3624..8c230dc56a 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -129,7 +129,8 @@ struct UserPicker: View { } } .padding(.trailing, 6) - Text(u.user.displayName).font(.title2).lineLimit(1) + NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .modifier(ListRow { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index fcfcde2c07..9214e3ecde 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -200,10 +200,9 @@ struct ContactListNavLink: View { private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - return ( - contact.verified == true - ? verifiedIcon + t - : t + return NameWithBadge( + contact.verified == true ? verifiedIcon + t : t, + chat.chatInfo.nameBadge ) .lineLimit(1) } @@ -318,8 +317,7 @@ struct ContactListNavLink: View { HStack{ ProfileImage(imageStr: chat.chatInfo.image, size: 30) - Text(chat.chatInfo.chatViewName) - .foregroundColor(color) + NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge) .lineLimit(1) Spacer() diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift new file mode 100644 index 0000000000..67f6d6d6b2 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -0,0 +1,174 @@ +// +// NameBadge.swift +// SimpleX +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric +// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios +// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app. +private let fontCapHeightRatio: CGFloat = 0.85 +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private let badgeBaselineOffsetRatio: CGFloat = 0.05 + +// A contact/member name with the supporter badge right after it. The name keeps its own styling +// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to +// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge. +// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge. +struct NameWithBadge: View { + let name: Text + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.name = name + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + name + NameBadge(badge, textStyle, onTap: onTap) + } + } +} + +// The badge glyph alone, sized to the given text style and sitting on the text baseline in an +// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one +// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this +// directly only where the name is not a single Text. Pass onTap to open the badge info alert. +struct NameBadge: View { + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + if let badge, badge.status != .expiredOld { + // the leading padding is the gap to the name; it lives here so an absent badge adds no gap. + // the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot) + let v = glyph(badge) + .frame(height: badgeHeight) + .alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) } + .padding(.leading, badgeGap) + if let onTap { + v.onTapGesture(perform: onTap) + } else { + v + } + } + } + + private var badgeHeight: CGFloat { + UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio + } + + // the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font) + private var badgeGap: CGFloat { + let font = UIFont.preferredFont(forTextStyle: textStyle) + return (" " as NSString).size(withAttributes: [.font: font]).width + } + + @ViewBuilder private func glyph(_ badge: LocalBadge) -> some View { + switch badge.status { + case .failed, .unknownKey: + Image(systemName: "exclamationmark.triangle.fill") + .resizable().scaledToFit() + .foregroundColor(.orange) + default: + Image(badgeImageName(badge.badge.badgeType)) + .resizable().scaledToFit() + .opacity(badge.status == .expired ? 0.4 : 1) + } + } +} + +private func badgeImageName(_ t: BadgeType) -> String { + switch t { + case .legend: "badge-legend" + case .investor: "badge-investor" + default: "badge-supporter" // supporter + unknown + } +} + +// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel +// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline, +// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph. +func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? { + guard let badge, badge.status != .expiredOld else { return nil } + var image: UIImage? + switch badge.status { + case .failed, .unknownKey: + image = UIImage(systemName: "exclamationmark.triangle.fill")? + .withTintColor(.systemOrange, renderingMode: .alwaysOriginal) + default: + image = UIImage(named: badgeImageName(badge.badge.badgeType)) + if badge.status == .expired, let img = image { + // a recently expired badge is dimmed, matching NameBadge's 0.4 opacity + image = UIGraphicsImageRenderer(size: img.size).image { _ in + img.draw(at: .zero, blendMode: .normal, alpha: 0.4) + } + } + } + guard let image else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + let h = font.pointSize * fontCapHeightRatio + // text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height + attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h) + let s = NSMutableAttributedString(string: " ") // the gap to the name + s.append(NSAttributedString(attachment: attachment)) + return s +} + +func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { + switch badge.status { + case .failed: + showAlert( + NSLocalizedString("Unverified badge", comment: "badge alert title"), + message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert") + ) + case .unknownKey: + showAlert( + NSLocalizedString("Badge cannot be verified", comment: "badge alert title"), + message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert") + ) + default: + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + let t = badge.badge.badgeType.text + let title = t.prefix(1).uppercased() + t.dropFirst() + if case .investor = badge.badge.badgeType { + let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) + showAlert(title, message: message) { + [ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in + if let url = URL(string: "https://simplex.chat/crowdfunding") { + UIApplication.shared.open(url) + } + }, + okAlertAction ] + } + } else { + // supporter, legend and unknown types use the supporter wording + let supports = + if badge.status == .expired, let expiry = badge.badge.badgeExpiry { + String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) + } + let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert") + showAlert(title, message: supports + "\n\n" + v7) + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 9f2fc833ba..82d17cd2b1 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat func getTopViewController() -> UIViewController? { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene @@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let profileBadge: LocalBadge? private let subtitle: String? private let information: String? private let cancelTitle: String @@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + profileBadge: LocalBadge? = nil, subtitle: String? = nil, information: String? = nil, cancelTitle: String = "Cancel", @@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.profileBadge = profileBadge self.subtitle = subtitle self.information = information self.cancelTitle = cancelTitle @@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController { // Name label let nameLabel = UILabel() - nameLabel.text = profileName nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) nameLabel.textColor = .label nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center nameLabel.translatesAutoresizingMaskIntoConstraints = false + if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) { + let s = NSMutableAttributedString(string: profileName) + s.append(badge) + nameLabel.attributedText = s + } else { + nameLabel.text = profileName + } var profileViews = [profileImage, nameLabel] @@ -365,6 +375,7 @@ func showOpenChatAlert( profileName: String, profileFullName: String, profileImage: Content, + profileBadge: LocalBadge? = nil, theme: AppTheme, subtitle: String? = nil, information: String? = nil, @@ -383,6 +394,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + profileBadge: profileBadge, subtitle: subtitle, information: information, cancelTitle: cancelTitle, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 4a7e50d7d2..67fd353ebc 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View { HStack { ProfileImage(imageStr: user.image, size: 30) .padding(.trailing, 2) - Text(user.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(user.chatViewName).foregroundColor(theme.colors.onBackground), + user.profile.localBadge + ) + .lineLimit(1) Spacer() if selectedProfile == user, !incognitoEnabled { Image(systemName: "checkmark") @@ -1160,6 +1162,7 @@ private func showPrepareContactAlert( : "person.crop.circle.fill", size: alertProfileImageSize ), + profileBadge: contactShortLinkData.localBadge, theme: theme, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), @@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert( iconName: contact.chatIconName, size: alertProfileImageSize ), + profileBadge: contact.active ? contact.profile.localBadge : nil, theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a903329454..c1bc699261 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -526,12 +526,14 @@ func settingsRow(_ icon: String, color: Color/* = .secondary*/, struct ProfilePreview: View { var profileOf: NamedChat var color = Color(uiColor: .tertiarySystemGroupedBackground) + var badge: LocalBadge? = nil var body: some View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - profileName(profileOf).lineLimit(1) + NameWithBadge(profileName(profileOf), badge, .title2) + .lineLimit(1) } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2728f031b3..e2915963e4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; + CE11BADE0000000000000002 /* NameBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE11BADE0000000000000001 /* NameBadge.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; @@ -183,8 +184,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -413,6 +414,7 @@ 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; + CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -561,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -731,8 +733,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +820,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */, ); path = Libraries; sourceTree = ""; @@ -880,6 +882,7 @@ CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */, CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */, + CE11BADE0000000000000001 /* NameBadge.swift */, ); path = Helpers; sourceTree = ""; @@ -1559,6 +1562,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + CE11BADE0000000000000002 /* NameBadge.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 5e0c302720..bfe25c6d42 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -136,6 +136,8 @@ public struct Profile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + // the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact) + public var badge: BadgeProof? public var localAlias: String { get { "" } } var profileViewName: String { @@ -158,6 +160,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { contactLink: String? = nil, preferences: Preferences? = nil, peerType: ChatPeerType? = nil, + localBadge: LocalBadge? = nil, localAlias: String ) { self.profileId = profileId @@ -168,6 +171,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.contactLink = contactLink self.preferences = preferences self.peerType = peerType + self.localBadge = localBadge self.localAlias = localAlias } @@ -179,6 +183,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + public var localBadge: LocalBadge? public var localAlias: String var profileViewName: String { @@ -201,6 +206,70 @@ public enum ChatPeerType: String, Codable { case bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +public enum BadgeType: Hashable { + case supporter + case legend + case investor + case unknown(String) + + // the disclosed (signed) type name, shown to the user for verified badges + public var text: String { + switch self { + case .supporter: "supporter" + case .legend: "legend" + case .investor: "investor" + case let .unknown(s): s + } + } +} + +extension BadgeType: Codable { + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "supporter": self = .supporter + case "legend": self = .legend + case "investor": self = .investor + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(text) + } +} + +public enum BadgeStatus: String, Codable { + case active + case expired + // expired over a month ago - the badge is not shown at all + case expiredOld + case failed + // signed with a key index this app version does not know - shown as a warning + case unknownKey +} + +public struct BadgeInfo: Codable, Hashable { + public var badgeType: BadgeType + public var badgeExpiry: Date? + public var badgeExtra: String +} + +public struct LocalBadge: Codable, Hashable { + public var badge: BadgeInfo + public var status: BadgeStatus +} + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +public struct BadgeProof: Codable, Hashable { + public var badgeKeyIdx: Int + public var presHeader: String + public var proof: String + public var badgeInfo: BadgeInfo +} + public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { LocalProfile( profileId: profileId, @@ -1457,6 +1526,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none) + public var nameBadge: LocalBadge? { + get { + switch self { + case let .direct(contact): return contact.active ? contact.profile.localBadge : nil + case let .contactRequest(contactRequest): return contactRequest.profile.localBadge + default: return nil + } + } + } + public var displayName: String { get { switch self { @@ -2263,7 +2343,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var userContactLinkId_: Int64? public var cReqChatVRange: VersionRange var localDisplayName: ContactName - var profile: Profile + public var profile: LocalProfile var createdAt: Date public var updatedAt: Date @@ -2281,7 +2361,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { userContactLinkId_: 1, cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, createdAt: .now, updatedAt: .now ) @@ -2625,6 +2705,8 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + public var localBadge: LocalBadge? } public struct GroupSummary: Decodable, Hashable { @@ -2781,6 +2863,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } public var contactLink: String? { get { memberProfile.contactLink } } + public var nameBadge: LocalBadge? { memberProfile.localBadge } public var verified: Bool { activeConn?.connectionCode != nil } public var blocked: Bool { blockedByAdmin || !memberSettings.showMessages } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 3d0dd663c1..c44bcee5bd 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB +public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 @@ -273,11 +277,26 @@ public func cleanupFile(_ aChatItem: AChatItem) { } } -public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { +public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 { switch fileProtocol { - case .xftp: return MAX_FILE_SIZE_XFTP - case .smp: return MAX_FILE_SIZE_SMP - case .local: return MAX_FILE_SIZE_LOCAL + case .smp: MAX_FILE_SIZE_SMP + case .local: MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + case .xftp: + if let badge = senderProfile?.localBadge, badge.status == .active { + badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER + } else { + MAX_FILE_SIZE_XFTP + } + } +} + +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? { + switch (ci.chatDir, chatInfo) { + case let (.groupRcv(groupMember), _): return groupMember.memberProfile + case let (.directRcv, .direct(contact)): return contact.profile + default: return nil } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index a09ca2792b..9649e37c0f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -95,8 +95,9 @@ fun UserPickerUserBox( } } val user = userInfo.user - Text( + NameWithBadge( user.displayName, + user.profile.localBadge, fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 11c0f9e7f6..19b36067ed 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 @@ -1430,6 +1430,17 @@ data class Chat( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none). + // a badge that expired over a month ago (ExpiredOld) is not shown at all. + val nameBadge: LocalBadge? get() { + val badge = when { + this is Direct && contact.active -> contact.profile.localBadge + this is ContactRequest -> contactRequest.profile.localBadge + else -> null + } + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } + @Serializable @SerialName("direct") data class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -1994,7 +2005,10 @@ data class Profile( override val localAlias : String = "", val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + // the 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: BadgeProof? = null ): NamedChat { val profileViewName: String get() { @@ -2022,7 +2036,8 @@ data class LocalProfile( override val localAlias: String, val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + val localBadge: LocalBadge? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } @@ -2046,6 +2061,70 @@ enum class ChatPeerType { @SerialName("bot") Bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +@Serializable(with = BadgeTypeSerializer::class) +sealed class BadgeType { + @Serializable @SerialName("supporter") object Supporter: BadgeType() + @Serializable @SerialName("legend") object Legend: BadgeType() + @Serializable @SerialName("investor") object Investor: BadgeType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType() + + // the disclosed (signed) type name, shown to the user for verified badges + val text: String + get() = when (this) { + is Supporter -> "supporter" + is Legend -> "legend" + is Investor -> "investor" + is Unknown -> type + } +} + +object BadgeTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeType = + when (val v = decoder.decodeString()) { + "supporter" -> BadgeType.Supporter + "legend" -> BadgeType.Legend + "investor" -> BadgeType.Investor + else -> BadgeType.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text) +} + +@Serializable +enum class BadgeStatus { + @SerialName("active") Active, + @SerialName("expired") Expired, + // expired over a month ago - the badge is not shown at all + @SerialName("expiredOld") ExpiredOld, + @SerialName("failed") Failed, + // signed with a key index this app version does not know - shown as a warning + @SerialName("unknownKey") UnknownKey +} + +@Serializable +data class BadgeInfo( + val badgeType: BadgeType, + val badgeExpiry: Instant? = null, + val badgeExtra: String = "" +) + +@Serializable +data class LocalBadge( + val badge: BadgeInfo, + val status: BadgeStatus +) + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +@Serializable +data class BadgeProof( + val badgeKeyIdx: Int, + val presHeader: String, + val proof: String, + val badgeInfo: BadgeInfo +) + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, @@ -2278,7 +2357,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 @@ -2409,6 +2490,11 @@ data class GroupMember ( override val image: String? get() = memberProfile.image val contactLink: String? = memberProfile.contactLink val verified get() = activeConn?.connectionCode != null + // the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown + val nameBadge: LocalBadge? get() { + val badge = memberProfile.localBadge + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } val blocked get() = blockedByAdmin || !memberSettings.showMessages override val localAlias: String = memberProfile.localAlias @@ -2727,7 +2813,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 { @@ -2753,7 +2839,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/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 061ea71016..97101f253e 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 @@ -710,19 +710,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = contact.profile.displayName.trim() + val badge = cInfo.nameBadge val text = buildAnnotatedString { if (contact.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun (name: String) { clipboard.setText(AnnotatedString(name)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9c36f4896b..affe5ce326 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools @Composable fun ForwardedFromSender(forwardedFromItem: AChatItem) { @Composable - fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { - Text( + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) { + NameWithBadge( text, + badge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1, @@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (forwardedFromItem.chatItem.chatDir.sent) { ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { - ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else { - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge) } } } @@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools ) { MemberProfileImage(size = 36.dp, member) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( member.chatViewName, - modifier = Modifier.weight(10f, fill = true), + member.nameBadge, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis ) 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..68e5ee3394 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow @@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) { ContactVerifiedShield() } - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList( } else { null to 1 } - Text( + // the name and the badge are one element, so SpaceBetween separates them from the role, not from each other + NameWithBadge( memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, Modifier .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) .weight(1f, false), @@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList( .background(MaterialTheme.appColors.receivedMessage) ) { ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + val bannerBadge = chatInfo.nameBadge + val uriHandler = LocalUriHandler.current Text( - chatInfo.displayName, + buildAnnotatedString { + append(chatInfo.displayName) + if (bannerBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index d874079238..6d598a166b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -113,7 +113,9 @@ data class ComposeState( val inProgress: Boolean = false, val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, - val mentions: MentionedMembers = emptyMap() + val mentions: MentionedMembers = emptyMap(), + // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -251,8 +253,6 @@ data class ComposeState( } } -private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) - sealed class RecordingState { object NotStarted: RecordingState() class Started(val filePath: String, val progressMs: Int = 0): RecordingState() @@ -300,6 +300,7 @@ fun MutableState.onFilesAttached(uris: List) { fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { + val maxFileSize = value.maxFileSize val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) @@ -318,6 +319,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } suspend fun MutableState.processPickedMedia(uris: List, text: String?) { + val maxFileSize = value.maxFileSize val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> @@ -487,7 +489,7 @@ fun ComposeView( if (live) { composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false) } else { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize) resetLinkPreview() } recState.value = RecordingState.NotStarted @@ -1094,7 +1096,7 @@ fun ComposeView( if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { - composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize) } } @@ -1322,6 +1324,11 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented + LaunchedEffect(chat.chatInfo) { + val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() + composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile)) + } if (appPlatform.isDesktop) { // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` DisposableEffect(Unit) { 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..45d336be75 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 @@ -354,9 +354,10 @@ fun ContactCheckRow( ) { ProfileImage(size = 36.dp, contact.image) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( contact.chatViewName, - modifier = Modifier.weight(10f, fill = true), + if (contact.active) contact.profile.localBadge else null, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 0cf3a3c96f..64f02d3376 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole if (member.verified) { MemberVerifiedShield() } - Text( + NameWithBadge( member.chatViewName, + member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified 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 7b9d6aa92e..770dfa64fb 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 @@ -1078,8 +1078,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr if (member.verified) { MemberVerifiedShield() } - Text( - if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, + member.nameBadge, + maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } 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 8677609863..fe45be92b7 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -735,19 +736,32 @@ fun GroupMemberInfoHeader(member: GroupMember) { ) { MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = member.displayName.trim() // alias if set + val badge = member.nameBadge val text = buildAnnotatedString { if (member.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun(name: String) { clipboard.setText(AnnotatedString(name)) 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..3d3096b4f5 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 @@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic if (member.verified) { MemberVerifiedShield() } - Text( - member.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 3d76c845ad..7ca277df94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) { if (member.verified) { MemberVerifiedShield() } - Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index afd55ed928..02bee37c24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -33,6 +33,7 @@ fun CIFileView( edited: Boolean, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,12 +72,12 @@ fun CIFileView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> { - if (fileSizeValid(file)) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } @@ -151,7 +152,7 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid(file)) + if (fileSizeValid(file, senderProfile)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -225,7 +226,9 @@ fun CIFileView( } } -fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +// whether a received file is within the size we accept from its sender +fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = + file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 8bfbea9fa6..ed9a0e6007 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -35,6 +35,7 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -160,13 +161,6 @@ fun CIImageView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { @@ -213,12 +207,12 @@ fun CIImageView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> - if (fileSizeValid()) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8289149ad9..f8dfba4c6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -34,6 +34,7 @@ fun CIVideoView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -84,7 +85,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, senderProfile, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -114,7 +115,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) } } } } @@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun fileSizeValid(file: CIFile?): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false -} - -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file)) { +private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 64288d9055..2c04911e39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -447,7 +447,7 @@ fun ChatItemView( } if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) { ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { withBGApi { Log.d(TAG, "ChatItemView downloadFileAction") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index f55c49fdd1..5c07fe3abf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -201,7 +201,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -312,7 +312,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -320,7 +320,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { 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..2c7e443b4d 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 @@ -152,7 +152,15 @@ fun ChatPreviewView( } else { Color.Unspecified } - chatPreviewTitleText(color = color) + NameWithBadge( + cInfo.chatViewName, + cInfo.nameBadge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = color + ) } } is ChatInfo.Group -> { @@ -316,13 +324,13 @@ fun ChatPreviewView( } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -334,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) { + CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } 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..96e7fbacd0 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 @@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { .padding(start = 8.dp, end = 8.sp.toDp()) .weight(1F) ) { - Text( + NameWithBadge( contactRequest.chatViewName, + contactRequest.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, 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..07058b5787 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 @@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { } else { ProfileImage(size = 42.dp, chat.chatInfo.image) } - Text( - chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified ) } 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 a02e0dc768..568cdfe574 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 @@ -210,7 +210,7 @@ fun UserPicker( } } else if (currentUser != null) { SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped) + ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge) } } } @@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning image = u.image, size = 54.dp * fontSizeSqrtMultiplier ) - Text( + // the end padding is on the row, not the name, so the badge stays right after the name + NameWithBadge( u.displayName, - modifier = Modifier - .padding(start = 10.dp, end = 8.dp), + u.profile.localBadge, + Modifier.padding(start = 10.dp, end = 8.dp), color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary, fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 636887275c..524fbedb1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -54,8 +54,9 @@ fun ContactPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor @@ -63,8 +64,9 @@ fun ContactPreviewView( } is ChatInfo.ContactRequest -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 3d670d1c43..c855259ffb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.* @@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @@ -272,6 +275,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + profileBadge: LocalBadge? = null, subtitle: String? = null, information: String? = null, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), @@ -299,8 +303,17 @@ class AlertManager { ) { profileImage() Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + val nameFontSize = MaterialTheme.typography.h4.fontSize Text( - profileName, + buildAnnotatedString { + append(profileName) + if (profileBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(), textAlign = TextAlign.Center, style = MaterialTheme.typography.h4, lineHeight = 20.sp, 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 5f3a73e7ea..d2ee1db09c 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 @@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.shape.* import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.model.localDate import chat.simplex.common.platform.* 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) { @@ -103,6 +120,137 @@ fun ProfileImage( } } +// badge height in em: calibrated visually so the badge top matches capital letters and digits +// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict) +private const val fontCapHeightRatio = 0.95f + +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private const val badgeBaselineOffsetRatio = 0.05f + +// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399) +private const val badgeAspectRatio = 300f / 399f + +// A contact/member name with the badge right after it: the badge is baseline-aligned with the name +// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible. +@Composable +fun NameWithBadge( + name: String, + badge: LocalBadge?, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current +) { + Row(modifier) { + Text( + name, + Modifier.alignByBaseline().weight(1f, fill = false), + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + overflow = overflow, + maxLines = maxLines, + style = style + ) + NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize) + } +} + +// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline. +// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline(). +@Composable +fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) { + // a badge that expired over a month ago (ExpiredOld) is not shown + if (badge == null || badge.status == BadgeStatus.ExpiredOld) return + val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio + BadgeGlyph( + badge, + // the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge, + // so the Row places the badge that much below the text baseline; + // 6.dp matches the visible gap between the name and the verification shield: + // the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none + Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio) + ) +} + +// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall. +// precede with append(" ") for the space between the name and the badge. +fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent { + val height = fontSize * fontCapHeightRatio + return InlineTextContent( + Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline) + ) { + // the placeholder bottom sits on the baseline and can't extend below it, + // so the badge is drawn shifted down by badgeBaselineOffsetRatio instead + BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick) + } +} + +@Composable +private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) { + val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } + if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) { + Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) + } else { + Image( + painterResource(badgeImage(badge.badge.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f, + modifier = mod + ) + } +} + +fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + when { + badge.status == BadgeStatus.Failed -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unverified_title), + text = generalGetString(MR.strings.badge_unverified_desc) + ) + badge.status == BadgeStatus.UnknownKey -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unknown_key_title), + text = generalGetString(MR.strings.badge_unknown_key_desc) + ) + badge.badge.badgeType is BadgeType.Investor -> + AlertManager.shared.showAlertDialog( + title = title, + text = String.format(generalGetString(MR.strings.badge_invested), name), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.learn_more), + onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") } + ) + else -> { + // Supporter, Legend and unknown types use the supporter wording + val expiry = badge.badge.badgeExpiry + val supports = + if (badge.status == BadgeStatus.Expired && expiry != null) + String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry)) + else + String.format(generalGetString(MR.strings.badge_supports_simplex), name) + AlertManager.shared.showAlertMsg( + title = title, + text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7) + ) + } + } +} + +private fun badgeImage(t: BadgeType): ImageResource = when (t) { + is BadgeType.Legend -> MR.images.badge_legend + is BadgeType.Investor -> MR.images.badge_investor + else -> MR.images.badge_supporter // Supporter + Unknown +} + @Composable fun ProfileImage(size: Dp, image: ImageResource) { Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 23c622bc34..86f2f13313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB +const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI @@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } -fun getMaxFileSize(fileProtocol: FileProtocol): Long { - return when (fileProtocol) { - FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP - FileProtocol.SMP -> MAX_FILE_SIZE_SMP - FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) { + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + FileProtocol.XFTP -> { + val badge = senderProfile?.localBadge + if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP + else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND + else MAX_FILE_SIZE_XFTP_SUPPORTER } } +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) { + is CIDirection.GroupRcv -> dir.groupMember.memberProfile + is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile + else -> null +} + expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration fun showWrongUriAlert() { 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..e5dbe01d68 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 @@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: icon = contact.chatIconName ) }, + // the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too + profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) 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 = { openKnownContact(chatModel, rhId, close, contact) @@ -633,6 +635,7 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index b1ab8eb24e..be16ced1f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -230,7 +230,8 @@ private fun ProfilePickerOption( disabled: Boolean, onSelected: () -> Unit, image: @Composable () -> Unit, - onInfo: (() -> Unit)? = null + onInfo: (() -> Unit)? = null, + badge: LocalBadge? = null ) { Row( Modifier @@ -243,7 +244,7 @@ private fun ProfilePickerOption( ) { image() TextIconSpaced(false) - Text(title, modifier = Modifier.align(Alignment.CenterVertically)) + NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically)) if (onInfo != null) { Spacer(Modifier.padding(6.dp)) Column(Modifier @@ -365,7 +366,8 @@ fun ActiveProfilePicker( } } }, - image = { ProfileImage(size = 42.dp, image = user.image) } + image = { ProfileImage(size = 42.dp, image = user.image) }, + badge = user.profile.localBadge ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index a02d67265d..22270ea5bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -309,12 +309,13 @@ fun AppVersionItem(showVersion: () -> Unit) { Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else "")) } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) { ProfileImage(size = size, image = profileOf.image, color = iconColor) Spacer(Modifier.padding(horizontal = 8.dp)) Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { - Text( + NameWithBadge( profileOf.displayName, + badge, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = if (stopped) MaterialTheme.colors.secondary else textColor, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index cd0508f95a..ecca74fae2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3105,4 +3105,12 @@ SimpleX — %d unread Minimize to tray when closing window Keep SimpleX running in the background to receive messages. + %s supports SimpleX Chat. + %1$s supported SimpleX Chat. The badge expired on %2$s. + You can support SimpleX starting from v7 of the app. + %s invested in SimpleX Chat crowdfunding. + Unverified badge + This badge could not be verified and may not be genuine. + Badge cannot be verified + The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 52e845b422..43428bab72 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -67,15 +66,17 @@ actual fun UserPickerUsersSection( } } - Text( - user.displayName, - fontSize = 12.sp, - fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(65.dp), - textAlign = TextAlign.Center - ) + Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) { + Text( + user.displayName, + fontSize = 12.sp, + fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alignByBaseline().weight(1f, fill = false) + ) + NameBadge(user.profile.localBadge, 12.sp) + } } } } diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 41321edc68..f0501fef4f 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -1,8 +1,14 @@ module Main where import Server (simplexChatServer) +import Simplex.Chat.Badges.CLI (runBadgeCommand) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI) +import System.Environment (getArgs) main :: IO () -main = simplexChatCLI terminalChatConfig (Just simplexChatServer) +main = do + args <- getArgs + case args of + ("badge" : _) -> runBadgeCommand args + _ -> simplexChatCLI terminalChatConfig (Just simplexChatServer) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 4036bd8cf1..89c5178f7d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -313,43 +313,48 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + withDB "getGroupAndReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = - withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ + withDB "getUserGroupReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = - withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db cxt user@User {userId, userContactId} = +getAllListedGroups_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg cxt user) + >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = - withDB' "searchListedGroups" cc $ \db -> + withDB' "searchListedGroups" cc $ \db -> do + currentTs <- getCurrentTime case searchType of STAll -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -357,11 +362,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " STRecent -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -369,11 +374,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " STSearch search -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) pure (gs, n) where @@ -381,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,21 +400,24 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa |] getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db cxt user@User {userId, userContactId} = - map (toGroupInfoReg cxt user) +getAllGroupRegs_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = - withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getDuplicateGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -417,15 +425,16 @@ listLastGroups cc user@User {userId, userContactId} count = listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index a87bcae5e4..60cee67d78 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -10,6 +10,10 @@ This file is generated automatically. - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) - [AutoAccept](#autoaccept) +- [BadgeInfo](#badgeinfo) +- [BadgeProof](#badgeproof) +- [BadgeStatus](#badgestatus) +- [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -122,6 +126,7 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [LocalBadge](#localbadge) - [LocalProfile](#localprofile) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) @@ -353,6 +358,49 @@ INACTIVE: - acceptIncognito: bool +--- + +## BadgeInfo + +**Record type**: +- badgeType: [BadgeType](#badgetype) +- badgeExpiry: UTCTime? +- badgeExtra: string + + +--- + +## BadgeProof + +**Record type**: +- badgeKeyIdx: int +- presHeader: string +- proof: string +- badgeInfo: [BadgeInfo](#badgeinfo) + + +--- + +## BadgeStatus + +**Enum type**: +- "active" +- "expired" +- "expiredOld" +- "failed" +- "unknownKey" + + +--- + +## BadgeType + +**Enum type**: +- "supporter" +- "legend" +- "investor" + + --- ## BlockingInfo @@ -1766,6 +1814,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- localBadge: [LocalBadge](#localbadge)? --- @@ -2672,6 +2721,15 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## LocalBadge + +**Record type**: +- badge: [BadgeInfo](#badgeinfo) +- status: [BadgeStatus](#badgestatus) + + --- ## LocalProfile @@ -2685,6 +2743,7 @@ Unknown: - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- localBadge: [LocalBadge](#localbadge)? - localAlias: string @@ -3029,6 +3088,7 @@ count= - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- badge: [BadgeProof](#badgeproof)? --- @@ -4213,7 +4273,7 @@ Handshake: - cReqChatVRange: [VersionRange](#versionrange) - localDisplayName: string - profileId: int64 -- profile: [Profile](#profile) +- profile: [LocalProfile](#localprofile) - createdAt: UTCTime - updatedAt: UTCTime - xContactId: string? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8894609758..1cd7c78913 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -202,6 +202,7 @@ cliCommands = "AbortSwitchGroupMember", "AcceptContact", "AcceptMember", + "AddBadge", "AddContact", "AddMember", "AllowRelayGroup", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 8397503bbe..7b268f4ec5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -34,6 +34,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), BadgeType (..), JSONBadge (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -183,6 +184,7 @@ ciQuoteType = chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), + ((sti @JSONBadge) {typeName = "LocalBadge"}, STRecord, "", [], "", ""), ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", ["JCInfoInvalidJSON"], "", ""), ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", ["JCIInvalidJSON"], "", ""), ((sti @JSONCIDeleted) {typeName = "CIDeleted"}, STUnion, "JCID", [], "", ""), @@ -207,6 +209,7 @@ chatTypesDocsData = (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AutoAccept, STRecord, "", [], "", ""), + (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -216,6 +219,8 @@ chatTypesDocsData = (sti @ChatDeleteMode, STUnion, "CDM", [], Param "type" <> Choice "self" [("messages", "")] (OnOffParam "notify" "notify" (Just True)), ""), (sti @ChatError, STUnion, "Chat", ["ChatErrorDatabase", "ChatErrorRemoteHost", "ChatErrorRemoteCtrl"], "", ""), (sti @ChatErrorType, STUnion, "CE", ["CEContactNotFound", "CEServerProtocol", "CECallState", "CEInvalidChatMessage"], "", ""), + (sti @BadgeStatus, STEnum, "BS", [], "", ""), + (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), (sti @ChatPeerType, STEnum, "CPT", [], "", ""), @@ -303,6 +308,7 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), @@ -422,11 +428,14 @@ deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType deriving instance Generic AutoAccept +deriving instance Generic BadgeProof deriving instance Generic BlockingInfo deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType +deriving instance Generic BadgeStatus +deriving instance Generic BadgeType deriving instance Generic ChatBotCommand deriving instance Generic ChatDeleteMode deriving instance Generic ChatError @@ -515,6 +524,7 @@ deriving instance Generic HandshakeError deriving instance Generic InlineFileMode deriving instance Generic InvitationLinkPlan deriving instance Generic InvitedBy +deriving instance Generic JSONBadge deriving instance Generic JSONChatInfo deriving instance Generic JSONCIContent deriving instance Generic JSONCIDeleted @@ -524,6 +534,7 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic BadgeInfo deriving instance Generic LocalProfile deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 36e87db62d..8dfba2bbb0 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -198,7 +198,11 @@ toTypeInfo tr = "AgentInvId", "AgentRcvFileId", "AgentSndFileId", + "BadgeMasterKey", "B64UrlByteString", + "BBSProof", + "BBSPresHeader", + "BBSSignature", "CbNonce", "ConnectionLink", "ConnShortLink", diff --git a/cabal.project b/cabal.project index 3e32dfcd5e..d3b9eeffa5 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7 + tag: 9f9b6c8e88524fb5fd063f47617a679ea53ac7c0 source-repository-package type: git diff --git a/flake.nix b/flake.nix index 43f4e8912a..fdd041bd88 100644 --- a/flake.nix +++ b/flake.nix @@ -406,6 +406,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -525,6 +527,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -591,6 +595,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -607,6 +612,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -626,6 +632,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -641,6 +648,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 76e6f9f3ee..ec4125193f 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -16,6 +16,8 @@ EXPORTS chat_password_hash chat_valid_name chat_json_length + chat_badge_keygen + chat_badge_issue chat_encrypt_media chat_decrypt_media chat_write_file diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5e671169de..883728f943 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -186,6 +186,33 @@ export interface AutoAccept { acceptIncognito: boolean } +export interface BadgeInfo { + badgeType: BadgeType + badgeExpiry?: string // ISO-8601 timestamp + badgeExtra: string +} + +export interface BadgeProof { + badgeKeyIdx: number // int + presHeader: string + proof: string + badgeInfo: BadgeInfo +} + +export enum BadgeStatus { + Active = "active", + Expired = "expired", + ExpiredOld = "expiredOld", + Failed = "failed", + UnknownKey = "unknownKey", +} + +export enum BadgeType { + Supporter = "supporter", + Legend = "legend", + Investor = "investor", +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -2044,6 +2071,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + localBadge?: LocalBadge } export enum ContactStatus { @@ -2940,6 +2968,11 @@ export interface LinkPreview { content?: LinkContent } +export interface LocalBadge { + badge: BadgeInfo + status: BadgeStatus +} + export interface LocalProfile { profileId: number // int64 displayName: string @@ -2949,6 +2982,7 @@ export interface LocalProfile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + localBadge?: LocalBadge localAlias: string } @@ -3299,6 +3333,7 @@ export interface Profile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + badge?: BadgeProof } export type ProxyClientError = @@ -4882,7 +4917,7 @@ export interface UserContactRequest { cReqChatVRange: VersionRange localDisplayName: string profileId: number // int64 - profile: Profile + profile: LocalProfile createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp xContactId?: string 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 66ba77c062..855a967215 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -138,6 +138,21 @@ AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FI class AutoAccept(TypedDict): acceptIncognito: bool +class BadgeInfo(TypedDict): + badgeType: "BadgeType" + badgeExpiry: NotRequired[str] # ISO-8601 timestamp + badgeExtra: str + +class BadgeProof(TypedDict): + badgeKeyIdx: int # int + presHeader: str + proof: str + badgeInfo: "BadgeInfo" + +BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] + +BadgeType = Literal["supporter", "legend", "investor"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -1441,6 +1456,7 @@ class ContactShortLinkData(TypedDict): profile: "Profile" message: NotRequired["MsgContent"] business: bool + localBadge: NotRequired["LocalBadge"] ContactStatus = Literal["active", "deleted", "deletedByUser"] @@ -2059,6 +2075,10 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class LocalBadge(TypedDict): + badge: "BadgeInfo" + status: "BadgeStatus" + class LocalProfile(TypedDict): profileId: int # int64 displayName: str @@ -2068,6 +2088,7 @@ class LocalProfile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + localBadge: NotRequired["LocalBadge"] localAlias: str MemberCriteria = Literal["all"] @@ -2318,6 +2339,7 @@ class Profile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + badge: NotRequired["BadgeProof"] class ProxyClientError_protocolError(TypedDict): type: Literal["protocolError"] @@ -3431,7 +3453,7 @@ class UserContactRequest(TypedDict): cReqChatVRange: "VersionRange" localDisplayName: str profileId: int # int64 - profile: "Profile" + profile: "LocalProfile" createdAt: str # ISO-8601 timestamp updatedAt: str # ISO-8601 timestamp xContactId: NotRequired[str] diff --git a/plans/2026-06-01-supporter-badges-v1.md b/plans/2026-06-01-supporter-badges-v1.md new file mode 100644 index 0000000000..29a47a103e --- /dev/null +++ b/plans/2026-06-01-supporter-badges-v1.md @@ -0,0 +1,80 @@ +# Supporter Badges v1 - Verification + +Badge verification in stable so that v6.5 users can see and verify badges from v7 users. Badge purchase and issuance is v2. + +## Why BBS+ + +BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures) allow a holder of a signed credential to generate zero-knowledge proofs that selectively disclose some signed attributes while hiding others. Each proof uses a random nonce, making different proofs from the same credential computationally unlinkable - a verifier seeing two proofs cannot determine they came from the same credential. This means a supporter badge shown to different contacts cannot be correlated, preserving SimpleX's unlinkable identity model. + +The server that signs the credential sees the master secret during signing but cannot link any received proof back to any signing session - this is the core zero-knowledge property. + +## References + +- IETF draft: https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/ +- libbbs: https://github.com/Fraunhofer-AISEC/libbbs (Apache-2.0, Fraunhofer-AISEC) +- blst: https://github.com/supranational/blst (Apache-2.0, audited by NCC Group) - internal dependency of libbbs for BLS12-381 curve operations + +Both are vendored verbatim into simplexmq so that users and maintainers can verify the source matches upstream. Only libbbs API is called directly. + +## Crypto + +3 signed messages: `[ms, expiry, level]`. `ms` undisclosed (index 0), `expiry` and `level` disclosed (indexes 1, 2). Proof size: 304 bytes (272 base + 32 per undisclosed). + +Server public key (`srvPK`, 96 bytes) hardcoded in app. + +## libbbs integration + +Vendor libbbs + blst C sources into simplexmq. Haskell FFI bindings following the SNTRUP761 pattern (`Simplex.Messaging.Crypto.BBS.Bindings`). + +Full FFI surface for testing the complete flow: + +- `bbs_keygen_full` - generate keypair +- `bbs_sign` - sign messages +- `bbs_proof_gen` - generate ZK proof with selective disclosure +- `bbs_proof_verify` - verify proof +- `bbs_sha256_ciphersuite` - ciphersuite constant + +Unit tests: keygen, sign, proof gen, proof verify roundtrip. Verify proof size. Verify rejection of tampered proofs. Verify two proofs from same credential don't correlate (different presentation headers produce different proofs that both verify). + +Use blst portable C fallback for now (avoids per-arch assembly). + +## Profile type + +Add optional `badge` field to `Profile`. The `SupporterBadge` type uses base64-encoded newtypes for binary fields, following the `KEMPublicKey`/`KEMCiphertext` pattern from SNTRUP761 bindings: + +```haskell +data SupporterBadge = SupporterBadge + { proof :: BBSProof + , proofNonce :: ByteString + , badgeExpiry :: UTCTime + , badgeType :: Text + } +``` + +`badgeType` is a string: `"supporter"`, `"business"`, `"legend"`, `"cf_investor"`. Displayed in UI as Supporter, Business, Legend, Crowdfunding Investor. `BBSProof` is a newtype over `ByteString` with `StrEncoding` instances for base64url JSON encoding. + +Backward compatible: `omitNothingFields` means older clients ignore it, newer clients without badge send `Nothing`. + +## DB + +- `badge` fields on `contact_profiles` and `group_member_profiles` to store received badge data +- `badge_status` column on `contacts` and `group_members` to store verification result +- `badge` fields on user profile (`users` or `contact_profiles` for own profile) for when badge issuance is added in v2 + +## Verification + +On receiving profile with `badge` (in Subscriber.hs, `XInfo`/`XGrpMemInfo`/`XContact` handlers): + +1. `bbs_proof_verify(srvPK, proof, "", proofNonce, disclosed=[1,2], [expiry, level])` +2. Check `expiry >= now` +3. Store badge + verification status on contact/member + +## UI + +Badge icon next to display name for verified contacts/members. Different icons per level string. Expired badges shown differently or hidden. + +## Not in v1 + +- Badge purchase, issuance, credential storage, proof generation - v2 +- Service framework - v2 +- Payment platform integration - v2 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3610906390..ac230b7af1 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7" = "0wpri01w30rd3wwzw630yngnj9fmyb7rschl3ic1cjd926vpg9b7"; + "https://github.com/simplex-chat/simplexmq.git"."9f9b6c8e88524fb5fd063f47617a679ea53ac7c0" = "01jdjndx0h2ardzi9dd21q0n36lvwbdkhp7nzdrz01c3hh0br9bd"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f3612c88cf..3a1a8ff24b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -38,6 +38,8 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings + Simplex.Chat.Badges + Simplex.Chat.Badges.CLI Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -51,6 +53,7 @@ library Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Mobile + Simplex.Chat.Mobile.Badges Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC @@ -134,6 +137,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access + Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges else exposed-modules: Simplex.Chat.Archive @@ -290,6 +294,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access + Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges other-modules: Paths_simplex_chat hs-source-dirs: @@ -550,6 +555,7 @@ test-suite simplex-chat-test main-is: Test.hs other-modules: APIDocs + BadgeTests Bots.BroadcastTests Bots.DirectoryTests ChatClient diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c3658a1c94..5adaaca150 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Controller +import Simplex.Chat.Badges (BBSPublicKeyStr (..)) import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets @@ -65,6 +66,17 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + badgePublicKeys = + M.fromList + [ (1, toBBSPublicKey "mW_5Zp1wHnXDF56wOZwFcRjGrf0GLLsfyymIQDqYoWfjfvS7oQWSfi7hH65N8JhuE9x8wbKXHidnQLO4GnOSMP_bRKUMH1qIzv5SQKFHNM8G4PaWcTcri8iZLc-3xhSI"), + (2, toBBSPublicKey "odGCB7uVDXTURsHgSvSciByV4Q3-3ZvEB8myDsDJqm-PwOYc5-At36uc7n_pyUDxEQEHr9i4RJgFih2FSArPW-EQBXNPNf4wTtA0znn74qLEGc4fh9pVYPEIm_ZGbnsJ"), + (3, toBBSPublicKey "txkT2003WMjc43KvYvPKEcR970NLmw5UZY51eUqgk91sgp53idt1HTlKYvnrEttJDFMlctYf1-bpri0e9DhBQ-xk1J4WoLN2uif_1OcA1pGCobpk9lwtsq1Idek4biy0"), + (4, toBBSPublicKey "q_YzegihaLYrEm9z3cAghsfDGNZfXuEpQGMJERJQS4M0Szl4gvSC_fV_muKc3NIMA_8iYuBN8qyvb5U55RctCRn3kleFQ4sqf-WBgoydX6UVo7BsYcUbXWWEFZXlOGIH"), + (5, toBBSPublicKey "oqymHASH_okefShrnz4HnTooUNlE1WoDRnSrgd0bTCpOacgJWBsMpwZpdmYlX-vQAKAC_zmI4VdKoOznnhW-sdUXZw6bthCi5JYjGxCR1Co27i1tix5UXCTbR5Jp901-"), + (6, toBBSPublicKey "kDqaB6zKSRp_97QPFj5JPDlo0vzfSTLSp9goFx1qajv4q4H6dR6BbkmWZ4xx_9Q2AxmcpqcV0ethz1OH-Jk_Sz2J1mIz1PUVM9LkdLhi_PNtqhezzO5dbVs-HJ1fNqe6"), + (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), + (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") + ], confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs new file mode 100644 index 0000000000..e861d27f11 --- /dev/null +++ b/src/Simplex/Chat/Badges.hs @@ -0,0 +1,414 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges + ( BadgeType (..), + BadgeStatus (..), + BadgeInfo (..), + BadgeCredential (..), + BadgeProof (..), + LocalBadge (..), + JSONBadge (..), + BBSPublicKeyStr (..), + localBadgeInfo, + localBadgeStatus, + maxXFTPFileSize, + maxFileSizeSupporter, + maxFileSizeLegend, + BadgePresHeaderTag (..), + BadgePresHeader (..), + BadgePurchase (..), + BadgeMasterKey (..), + BadgeRequest (..), + VerifiedBadgeRequest (..), + bbsBadgeHeader, + generateMasterKey, + verifyPayment, + issueBadge, + verifyCredential, + generateBadgeProof, + badgeProof, + verifyBadge, + verifyBadge_, + mkBadgeStatus, + BadgeRow, + badgeToRow, + localBadgeToRow, + rowToBadge, + ) where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (fromRight) +import Data.Int (Int64) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.String +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) +import Simplex.FileTransfer.Description (gb, maxFileSize) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- Badge type + +data BadgeType + = BTSupporter + | BTLegend + | BTInvestor + | BTUnknown Text + deriving (Eq, Show) + +instance TextEncoding BadgeType where + textEncode = \case + BTSupporter -> "supporter" + BTLegend -> "legend" + BTInvestor -> "investor" + BTUnknown tag -> tag + textDecode s = Just $ case s of + "supporter" -> BTSupporter + "legend" -> BTLegend + "investor" -> BTInvestor + tag -> BTUnknown tag + +instance ToJSON BadgeType where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeType where + parseJSON = textParseJSON "BadgeType" + +-- Badge status + +data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey + deriving (Eq, Show) + +-- Disclosed badge content (BBS messages 1, 2, 3) + +data BadgeInfo = BadgeInfo + { badgeType :: BadgeType, + badgeExpiry :: Maybe UTCTime, + badgeExtra :: Text + } + deriving (Eq, Show) + +-- a badge expired longer than this ago is BSExpiredOld and is not shown in the UI +badgeOldInterval :: NominalDiffTime +badgeOldInterval = 31 * nominalDay + +-- the verification outcome of a received proof: Just True = verified, Just False = failed, +-- Nothing = the proof's key index is not among this app version's configured keys (BSUnknownKey). +mkBadgeStatus :: UTCTime -> Maybe Bool -> BadgeInfo -> BadgeStatus +mkBadgeStatus now verified BadgeInfo {badgeExpiry} = case verified of + Nothing -> BSUnknownKey + Just False -> BSFailed + Just True -> case badgeExpiry of + Just e + | addUTCTime badgeOldInterval e < now -> BSExpiredOld + | e < now -> BSExpired + _ -> BSActive + +-- A badge credential (own, secret) and a proof (a presentation) are independent records. +-- badgeKeyIdx is the issuer key index: it tells verifiers which configured key to use. +-- Only proofs ride the wire (in a profile); credentials come from the badge service. Neither is +-- ever serialized as a sum - each travels as its own record, so the JSON carries no credential/proof tag. + +data BadgeCredential = BadgeCredential + { badgeKeyIdx :: Int, + masterKey :: BadgeMasterKey, + signature :: BBSSignature, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +data BadgeProof = BadgeProof + { badgeKeyIdx :: Int, + presHeader :: BBSPresHeader, + proof :: BBSProof, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +-- Local badge: a stored badge plus its display status (the in-memory sum; never serialized as a sum). +-- OwnBadge - the user's own credential (loaded from the DB). +-- PeerBadge - a verified peer proof (from the DB, or received over the wire). +-- ShownBadge - decoded from a crypto-free profile JSON for display only: no crypto, so it cannot be sent. +data LocalBadge + = OwnBadge BadgeCredential BadgeStatus + | PeerBadge BadgeProof BadgeStatus + | ShownBadge BadgeInfo BadgeStatus + deriving (Eq, Show) + +localBadgeInfo :: LocalBadge -> BadgeInfo +localBadgeInfo = \case + OwnBadge BadgeCredential {badgeInfo} _ -> badgeInfo + PeerBadge BadgeProof {badgeInfo} _ -> badgeInfo + ShownBadge i _ -> i + +localBadgeStatus :: LocalBadge -> BadgeStatus +localBadgeStatus = \case + OwnBadge _ st -> st + PeerBadge _ st -> st + ShownBadge _ st -> st + +-- XFTP file size limit raised by an active badge: a legend badge to 5GB, any other to 2GB, otherwise the default. +maxFileSizeSupporter :: Int64 +maxFileSizeSupporter = gb 2 + +maxFileSizeLegend :: Int64 +maxFileSizeLegend = gb 5 + +maxXFTPFileSize :: Maybe LocalBadge -> Int64 +maxXFTPFileSize = \case + Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of + BTLegend -> maxFileSizeLegend + _ -> maxFileSizeSupporter + _ -> maxFileSize + +-- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per +-- presentation, not bound to any context; the 'T' tag marks it so master rejects it. +-- PHUnknown is the forward-compat catch-all for tags this version does not interpret. + +data BadgePresHeaderTag = PHTestTag | PHUnknownTag Char + +instance StrEncoding BadgePresHeaderTag where + strEncode = B.singleton . \case + PHTestTag -> 'T' + PHUnknownTag c -> c + strP = tag <$> A.anyChar + where + tag = \case + 'T' -> PHTestTag + c -> PHUnknownTag c + +data BadgePresHeader + = PHTest ByteString + | PHUnknown Char ByteString + +instance StrEncoding BadgePresHeader where + strEncode = \case + PHTest nonce -> strEncode PHTestTag <> nonce + PHUnknown c b -> strEncode (PHUnknownTag c) <> b + strP = + strP >>= \case + PHTestTag -> PHTest <$> A.takeByteString + PHUnknownTag c -> PHUnknown c <$> A.takeByteString + +-- v6.5.x accepts both; v7 will reject PHTest/PHUnknown +badgePresHeaderAccepted :: BadgePresHeader -> Bool +badgePresHeaderAccepted = \case + PHTest _ -> True + PHUnknown _ _ -> True + +-- Payment proof + +data BadgePurchase + = BPAppleReceipt Text + | BPGoogleReceipt Text + | BPStripeSession + | BPRedeemCode Text + deriving (Eq, Show) + +-- Master key + +newtype BadgeMasterKey = BadgeMasterKey ByteString + deriving newtype (Eq, Show, StrEncoding) + +instance ToJSON BadgeMasterKey where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON BadgeMasterKey where + parseJSON = strParseJSON "BadgeMasterKey" + +generateMasterKey :: TVar ChaChaDRG -> IO BadgeMasterKey +generateMasterKey drg = BadgeMasterKey <$> atomically (C.randomBytes 32 drg) + +-- Workflow types + +data BadgeRequest = BadgeRequest + { masterKey :: BadgeMasterKey, + badgeInfo :: BadgeInfo + } + deriving (Show) + +newtype VerifiedBadgeRequest = VerifiedBadgeRequest BadgeRequest + deriving (Show) + +-- Constants + +bbsBadgeHeader :: BBSHeader +bbsBadgeHeader = BBSHeader "SimpleX badges v1" + +bbsBadgeMessageCount :: Int +bbsBadgeMessageCount = 4 + +bbsBadgeDisclosedIndexes :: [Int] +bbsBadgeDisclosedIndexes = [1, 2, 3] + +-- Message encoding + +encodeExpiry :: Maybe UTCTime -> ByteString +encodeExpiry = maybe "lifetime" strEncode + +badgeMessages :: BadgeMasterKey -> BadgeInfo -> [ByteString] +badgeMessages (BadgeMasterKey ms) info = ms : badgeInfoMessages info + +badgeInfoMessages :: BadgeInfo -> [ByteString] +badgeInfoMessages BadgeInfo {badgeType, badgeExpiry, badgeExtra} = + [encodeExpiry badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra] + +-- Payment verification (stub - always passes) + +verifyPayment :: BadgePurchase -> BadgeRequest -> IO (Maybe VerifiedBadgeRequest) +verifyPayment _payment req = pure $ Just (VerifiedBadgeRequest req) + +-- Server-side: issue a badge credential, recording which issuer key signed it + +issueBadge :: Int -> BBSSecretKey -> VerifiedBadgeRequest -> IO (Either String BadgeCredential) +issueBadge keyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + | badgeExtra badgeInfo /= "" = pure $ Left "badgeExtra must be empty (reserved)" + | otherwise = fmap (\sig -> BadgeCredential keyIdx masterKey sig badgeInfo) <$> bbsSign sk bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: verify the credential received from server + +verifyCredential :: BBSPublicKey -> BadgeCredential -> IO Bool +verifyCredential pk (BadgeCredential _ masterKey signature badgeInfo) = + bbsVerify pk signature bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: generate a proof for a contact/group; the proof carries the credential's key index + +generateBadgeProof :: BBSPublicKey -> BadgeCredential -> BBSPresHeader -> IO (Either String BadgeProof) +generateBadgeProof pk (BadgeCredential keyIdx masterKey signature badgeInfo) ph = + fmap (\p -> BadgeProof keyIdx ph p badgeInfo) <$> bbsProofGen pk signature bbsBadgeHeader ph bbsBadgeDisclosedIndexes (badgeMessages masterKey badgeInfo) + +-- application-level proof generation with a semantic presentation header +badgeProof :: BBSPublicKey -> BadgeCredential -> BadgePresHeader -> IO (Either String BadgeProof) +badgeProof pk cred ph = generateBadgeProof pk cred (BBSPresHeader $ strEncode ph) + +-- Recipient-side: verify a badge proof with the configured key its index points to. +-- Nothing means the key index is not in the configured keys (this app version can't verify it). + +verifyBadge :: Map Int BBSPublicKey -> BadgeProof -> IO (Maybe Bool) +verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of + Nothing -> pure Nothing + Just pk -> Just <$> verifyBadgeWith pk b + +verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool +verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) + | either (const False) badgePresHeaderAccepted (strDecode phBytes) = + bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) + | otherwise = pure False + +verifyBadge_ :: Map Int BBSPublicKey -> Maybe BadgeProof -> IO (Maybe Bool) +verifyBadge_ keys = maybe (pure (Just False)) (verifyBadge keys) + +-- DB + +instance FromField BadgeType where fromField = fromTextField_ textDecode + +instance ToField BadgeType where toField = toField . textEncode + +-- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) +type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) + +-- receive/store sites have a wire proof + a computed verification outcome; +-- the status here only drives the stored verified flag, the display status is recomputed on load +badgeToRow :: Maybe BadgeProof -> Maybe Bool -> BadgeRow +badgeToRow badge verified = localBadgeToRow $ (`PeerBadge` st) <$> badge + where + st = case verified of + Just True -> BSActive + Just False -> BSFailed + Nothing -> BSUnknownKey + +localBadgeToRow :: Maybe LocalBadge -> BadgeRow +localBadgeToRow (Just lb) = case lb of + OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx) + PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Just (Binary p), Just (Binary ph), badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx) + ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing) + where + verifiedField st = case st of + BSFailed -> Just (BI False) + BSUnknownKey -> Nothing + _ -> Just (BI True) +localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing, Nothing) + +rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge +rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_, idx_) = do + btText <- type_ + bt <- textDecode btText + let info = BadgeInfo {badgeType = bt, badgeExpiry, badgeExtra = maybe "" id extra_} + -- NULL badge_verified means the key index was unknown when stored (Nothing) + st = mkBadgeStatus now (unBI <$> verified_) info + case (mk_, sg_, p_, ph_, idx_) of + (Just (Binary mk), Just (Binary sg), _, _, Just idx) -> Just $ OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) info) st + (_, _, Just (Binary p), Just (Binary ph), Just idx) -> Just $ PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) info) st + _ -> Just $ ShownBadge info st + +-- JSON + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BS") ''BadgeStatus) + +$(JQ.deriveJSON defaultJSON ''BadgeInfo) + +$(JQ.deriveJSON defaultJSON ''BadgeRequest) + +-- Each record is a plain JSON object (defaultJSON), platform-independent and with no credential/proof +-- tag - the context (a proof in a profile, a credential from the service) determines which it is. + +$(JQ.deriveJSON defaultJSON ''BadgeCredential) + +$(JQ.deriveJSON defaultJSON ''BadgeProof) + +-- LocalBadge is sent to the UI/clients WITHOUT crypto - only disclosed info + status. The credential/proof +-- bytes stay core-side. FromJSON reconstructs a display-only badge (empty proof) for read-only consumers +-- (remote host, UI echoes); the authoritative badge is loaded from the DB (rowToBadge), never from this JSON. +data JSONBadge = JSONBadge {badge :: BadgeInfo, status :: BadgeStatus} + +$(JQ.deriveJSON defaultJSON ''JSONBadge) + +instance ToJSON LocalBadge where + toJSON lb = toJSON $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + toEncoding lb = toEncoding $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + +instance FromJSON LocalBadge where + parseJSON v = do + JSONBadge info st <- parseJSON v + pure $ ShownBadge info st + +newtype BBSPublicKeyStr = BBSPublicKeyStr {toBBSPublicKey :: BBSPublicKey} + +instance IsString BBSPublicKeyStr where + fromString = BBSPublicKeyStr . fromRight (error "bad base64 in BBSPublicKey") . strDecode . B.pack diff --git a/src/Simplex/Chat/Badges/CLI.hs b/src/Simplex/Chat/Badges/CLI.hs new file mode 100644 index 0000000000..8a7cd84b61 --- /dev/null +++ b/src/Simplex/Chat/Badges/CLI.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Offline operator tooling for supporter badges, invoked as `simplex-chat badge ...`. +-- keygen - the issuer keypair: the "secret" signs, the "public" goes into the app config. +-- master-key - the user's master secret (their unlinkability secret; generated client-side in the real flow). +-- sign - bind a user master secret to a badge with the issuer secret, printed as one-line JSON for `/badge add`. +module Simplex.Chat.Badges.CLI (runBadgeCommand) where + +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Data.Time.Clock (UTCTime) +import Data.Time.Format (defaultTimeLocale, parseTimeM) +import Options.Applicative +import Simplex.Chat.Badges +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textDecode) +import System.Exit (die) + +bbsSecretLen :: Int +bbsSecretLen = 32 + +data BadgeCommand + = Keygen + | MasterKey + | Sign Int BBSSecretKey BadgeMasterKey BadgeType (Maybe UTCTime) + +runBadgeCommand :: [String] -> IO () +runBadgeCommand args = + handleParseResult (execParserPure defaultPrefs badgeInfo args) >>= \case + Keygen -> keygen + MasterKey -> genMasterKey + Sign keyIdx sk ms badgeType badgeExpiry -> sign keyIdx sk ms badgeType badgeExpiry + where + badgeInfo = info (helper <*> hsubparser badgeCmd) fullDesc + badgeCmd = command "badge" (info (helper <*> badgeCommandP) (progDesc "SimpleX supporter badge tooling")) + +badgeCommandP :: Parser BadgeCommand +badgeCommandP = + hsubparser $ + command "keygen" (info (pure Keygen) (progDesc "generate an issuer keypair (issuer secret + public, base64url)")) + <> command "master-key" (info (pure MasterKey) (progDesc "generate a user master secret (base64url)")) + <> command "sign" (info signP (progDesc "sign a badge for a user master secret, printed as one-line JSON")) + where + signP = + Sign + <$> option auto (long "key-idx" <> metavar "KEY_IDX" <> help "index of the issuer key in the app config") + <*> option (eitherReader secretR) (long "secret" <> metavar "ISSUER_SECRET" <> help "issuer secret from keygen (base64url)") + <*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)") + <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)") + <*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'") + secretR s = do + sk@(BBSSecretKey b) <- strDecode (B.pack s) + if B.length b == bbsSecretLen + then Right sk + else Left "bad issuer secret - use the 'secret' value from keygen" + badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack + expireR = \case + "lifetime" -> Right Nothing + s -> maybe (Left "use 'lifetime' or YYYY-MM-DD") (Right . Just) $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s + +keygen :: IO () +keygen = + bbsKeyGen >>= \case + Left e -> die $ "keygen failed: " <> e + Right (BBSPublicKey pk, BBSSecretKey sk) -> do + B.putStrLn $ "secret " <> strEncode sk + B.putStrLn $ "public " <> strEncode pk + +genMasterKey :: IO () +genMasterKey = do + drg <- C.newRandom + mk <- generateMasterKey drg + B.putStrLn $ strEncode mk + +sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO () +sign keyIdx secretKey masterKey badgeType badgeExpiry = do + let req = VerifiedBadgeRequest (BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} :: BadgeRequest) + issueBadge keyIdx secretKey req >>= \case + Left e -> die $ "sign failed: " <> e + -- single-line JSON (master secret + signature + info), pasted into the app via `/badge add` + Right cred -> LB.putStrLn $ J.encode cred diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c92c1f9e09..fbb8536fcf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -81,6 +81,8 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption) @@ -137,6 +139,8 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation + badgePublicKeys :: Map Int BBSPublicKey, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -172,7 +176,7 @@ data ChatConfig = ChatConfig -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt -mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys {-# INLINE mkStoreCxt #-} data RandomAgentServers = RandomAgentServers @@ -575,6 +579,7 @@ data ChatCommand | SetBotCommands [ChatBotCommand] | UpdateProfile ContactName (Maybe Text) -- UserId (not used in UI) | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 3c1ce9bc26..e51f7a40e8 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -138,7 +138,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case loop = do displayName <- T.pack <$> withPrompt "display name: " getLine createUser loop $ mkProfile displayName - mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} createUser onError p = execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = chatRelay}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f35a9ef177..43f480b5c4 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -55,6 +55,7 @@ import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber +import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -363,16 +364,16 @@ processChatCommand cxt nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p userChatRelay True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchAllErrors` \_ -> pure () + createPresetContactCards db cxt user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () - createPresetContactCards db user = do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile + createPresetContactCards :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO () + createPresetContactCards db cxt user = do + createContact db cxt user simplexStatusContactProfile + createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) chooseServers user_ = do as <- asks randomAgentServers @@ -1941,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 user incognitoProfile $ 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 @@ -1963,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 Nothing (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1982,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 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 ccLink' <- shortenCreatedLink ccLink @@ -2259,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 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 ccLink' <- shortenCreatedLink ccLink @@ -3143,7 +3147,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile - let p = 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 @@ -3293,6 +3297,7 @@ processChatCommand cxt nm = \case fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) + AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3520,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 - let profileToSend = 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 @@ -3624,7 +3629,7 @@ processChatCommand cxt nm = \case relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) @@ -3667,11 +3672,12 @@ processChatCommand cxt nm = \case joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection 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 - let profileToSend = case gInfo_ of - Just gInfo_' -> - let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' - in userProfileInGroup' user allowSimplexLinks incognitoProfile - Nothing -> userProfileDirect user incognitoProfile Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True chatEvent <- case gInfo_ of Just (Just gInfo) | useRelays' gInfo -> do let GroupInfo {membership = GroupMember {memberId}} = gInfo @@ -3688,12 +3694,12 @@ processChatCommand cxt nm = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: CryptoFile -> CM Integer - checkSndFile (CryptoFile f cfArgs) = do + checkSndFile :: Maybe LocalBadge -> CryptoFile -> CM Integer + checkSndFile sndBadge (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs - when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f + when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' @@ -3723,7 +3729,7 @@ processChatCommand cxt nm = \case case changedCts_ of Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do - let idsEvts = L.map ctSndEvent changedCts + idsEvts <- mapM ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CEvtChatErrors errs @@ -3747,8 +3753,11 @@ processChatCommand cxt nm = \case mergedProfile = userProfileDirect user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False - ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, Nothing, XInfo mergedProfile') + -- 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' Nothing mergedProfile' + pure (ConnectionId connId, Nothing, XInfo p) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> @@ -3756,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 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 | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} @@ -3779,7 +3788,8 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView + 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' runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse @@ -4065,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 @@ -4092,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 @@ -4261,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} = @@ -4325,7 +4335,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if contactConnIncognito ct then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4406,7 +4417,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers n = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4641,6 +4653,28 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- attach an issued badge credential to the user's own profile and present it to all current contacts. +-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). +addUserBadge :: User -> BadgeCredential -> CM () +addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do + keys <- asks $ badgePublicKeys . config + key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys + verified <- liftIO $ verifyCredential key cred + unless verified $ throwCmdError "badge credential does not verify against configured key" + now <- liftIO getCurrentTime + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + asks currentUser >>= atomically . (`writeTVar` Just user') + cxt <- asks $ mkStoreCxt . config + contacts <- withFastStore' $ \db -> getUserContacts db cxt user' + withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + case contactSendConn_ ct of + Right conn + | not (connIncognito conn) -> do + let ct' = updateMergedPreferences user' ct + p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False + void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView + _ -> pure () + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5241,6 +5275,7 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNameDescr), ("/profile" <|> "/p") $> ShowProfile, + "/badge add " *> (AddBadge <$> jsonP), "/set bot commands " *> (SetBotCommands <$> botCommandsP), "/delete bot commands" $> SetBotCommands [], "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), @@ -5378,7 +5413,7 @@ chatCommandP = quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' newUserP userChatRelay = do (cName, shortDescr) <- profileNameDescr - let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space @@ -5386,7 +5421,7 @@ chatCommandP = let preferences = case files_ of Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} - profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences} + profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = False} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f2c448d5b8..68e870a7c5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,6 +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 (BadgeCredential (..), BadgePresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -906,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) - let profileToSend = 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) @@ -919,7 +920,7 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - let profileToSend = 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 @@ -947,8 +948,9 @@ acceptGroupJoinRequestAsync memberKey_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -964,7 +966,6 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -982,8 +983,9 @@ acceptGroupJoinSendRejectAsync cReqXContactId_ rejectionReason = do gVar <- asks random + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -994,7 +996,6 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -1197,8 +1198,8 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g redactedMemberProfile :: Bool -> Profile -> Profile -redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType} = - Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType, badge} where removeSimplexLink s | allowSimplexLinks = Just s @@ -1895,6 +1896,33 @@ sendDirectContactMessages' user ct events = do forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc' pure sndMsgs' +-- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. +-- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. +-- a long-expired badge is not presented at all (receivers would hide it anyway). +presentUserBadge :: User -> Maybe i -> Profile -> CM Profile +presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of + (Nothing, Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) st)) | st == BSActive || st == BSExpired -> do + keys <- asks $ badgePublicKeys . config + case M.lookup keyIdx keys of + Nothing -> p <$ logError "presentUserBadge: badge key index not in config" + Just key -> do + nonce <- drgRandomBytes 16 + liftIO (badgeProof key cred (PHTest nonce)) >>= \case + Right proof -> pure p {badge = Just proof} + Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) + _ -> pure 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 + keys <- asks $ badgePublicKeys . config + verified <- liftIO $ verifyBadge keys 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 @@ -2102,8 +2130,9 @@ sendGroupMessages user gInfo scope asGroup members events = do sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members' profileUpdateEvent + -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented + 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 @@ -2837,7 +2866,8 @@ simplexTeamContactProfile = image = Just simplexChatImage, contactLink = Just $ CLFull adminContactReq, peerType = Nothing, - preferences = Nothing + preferences = Nothing, + badge = Nothing } simplexStatusContactProfile :: Profile @@ -2849,7 +2879,8 @@ simplexStatusContactProfile = image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAr6ADAAQAAAABAAAArwAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgArwCvAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQAC//aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Q/v4ooooAKKKKACiiigAoorE8R+ItF8J6Jc+IvEVwlrZ2iGSWWQ4CgVUISlJRirtmdatTo05VaslGMU223ZJLVtvokbdFfl3of/BRbS734rtpup2Ig8LSsIYrjnzkOcea3bafTqBX6cafqFjq1jFqemSrPbzqHjkQ5VlPIINetm2Q43LXD65T5eZXX+XquqPiuC/Efh/itYh5HiVUdGTjJWaflJJ6uEvsy2fqXKKKK8c+5Ciq17e2mnWkl/fyLDDCpd3c4VVHJJJr8c/2kf8Ago34q8M3mpTfByG3fT7CGSJZrlC3nStwJF5GFU8gd69LA5VicXTrVaMfdpxcpPokk397toj4LjvxKyLhGjRqZxValVkowhFc05O9m0tPdjfV7dN2kfq346+J3w9+GWlPrXxA1m00i1QZL3Uqxj8Mnn8K/Mj4tf8ABYD4DeEJ5dM+Gmn3niq4TIE0YEFtn/ffBI+imv51vHfxA8b/ABR1+bxT8RNUuNXvp3LtJcOWCk84VeigdgBXI18LXzupLSkrL72fzrxH9IXNsTKVPKKMaMOkpe/P8fdXpaXqfqvrf/BYH9p6+1w3+iafo1jZA8WrRPKSPeTcpz9BX1l8J/8Ags34PvxDp/xn8M3OmSnAe709hcQfUoSHA/A1/PtSE4/GuKGZ4mLvz39T4TL/ABe4swlZ1ljpTvvGaUo/dbT/ALdsf2rfCX9pT4HfHGzF18M/EdnqTYBaFXCzJn+9G2GH5V7nX8IOm6hqGkX8eraLcy2d3EcpPbuY5FPsykGv6gf+CWf7QPxB+OPwX1Ky+JF22pX3h69+yJdyf62WJlDrvPdlzjPevdwGae3l7OcbP8D+i/DTxm/1ixkcqx2H5K7TalF3jLlV2rPWLtqtWvM/T2iiivYP3c//0f7+KKKKACiiigAooooAK/Fv/goX8Qvi2fFcXgfWrRtP8NDEls0bZS7YfxORxlT0Xt1r9pK8u+L/AMI/Cfxp8F3HgvxbFujlGYpgB5kMg6Op9R+tfR8K5vQy3MYYnE01KK0843+0vNf8NZn5f4wcFZhxTwziMpy3FOjVeqSdo1Lf8u5u11GXk97Xuro/mBFyDX3t+yL+2Be/CW+h8B+OHafw7cyALIxJa0Ldx6p6jt1FfMvx/wDgR4w/Z+8YN4d8RoZrSbLWd4owk6D+TDuK8KF0K/pLFYHA51geWVp0pq6a/Brs1/wH2P8ALvJsz4h4D4h9tR5qGLoS5ZRls11jJbSjJferSi9mf1uafqFlqtlFqWmyrPBOoeORDlWU8gg069vrPTbSS/v5FhghUu7ucKqjqSa/CH9j79sm++EuoQ/D/wAeSNceHbmRVjlZstZk9x6p6jt2q3+15+2fffFS8n8AfD2V7bw9CxWWZThrwj+Se3evxB+G2Zf2n9TX8Lf2nTl/+S/u/PbU/v2P0nuGv9Vf7cf+9/D9Xv73tLd/+ffXn7afF7pqftbfth3nxUu5vAXgGR7fw/A5WWUHDXZX19E9B361+Z/xKm3eCL9R3UfzFbQul6Cn+I/A3ivxR8LPEXivSbVn07RoVkurg8Iu5gAue7HPSv1HOsrwmVcN4uhRSjBUp6vq3Fq7fVt/5I/gTNeI884x4kjmeYOVWtKSdop2hCPvWjFbQjFNv5ybbuz4Toqa0ge9uoLOIhWnkSNSxwAXIUEnsBnmv0+/aK/4Jg+O/gj8Hoviz4b1n/hJFt40l1G2ig2NDG4yZEIJ3KvfgHHNfxVTw9SpGUoK6W5+xZVw1mWZYfEYrA0XOFBKU2raJ31te72b0T0R+XRIAyegr+gr/glx+yZoHhjwBc/tKfFywiafUY2OmpeIGS3sVGWmIbgF+TkjhR71+YP7DX7Lt9+1H8ZLfR75WTw5pBS61ScDKsoIKwg+snf0Ffqd/wAFSv2o4Phf4Ltv2WvhmVtrjUbRBfvA2Ps1kOFhAHQyAc9ML9a9HL6UacHi6q0W3mz9Q8M8owuV4KvxpnEL0aN40Yv/AJeVXpp5LZPo7v7J+M/7U/jX4e/EL4/+JfFXwrsI9P0Ke5K26RKESTZw0oUcAOeQBX7J/wDBFU5+HPjYf9RWH/0SK/nqACgKOgr+hT/giouPh143b11SH/0SKWVzc8YpPrf8jHwexk8XxzSxVRJSn7WTSVknKMnoui7H7a0UUV9cf3Mf/9L+/iiiigAoorzX4wfGD4afAP4bav8AF74v6xbaD4d0K3e6vb26cJHHGgyevUnoAOSeBTjFyajFXYHpVFf55Xxt/wCDu34nj9vzS/G3wX0Qz/ArQ2ksLnSp1CXurQyMA15uPMTqBmJD2+914/uU/Y//AGxfgH+3P8ENL+P37OutxazoWpoNwHyzW02PmhmjPKSKeCD9RxXqY/JcXg4QqV4WUvw8n2ZnCrGTaTPqGiiivKNDy/4u/CLwd8afBtx4N8ZW4kilBMUoH7yGTs6HsR+tfzjftA/AXxl+z54yfw34jQzWkuXs7xF/dzR/0YdxX9OPiDxBofhPQ7vxN4mu4rDT7CF57m4ncJHFFGMszMcAAAZJNf53n/Bav/g5W1H4ufGjTvg5+xB5F14E8JX4l1HVriIE6xNE2GjhLDKQdRuGC55HHX9L8Os+x2ExP1eKcsO/iX8vmvPy6/ifg3jZ4NYDjDBPFUEqeYU17k/50vsT8n0lvF+V0fq0LhTUgnA4r4y/ZG/bJ+FX7YXw9HjDwBP5N/ahV1LTZeJrSUjoR3U/wsOK+sRdL/n/APXX9G0nCrBTpu6Z/mVmuSYvLcXUwOPpOnWg7SjJWaf9ap7NarQ+pf2dP2evGH7Q3i4aLogNvp1uQ15esMpEnoPVj2Ffrd+1V8GvDnw5/YU8X+APh/Z7IrewEjYGXlZGUs7nqSQM18C/sO/ti6b8F7o/Dnx6qpoN9LvS6RRvglbjL45ZT69vpX7wX1poHjjwxNYzbL3TdUt2jbaQySRSrg4PoQa/nnxXxGaTxLwmIjy4e3uW2lpu33Xbp87v+7Po58I8L4nhfFVMuqKeY1oTp1nJe9S5k0oxWtoPfmXxve1uVfwqKA0YHYiv6Ev+CZ37bVv490eP9mb4zXAn1GKJo9Murg5F3bgYMLk9XUcD+8tflR+1/wDsn+Nv2XfiNdadqFs8vh28md9Mv1GY3iJyEY9nXoQa+UrC/v8ASr+DVdJnktbq2dZYZomKvG6nIZSOhFfztQrVMJW1Xqu5+Z8PZ5mvBWeSc4NSg+WrTeinHqv1jL56ptP+s7xHZ/A//gnR8EfE/jTwra+RHqF5JdxWpbLTXcwwkSnrsGPwXNfyrfEDx54l+J/jXU/iB4wna51LVZ3nmdj3Y8KPQKOAPQV2vxX/AGhvjT8corC3+K2vz6vFpq7beNgERT3YqvBY92NeNVeOxirNRpq0Fsju8RePKWfTo4TLqPscFRXuU9F7z+KTSuvJK7srvqwr+ir/AIIuaVd2/wAH/FesSIRDd6uFjb+8Y41Dfka/BX4YfCzx78ZfGVr4C+G+nyajqV22Aqj5I17u7dFUdya/r+/ZV+Aenfs2fBLSPhbZyC4ntVaW7nAx5tzKd0jfTJwPYV1ZLQk63tbaI+w8AOHcXiM8ebcjVClGS5ujlJWUV3sm27baX3R9FUUUV9Uf2gf/0/7+KKKKACv4If8Ag8QT9vN9W8IsVk/4Z+WJedOL7f7Xyd32/HGNu3yc/LnPev73q84+Lnwj+G/x3+HGr/CT4uaRba74d123e1vbK6QPHJG4weD0I6gjkHkV6WUY9YLFQxDgpJdP8vMipDmi0f4W1frt/wAEhP8Agrt8af8AglD8b38V+Fo21zwPr7xp4i0B3KpcRoeJoTyEnjBO04+boeK+m/8AguZ/wQz+I3/BMD4kyfEn4Ww3fiD4Oa5KzWWolC76XKx4tbphwOuI3PDAc81/PdX7LCeFzHC3VpU5f18mjympU5eZ/t9fsk/tb/Av9tv4G6N+0F+z3rUWs6BrEQYFCPNt5cfPDMnVJEPDKf5V794h8Q6F4T0O78TeJ7uGw06wiae4uZ3EcUUaDLMzHAAA6k1/j9f8EiP+Cunxv/4JTfHAeKPCZfWfAuuyRx+IvD8jkRTxg486Lsk8YJ2n+Loa/V7/AILy/wDBxZd/t2eHl/Zc/Y6mu9I+Gl1DDNrWoSBoLvUpGAY2+OqQoeH/AL5GOlfneI4OxCxio0taT+12Xn59u53xxMeW73ND/g4M/wCDgzVP2yNV1H9jz9j3UZrD4ZWE7waxrEDlH110ONiEYItgQe/7z6V/I6AAMDgCgAKNo6Cv0j/4Jkf8Ex/j/wD8FOvj/Y/Cj4UWE9voFvNGdf18xk2um2pPzEt0MhGdiZyTX6FhsNhctwvLH3YR1bfXzfn/AEjhlKVSR77/AMEMf2Rf2v8A9qr9tPRrb9mNpdL0fSp438UaxKjNYW+nk/PHKOA7uoIjTrnniv7Lfj98CvG37PPjiXwj4uiLxNl7S7UYjuIuzD39R1Ffvt+wn+wd+z5/wTy+A+n/AAF/Z70pbKyt1V728cA3V/c4w0079WYnoOijgV7V8cPgb4G+Pngqfwb41twwYEwXCgebBJ2ZT/MdDXi5N4mTwmYWqRvhXpb7S/vL9V28z8c8YfBXC8XYL61hbQx9Ne7LpNfyT8v5ZfZfkfyXi5r9Lf2Jv24bn4S3UHwz+JkzT+HZ5AsNy5LNZlu3vHn8q+KPj38CPHf7PPjabwn4yt2ELMxtLsD91cRg8Mp6Z9R2rxAXAPANfuePyzL89y/2c7TpTV1JdOzT6Nf8Bn8C5FnGfcEZ79Yw96OJpPlnCS0a6xkusX/k4u9mf2IeK/B/w++Mngt9C8U2ltrWi6lEGCuA6OrDhlPY+hHNfztftw/8E4tN+AGlTfE34ba3HJo0koVdMvGC3CFv4Ym/5aAenBArvf2PP2+9R+CGmv4B+JSy6joEUbtaOp3TQOBkRj1Rjx7V8uftEftH+Nf2i/G7+KPEzmG0hyllZqT5cEef1Y9zX4LT8GMTisynhsY7UI6qot5J7Jefe+i87o/prxI8YuEM/wCF6WM+rc2ZSXKo6qVJrdykvih/Ktebsmnb4DkilicxyqVYdQRzXUaN4R1HVMSzjyIf7zDk/QV6dIlpJIJ5Y1Z16MRk1+qf7DX7Ed58ULmH4p/Fe2kt/D8Dq9paSDabwjncf+mf/oX0rKXg3lOR+0zDPMW6lCL92EVyufZN3vfyjbvdI/AeFsJnHFOPp5TktD97L4pP4YLrJu2iXnq3ok20es/8Erv2f/G/gf8AtD4ozj7Bo2pwiFIpY/3t2VOQ4J5VFzx659q/aKq9paWthax2VlGsUMShERBtVVHAAA6AVYr4LNcdTxWIdSjRjSpqyjGKslFber7t6tn+k3APB1LhjJaOUUqsqjjdylJ/FKTvJpfZV9orbzd2yiiivNPsj//U/v4ooooAKKKKAPO/iz8Jvh18c/h1q/wm+LGk2+ueHtdt3tb2yukDxyxuMEEHoR1B6g81/lm/8Fy/+CFfxG/4Jh/ENvid8J4bzxF8Htdmke1vliaRtHctxbXTAEBecRyHAbGDzX+q54j8R6B4Q0C88U+KbyHT9N0+F7i5ubhxHFFFGMszMcAADqa/zM/+Dhb/AIL06p+3f4rvP2Tf2Xr6S0+Eui3DR397GcHXriM8N7W6EfIP4jz6V9fwfPGLFctD+H9q+3/D9jmxKjy+9ufyq0UAY4or9ZPMP0v/AOCX3/BLf9oT/gqP8d4Phf8ACa0lsvDtjLG3iDxDJGTa6bbse56NKwB8uPOSfav9ZX9hD9hT4Df8E8v2fdK/Z7+AenLbWNkoe8vHUfab+6I+eeZhyWY9B0UcCv8AKC/4JUf8FV/j1/wSu+PCfEf4aSHUvC+rPHH4i0CViIL63U43D+7MgJKN+B4r/Wd/Yy/bM+BH7eHwH0j9oL9n7Vo9S0fU4182LI8+0nx88MydVdTxz16ivzbjZ43nipfwOlu/n59uh6GE5Labn1ZRRRXwB2Hi3x3+BPgj9oHwJceCPGcIIYFre4UfvYJezKf5jvX8vH7QvwB8d/s4eOZfB/jKEtDIS9neKP3VxFngqfX1Hav6gvj58e/An7PHgK48ceN7gLtBW2twf3txL2RR/M9hX8rX7Qn7Rnjz9o3x5L418ZyhUXKWlqh/dW8WeFUevqe5r988G4Zu3Ut/ueu/839z/wBu6fM/jj6UdPhlwo8y/wCFTS3Lb+H/ANPf/bPtf9unlQuAec077SPWueFznrTxc1+/eyP4udE/XX9g79h24+K8tv8AF74qQvD4fgkDWdo64N4V53H/AKZg/wDfX0r+ge0tLWwtY7KyjWKGJQiIgwqqOAAOwFfzc/sIft2XnwO1KH4ZfEeVp/Ct5L8k7Es9k7YHH/TMnkjt1r+kDTNT07WtOg1fSJ0ubW5QSRSxncjowyCCOoNfyr4q0s3jmreYfwtfZW+Hl/8Akv5r6/Kx/or9HSXDX+rqhkqtidPb81vac/d/3P5Lab/auXqKKK/Lz+gwooooA//V/v4ooooAKxfEniTQPB2gXnirxVew6dpunQvcXV1cOI4oYoxlndjgAADJJrar/PV/4Ozf+CiX7Xlr8Yrf9hCx0u98GfDaS0iv5L1GZT4iZs5HmKceTERgx9d3LcYr08py2eOxMaEXbu/L9SKk1CN2fIX/AAcD/wDBfrXv27vFF1+yx+ylqFzpnwl0id476+icxSa/MhwGOMEWykHYv8fU9hX8qoAAwOAKUAAYFfqj/wAEnf8AglH8cv8Agqp8ek+Hvw/R9M8I6NJFJ4k19lzHZW7k/ImeGmcAhF/E8V+xUKGFyzC2Xuwju/1fds8tuVSXmM/4JQ/8Epfjr/wVU+Pcfw5+HiPpXhPSXjl8ReIZEJhsoGP3E7PO4B2J+J4r7o/4Li/8EC/H3/BL/UYPjH8Hp7vxV8JNQMcL3sy7rnTLkgDbcFRjZI3KPwATg9q/0rP2MP2MPgL+wZ8BdI/Z5/Z60hNM0bS4x5kpANxeTn7887gAvI55JPToOK9y+J/ww8AfGfwBqvwu+KOlW+t6Brdu9re2V0gkilicYIIP6HqDXwVbjSu8YqlNfulpy9139e3Y7VhY8tnuf4VdfqD/AMErP+Cpvx1/4Jb/ALQNn8S/h7cS6j4VvpUj8QeH2kIt723zgsB0WVRyjetffn/BeH/ghJ4x/wCCZvjlvjP8EYbvXPg5rk7GKcqZJdGmc5FvOwH+rOcRyH0wea/nCr9ApVcNmOGuvehL+vk0cLUqcvM/24v2Mf20PgH+3l8CdK/aA/Z61iPVNI1FF86LI+0Wc+PnhnTqjqeOevUcV3nx/wD2gfh/+zp4CuPHHjq5CBQVtrZT+9uJeyIP5noBX+Ud/wAEL/25f2t/2NP2u7A/s7xPrPhzW5Yk8T6LOzCyls1PzTE9I5UXJRupPHIr+p39o79pXx/+0v8AEGbxv42l2RrlLO0QnyreLPCqPX1PUmvM4b8KauYZg5VJWwkdW/tP+6vPu+i8z8r8VvF3D8L4P6vhbTx017sekF/PL/21fafkjV/aF/aN8e/tHePZ/GvjOc+XuK2lopPlW8WeFUevqe9eFfasDmsL7UB1r9kv+Cen/BPuX4mPa/Gv41Wrw6HE4k0/T5FwbsjkO4PPl56D+L6V/QWbZjlnDmW+1q2hSgrRit2+kYrq/wDh2fw9kXDmdcZ526NK9SvUfNOctorrKT6JdF6JIh/Yq/4JyXXxq8MSfEn4wtPpukXkLLp1vH8s0hYcTHPRR1Ud6+KP2nP2bvHX7MXj+Twl4pUz2U+Xsb5QRHcRZ/Rh/Etf2D2trbWNtHZ2caxRRKEREGFVRwAAOgFeSfHL4G+Af2gvAVz4A8f2wmt5huimUDzYJB0dD2I/Wv5/yrxgx0c3niMcr4abtyL7C6OPdrr/ADeWlv604g+jdlFTh6ngsrfLjaauqj/5eS6xn2i/s2+Hz1v/ABi+d3r9O/2DP28r/wCBGpRfDT4lSvdeFL2UBJmYs9izcZX1j7kduor48/ah/Zr8bfsu/EWTwZ4pHn2c4MtheqMJcQ5IB9mHRhXzd9oAFf0Djsuy3iHLeSdqlGorpr8Gn0a/4DW6P5DyrMc74Mzz2tG9LE0XaUXs11jJdYv/ACaezP7pdK1bTNd02DWdGnS6tLlBJFLEwZHRuQQR1FaFfix/wSG1n47X3hPVLHXUL+BoT/oEtxneLjPzLD6pjr2B6d6/aev424nyP+yMyrZf7RT5Huvv17NdV0Z/pTwPxP8A6w5Lh82dGVJ1FrGXdaNp9YveL6oKKKK8A+sP/9b+/iiiigAr4E/4KI/8E4f2b/8AgpZ8DLr4M/H7SklljV5NJ1aJQLzTblhxLC/Uc43L0YcGvvuitKNadKaqU3aS2Ymk1Zn+Vt8Nf+DZH9vDxJ/wUEn/AGQfGti+m+DdMkF5eeNlTNjLpRb5Xgz964cfL5XVWyTx1/0lv2L/ANif9nv9gn4H6b8Bv2dNDh0jSrFF8+YKDcXs4GGmuJOskjHPJ6dBxX1lgZz3pa9bNc+xWPjGFV2iui6vu/60M6dKMNgooorxTU4T4m/DHwB8ZfAeqfDH4paRba7oGtQPbXtjeRiWGaJxghlII/wr/M//AOCw/wDwbq/En9kb9o7Ttc/ZhQ6h8KvGl4VgknkUyaJIxy0UmTueMDmNgCexr/SN/aA/aA+Hf7N3w6u/iL8RbtYYIFIggBHm3Ev8Mca9yfyA5NfyB/tTftZfEX9qv4gSeL/GEv2exgLJYWEZPlW8WeOO7H+Ju9fsXhRwnmOZYl4hNwwi+Jv7T/lj5930Xnofj3iv4nYThrCPD0bTxs17kekV/PPy7L7T8rn58fs1fs1/Df8AZg8Dp4U8CwB7qYK19fuAZrmQDkseyjsvQV9GfaWrAWcjvUnnt6mv62w+Cp0KapUo2itkfwFmOLxWPxNTGYyo51Zu8pN6t/1stktEftx/wTa/YHsfi6sHx2+L8aT6BFJnT7DcGFy6dWlAzhQf4T171/SBaWltY20dlZRrFDEoREQYVVHAAA6AV/Hv+xJ+3N4y/ZO8Wi0ui+oeE9QkX7dYk5KdjLFzw49Ohr+tj4c/Efwb8WPB1l498A30eoaZqEYkiljOevVWHZh0IPIr+TPGXLs6p5p9Zxz5sO9KbXwxX8rXSXd/a3Wmi/t76P8AmHD08l+qZZDkxUdaydueT/mT0vDsl8Oz1d33FFFFfjR/QB4x8dPgN8O/2hvA1x4F+Idms8MgJhmAxLbydnjbqCP1r8RPg3/wSV8Z/wDC9r7T/izMreDNIlEkM8TYfUVPKpgcoAPv+/Ar+iKivrsh43zbKMLWweCq2hUXXXlf80eza0/HdJnwPFHhpkHEGOw+YZlQ5qlJ7rTnXSM/5op6/hs2jD8NeGdA8HaHbeGvC9nFYWFmgjhghUIiKOwArcoor5Oc5Tk5Sd292fd06cacVCCtFaJLZLsgoooqSz//1/7+KKKKACiiigAooooAK8J/aK/aG+H37M/wzvPiX8QrgRwwDbb26kebczH7saDuSep7DmvdW3bTt69s1/Hj/wAFS9c/acu/2hbiw+Psf2fTYWf+w47bd9ha2zw0ZPWQj7+eQfav0Dw44PpcRZssLXqqFOK5pK9pSS6RXfu+i1PzvxN4zrcN5PLF4ei51JPli7XjFv7U327Lq9Dwr9qv9rn4lftZ+Pv+Ev8AG8i29na7ksNPiJ8m2iJ7Ak5Y/wATHrXy/wDacDJNYfn45PFftR/wTX/4Ju6j8aryz+OXxttpLXwtbSrJY2Mi7W1Bl53MD0hB/wC+vpX9jZpmGU8LZT7WolTo01aMVu30jFdW/wDNvqz+HcryTOeLs4dODdSvUd5Tlsl1lJ9Eui9Elsix/wAE8/8Agmpc/Hq3HxZ+OcFxY+F8f6Daj93Jen++eMiMdum76V88ft4fsM+LP2RvGH9p6MJtS8G6gxNnfMMmFj/yxmIAAYfwnuPev7DbGxs9Ms4tP0+JYIIFCRxoNqqq8AADoBXL+P8AwB4R+KHhG+8C+OrGPUNM1CMxTQyjIIPcehHUEdDX8x4PxqzWOdvH11fDS0dJbKPRp/zrdvrtta39V47wCyWeQRy7D6YqOqrPeUuqkv5Hsl9ndXd7/wACwuGHevvT9iL9u7x1+yP4n+wMDqXhPUJVN/YMTlOxlh/uuB+BqH9vD9hXxl+yD4v/ALS03zNT8HajIfsV8VyYSf8AljNjgMOx/iHvX59C6bHav6fjDKeJsqurVcPVX9ecZRfzTP5LdLOeE850vRxNJ/15SjJfJo/v3+GnxJ8HfF3wRp/xC8BXiX2l6lEJYZEPr1Vh2YdCDyDXd1/PD/wRa8KftJW8moeKfPNp8N7kMBBdKT9ouR/Hbgn5QP4m6Gv6Hq/iHjXh6lkmb1svoVlUjF6Nbq/2ZdOZdbfhsf6AcC8SVs9yahmWIoOlOS1T2dvtR68r3V/x3ZRRRXyh9eFFFFABRRRQB//Q/v4ooooAKKKKACiiigAr5u/aj/Zg+HX7VvwyuPh14+i2N/rLO8jA861mHR0Pp2YdCOK+kaK6sDjq+DxEMVhZuFSDumt00cmOwOHxuHnhcVBTpzVpJ7NM/nF/ZW/4I2eINL+MV9rH7Rk0Vz4d0G5H2GCA8anjlXfuiDjcvJJ46V/RfY2FlpdlFpumxJBbwII444wFVEUYAAHAAFW6K9/injHM+Ia8a+Y1L8qsorSK7tLu3q3+iSPn+E+C8q4dw86GW07czvJvWT7Jvstkv1bYUUUV8sfVnEfEb4c+Dvix4Mv/AAB49sY9Q0vUYjFNDIMjB7j0YdQRyDX4HeH/APgiNJB+0LKNe1vzvhzARcxBeLyUEn/R27ADu46jtmv6KKK+r4d42zjI6Vajl1ZxjUVmt7P+aN9pW0uv0R8lxJwNk2e1aFfMqCnKk7p7XX8srbxvrZ/qzn/CnhXw/wCCPDll4R8K2sdlp2nQrBbwRDCoiDAAFdBRRXy05ynJzm7t6tvqfVwhGEVCCsloktkgoooqSgooooAKKKKAP//R/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Z"), contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), peerType = Just CPTBot, - preferences = Nothing + preferences = Nothing, + badge = Nothing } timeItToView :: String -> CM' a -> CM' a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 87b560d1ab..f8cb2b861c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -437,9 +437,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- [incognito] send saved profile (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + profileToSend <- + 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 allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -555,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 - let p = 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 @@ -566,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) - let profileToSend = 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" @@ -798,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) - let profileToSend = 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" @@ -813,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 = 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 @@ -921,7 +922,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = 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 @@ -1170,7 +1171,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1184,8 +1185,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo - profileToSend = userProfileInGroup user gInfo incognitoProfile + incognitoProfile = incognitoMembershipProfile gInfo + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) memberPubKey <- case groupKeys gInfo of Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" @@ -2550,14 +2551,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processContactProfileUpdate :: Contact -> Profile -> Bool -> CM Contact processContactProfileUpdate c@Contact {profile = lp} p' createItems - | p /= p' = do + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify lp = do c' <- withStore $ \db -> if userTTL == rcvTTL - then updateContactProfile db user c p' + then updateContactProfile db cxt user c p' else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' - updateContactProfile db user c' p' - when (directOrUsed c' && createItems) $ do + updateContactProfile db cxt user c' p' + when (contentChanged && directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' toView $ CEvtContactUpdated user c c' @@ -2565,6 +2567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure c where + contentChanged = not (sameProfileContent p p') p = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2667,22 +2670,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ - | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do - updateBusinessChatProfile gInfo + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify p = do + when contentChanged $ updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' + m' <- withStore $ \db -> updateMemberProfile db cxt user m p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' toView $ CEvtContactUpdated user mCt ct' pure m' @@ -2696,6 +2700,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where + contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do @@ -2976,7 +2981,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus gInfo' <- if memberPending newMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3028,7 +3033,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = MemberInfo mId mRole v p _ | mRole == GROwner -> MemberInfo mId mRole v p Nothing _ -> memInfo - void $ withStore $ \db -> createIntroReMember db user gInfo memInfo' memRestrictions + void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions | otherwise -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) case memChatVRange of @@ -3040,7 +3045,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do - reMember <- createIntroReMember db user gInfo memInfo memRestrictions + reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -3075,7 +3080,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo GCPostMember GSMemAnnounced e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? @@ -3085,8 +3090,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + 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 @@ -3385,7 +3390,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createItems mCt m' joinConn subMode = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user Nothing True connReq dm subMode diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 281fc6b03b..d932194934 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -38,6 +38,7 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -136,6 +137,10 @@ foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt +foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString + +foreign export ccall "chat_badge_issue" cChatBadgeIssue :: CString -> IO CJSONString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString diff --git a/src/Simplex/Chat/Mobile/Badges.hs b/src/Simplex/Chat/Mobile/Badges.hs new file mode 100644 index 0000000000..91e90e16c3 --- /dev/null +++ b/src/Simplex/Chat/Mobile/Badges.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Mobile.Badges + ( cChatBadgeKeygen, + cChatBadgeIssue, + BadgeResult (..), + BadgeIssueReq (..), + IssuerKeyPair (..), + ) +where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString as B +import Data.Text (Text) +import qualified Data.Text as T +import Foreign.C (CString) +import Simplex.Chat.Badges +import Simplex.Chat.Mobile.Shared (CJSONString, newCStringFromLazyBS) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Parsers (defaultJSON) + +-- FFI envelope for a generated issuer keypair (the BBS keypair tuple serialized with named fields) +data IssuerKeyPair = IssuerKeyPair + { publicKey :: BBSPublicKey, + secretKey :: BBSSecretKey + } + +data BadgeIssueReq = BadgeIssueReq + { badgeKeyIdx :: Int, + secretKey :: BBSSecretKey, + request :: BadgeRequest + } + +data BadgeResult r + = BadgeResult {result :: r} + | BadgeError {error :: Text} + +$(JQ.deriveJSON defaultJSON ''IssuerKeyPair) + +$(JQ.deriveJSON defaultJSON ''BadgeIssueReq) + +$(pure []) + +instance ToJSON r => ToJSON (BadgeResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +instance FromJSON r => FromJSON (BadgeResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +cChatBadgeKeygen :: IO CJSONString +cChatBadgeKeygen = + bbsKeyGen >>= \case + Right (pk, sk) -> encodeResult $ BadgeResult (IssuerKeyPair pk sk) + Left e -> encodeResult @IssuerKeyPair $ BadgeError (T.pack e) + +cChatBadgeIssue :: CString -> IO CJSONString +cChatBadgeIssue cReq = do + bs <- B.packCString cReq + encodeResult @BadgeCredential =<< case J.eitherDecodeStrict' bs of + Left e -> pure $ BadgeError (T.pack e) + Right BadgeIssueReq {badgeKeyIdx, secretKey, request} -> + either (BadgeError . T.pack) BadgeResult <$> issueBadge badgeKeyIdx secretKey (VerifiedBadgeRequest request) + +encodeResult :: ToJSON r => BadgeResult r -> IO CJSONString +encodeResult = newCStringFromLazyBS . J.encode diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b0903589de..7d272481f6 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile generateRandomProfile = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) 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/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index abc40f1e6e..e5ebf8e2bd 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -29,7 +29,8 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Badges (rowToBadge) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -104,8 +105,9 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do (userId, agentConnId, ConnDeleted) getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ contactId c = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getDirectChatTags db contactId - firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $ + firstRow (toContact' currentTs contactId c chatTags) (SEInternalError "referenced contact not found") $ DB.query db [sql| @@ -113,15 +115,16 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 |] (userId, contactId, CSActive) - toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} + toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -130,9 +133,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do + currentTs <- liftIO getCurrentTime gm <- ExceptT $ - firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + firstRow (toGroupAndMember currentTs c) (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -152,11 +156,13 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -170,10 +176,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember currentTs c (groupInfoRow :. memberRow) = + let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow + member = toGroupMember currentTs userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 27cb970b73..9c5fe0cd91 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Badges (badgeToRow, verifyBadge_) import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -72,7 +73,7 @@ createOrUpdateContactRequest isSimplexTeam invId cReqChatVRange@(VersionRange minV maxV) - profile@Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -103,8 +104,9 @@ createOrUpdateContactRequest where getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do + currentTs <- getCurrentTime ct_ <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -114,6 +116,7 @@ createOrUpdateContactRequest cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -127,26 +130,29 @@ createOrUpdateContactRequest mapM (addDirectChatTags db) ct_ getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do + currentTs <- getCurrentTime g_ <- - maybeFirstRow (toGroupInfo cxt userContactId []) $ + maybeFirstRow (toGroupInfo currentTs cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ + getContactRequestByXContactId xContactId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -157,12 +163,13 @@ createOrUpdateContactRequest createContactRequest :: ExceptT StoreError IO RequestStage createContactRequest = do currentTs <- liftIO $ getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -214,7 +221,7 @@ createOrUpdateContactRequest ucr <- getContactRequest db user contactRequestId pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = LocalProfile {displayName = oldDisplayName}} = do currentTs <- liftIO getCurrentTime liftIO $ updateProfile currentTs updateRequest currentTs @@ -222,7 +229,8 @@ createOrUpdateContactRequest re_ <- getRequestEntity ucr' pure $ RSCurrentRequest (Just ucr) ucr' re_ where - updateProfile currentTs = + updateProfile currentTs = do + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge DB.execute db [sql| @@ -232,7 +240,16 @@ createOrUpdateContactRequest short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -240,7 +257,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + ((displayName, fullName, shortDescr, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index e60d51ac85..204b5325ed 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -351,7 +351,8 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember cxt user) <$> + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ?") diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 1c2f35f2bf..5068c5c61c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -105,6 +105,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -307,8 +308,9 @@ getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do + currentTs <- getCurrentTime ct <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -318,6 +320,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -399,7 +402,7 @@ createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs + contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs getContact db cxt user contactId updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -444,7 +447,7 @@ createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profil createDirectContact db cxt user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs + contactId <- createContact_ db cxt user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -552,22 +555,25 @@ deleteUnusedProfile_ db userId profileId = :. (userId, profileId, userId, profileId, profileId) ) -updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db user@User {userId} c p' - | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure c {profile, mergedPreferences} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} +updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db cxt user@User {userId} c p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactProfile' currentTs badgeVerified profile where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c + Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Profile {displayName = newName, preferences} = p' - profile = toLocalProfile profileId p' localAlias mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c + updateContactProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateContactProfile_' db userId profileId p' badgeVerified currentTs + pure c {profile, mergedPreferences} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do @@ -694,55 +700,58 @@ setQuotaErrCounter db User {userId} Connection {connId} counter = do updatedAt <- getCurrentTime DB.execute db "UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?" (counter, updatedAt, userId, connId) -updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateContactProfile_ db userId profileId profile = do +updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateContactProfile_' db userId profileId profile currentTs + updateContactProfile_' db userId profileId profile badgeVerified currentTs -updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = do +updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) -updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfileReset_ db userId profileId profile = do +updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId profile currentTs + updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) -updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfile_ db userId profileId profile = do +updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId profile currentTs + updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do @@ -773,18 +782,21 @@ getUserContactLinkIdByCReq db contactRequestId = DB.query db "SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ?" (Only contactRequestId) getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db User {userId} contactRequestId = - ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ +getContactRequest db User {userId} contactRequestId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactRequest currentTs) (SEContactRequestNotFound contactRequestId) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getContactRequest' :: DB.Connection -> User -> Int64 -> IO (Maybe UserContactRequest) -getContactRequest' db User {userId} contactRequestId = - maybeFirstRow toContactRequest $ +getContactRequest' db User {userId} contactRequestId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getBusinessContactRequest :: DB.Connection -> User -> GroupId -> IO (Maybe UserContactRequest) -getBusinessContactRequest db _user groupId = - maybeFirstRow toContactRequest $ +getBusinessContactRequest db _user groupId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.business_group_id = ?") (Only groupId) contactRequestQuery :: Query @@ -793,10 +805,11 @@ contactRequestQuery = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) |] @@ -832,7 +845,7 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) +createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> LocalProfile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) createContactFromRequest db user@User {userId, profile = LocalProfile {preferences}} uclId_ agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile xContactId incognitoProfile subMode pqSup contactUsed = do currentTs <- getCurrentTime let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences @@ -848,7 +861,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc Contact { contactId, localDisplayName, - profile = toLocalProfile profileId profile "", + profile, activeConn = Just conn, contactUsed, contactStatus = CSActive, @@ -904,8 +917,9 @@ getContact db cxt user contactId = getContact_ db cxt user contactId False getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact getContact_ db cxt user@User {userId} contactId deleted = do + currentTs <- liftIO getCurrentTime chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact currentTs cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -915,6 +929,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -928,8 +943,9 @@ getContact_ db cxt user@User {userId} contactId deleted = do (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User -getUserByContactRequestId db contactRequestId = - ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ +getUserByContactRequestId db contactRequestId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4e38ef83e2..c6b5684945 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -196,6 +196,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -225,12 +226,12 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) -toMaybeGroupMember _ _ = Nothing +toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) +toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = do @@ -634,7 +635,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b randHostId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId hostProfile = profileFromName $ nameFromBS randHostId - (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -789,7 +790,7 @@ updatePreparedUserAndHostMembers' |] (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) updateHostMember currentTs = do - _ <- updateMemberProfile db user hostMember fromMemberProfile + _ <- updateMemberProfile db cxt user hostMember fromMemberProfile let MemberIdRole memberId memberRole = fromMember gmId = groupMemberId' hostMember liftIO $ @@ -839,7 +840,7 @@ createGroupViaLink' (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1005,7 +1006,8 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1039,16 +1041,18 @@ getGroupInfoByName db cxt user gName = do getGroupInfo db cxt user gId getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db cxt user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember db cxt user@User {userId} groupId groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db cxt user groupId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ +getHostMember db cxt user groupId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1088,32 +1092,36 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db cxt user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById db cxt user@User {userId} groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember -getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") @@ -1146,8 +1154,9 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember cxt user) +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") @@ -1156,8 +1165,9 @@ getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") @@ -1169,8 +1179,9 @@ getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") @@ -1181,7 +1192,8 @@ getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") @@ -1189,7 +1201,8 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") @@ -1197,7 +1210,8 @@ getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db ( groupMemberQuery @@ -1361,7 +1375,7 @@ createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> Gr createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, memProfileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1380,11 +1394,12 @@ createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {gro getGroupMemberById db cxt user groupMemberId getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = - liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = do + currentTs <- liftIO getCurrentTime + liftIO (getGroupMemberByRelayLink currentTs) >>= maybe createRelayMember pure where - getGroupMemberByRelayLink = - maybeFirstRow (toContactMember cxt user) $ + getGroupMemberByRelayLink currentTs = + maybeFirstRow (toContactMember currentTs cxt user) $ DB.query db #if defined(dbPostgres) @@ -1399,7 +1414,7 @@ getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo randRelayId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId relayProfile = profileFromName $ nameFromBS randRelayId - (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId groupMemberId <- liftIO $ do DB.execute @@ -1472,7 +1487,7 @@ setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (relayKey, currentTs, gmId) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () @@ -1519,8 +1534,8 @@ getRelayConfId db m = |] (Only (groupMemberId' m)) -updateRelayMemberData :: DB.Connection -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () -updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do +updateRelayMemberData :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () +updateRelayMemberData db cxt user m memberId (MemberKey relayKey) profile = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1531,7 +1546,7 @@ updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (memberId, relayKey, currentTs, groupMemberId' m) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () setGroupInProgressDone db GroupInfo {groupId} = do @@ -1584,7 +1599,7 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -1651,7 +1666,8 @@ isRelayGroupRejected db User {userId} groupLink = getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] getRelayServedGroups db cxt User {userId, userContactId} = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1661,8 +1677,9 @@ getRelayServedGroups db cxt User {userId, userContactId} = do getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do - cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + let cutoffTs = addUTCTime (- ttl) currentTs + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1697,14 +1714,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db + cxt gVar User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -1712,12 +1730,13 @@ createJoiningMember memberStatus memberKey_ = do currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db case cReqMemberId_ of Just memberId -> do @@ -2053,10 +2072,10 @@ increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, memb pure g {membersRequireAttention = membersRequireAttention + 1} -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user profile currentTs let newMember = NewGroupMember { memInfo, @@ -2069,19 +2088,20 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs -createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = +createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool) +createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db - pure $ Right (ldn, profileId) + pure $ Right (ldn, profileId, badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -2097,6 +2117,7 @@ createNewMember_ memContactId = memberContactId, memProfileId = memberContactProfileId } + badgeVerified createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing @@ -2134,7 +2155,7 @@ createNewMember_ invitedBy, invitedByGroupMemberId = memInvitedByGroupMemberId, localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified, memberContactId, memberContactProfileId, activeConn, @@ -2248,18 +2269,19 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db + cxt user gInfo memInfo@(MemberInfo _ _ _ memberProfile _) memRestrictions_ = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn @@ -2983,41 +3005,47 @@ setMemberContactStartedConnection db Contact {contactId} = do "UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ?" (BI True, currentTs, contactId) -updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db user@User {userId} m p' - | displayName == newName = do - liftIO $ updateMemberContactProfileReset_ db userId profileId p' - pure m {memberProfile = profile} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId p' currentTs - DB.execute - db - "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" - (ldn, currentTs, userId, groupMemberId) - safeDeleteLDN db user localDisplayName - pure $ Right m {localDisplayName = ldn, memberProfile = profile} +updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db cxt user@User {userId} m p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateMemberProfile' currentTs badgeVerified memberProfile where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateMemberProfile' currentTs badgeVerified memberProfile + | displayName == newName = do + liftIO $ updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + pure m {memberProfile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + safeDeleteLDN db user localDisplayName + pure $ Right m {localDisplayName = ldn, memberProfile} -updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' - | displayName == newName = do - liftIO $ updateMemberContactProfile_ db userId profileId p' - pure (m {memberProfile = profile}, ct {profile} :: Contact) - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) +updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactMemberProfile' currentTs badgeVerified profile where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateContactMemberProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + pure (m {memberProfile = profile}, ct {profile} :: Contact) + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = @@ -3036,7 +3064,7 @@ createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3061,7 +3089,7 @@ createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3087,7 +3115,7 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI -- Updating from an in-band message would allow a compromised relay to substitute keys. updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do - _ <- updateMemberProfile db user member profile + _ <- updateMemberProfile db cxt user member profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3108,7 +3136,7 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do - _ <- updateMemberProfile db user unknownMember profile + _ <- updateMemberProfile db cxt user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 76e0a0fd97..edbe7a6acb 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -652,7 +652,8 @@ insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime -> insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts) getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c) -getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = +getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = do + currentTs <- getCurrentTime case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> @@ -660,13 +661,13 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c @@ -695,8 +696,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" (userId, groupId, msgId, MDRcv, groupMemberId) - getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) - getGroupChatItemQuote_ groupId mId = do + getGroupChatItemQuote_ :: UTCTime -> Int64 -> MemberId -> IO (CIQuote 'CTGroup) + getGroupChatItemQuote_ currentTs groupId mId = do ciQuoteGroup <$> DB.query db @@ -706,6 +707,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -721,7 +723,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing - ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow + ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember currentTs userContactId memberRow getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db cxt user withPCC pagination query = do @@ -1111,22 +1113,25 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] -getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" - CLQSearch {search} -> map toPreview <$> getPreviews search +getContactRequestChatPreviews_ db User {userId} pagination clq = do + currentTs <- getCurrentTime + case clq of + CLQFilters {favorite = False, unread = False} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQSearch {search} -> map (toPreview currentTs) <$> getPreviews search where query = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -1148,9 +1153,9 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: ContactRequestRow -> AChatPreviewData - toPreview cReqRow = - let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow + toPreview :: UTCTime -> ContactRequestRow -> AChatPreviewData + toPreview now cReqRow = + let cReq@UserContactRequest {updatedAt} = toContactRequest now cReqRow aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat @@ -2358,9 +2363,9 @@ toGroupChatItem ) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ + member_ = toMaybeGroupMember currentTs userContactId memberRow_ + quotedMember_ = toMaybeGroupMember currentTs userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember currentTs userContactId deletedByGroupMemberRow_ invalid = ACIContent msgDir $ CIInvalidJSON itemContentText chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> @@ -3036,6 +3041,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -3044,12 +3050,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a8bb0da945..4c9a1b1c91 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -32,6 +32,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -63,7 +64,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..ffc3122e3f --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260516_supporter_badges :: Text +m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TIMESTAMPTZ; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified SMALLINT; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx BIGINT; +|] + +down_m20260516_supporter_badges :: Text +down_m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index cc3543e8a8..68c43efa19 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -531,7 +531,16 @@ CREATE TABLE test_chat_schema.contact_profiles ( preferences text, contact_link bytea, short_descr text, - chat_peer_type text + chat_peer_type text, + badge_proof bytea, + badge_pres_header bytea, + badge_expiry timestamp with time zone, + badge_type text, + badge_verified smallint, + badge_extra text, + badge_master_key bytea, + badge_signature bytea, + badge_key_idx bigint ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d432067866..bfd198d885 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -43,6 +43,7 @@ module Simplex.Chat.Store.Profiles updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, updateUserProfile, + setUserBadge, setUserProfileContactLink, getUserContactProfiles, createUserContactLink, @@ -97,6 +98,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges (LocalBadge, localBadgeToRow) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -162,7 +164,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) :. localBadgeToRow Nothing -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -196,8 +198,9 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] -getUsers db = - map toUser <$> DB.query_ db userQuery +getUsers db = do + now <- getCurrentTime + map (toUser now) <$> DB.query_ db userQuery setActiveUser :: DB.Connection -> User -> IO User setActiveUser db user@User {userId} = do @@ -214,13 +217,15 @@ getNextActiveOrder db = do else pure $ order + 1 getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User -getUser db userId = - ExceptT . firstRow toUser (SEUserNotFound userId) $ +getUser db userId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) getRelayUser :: DB.Connection -> ExceptT StoreError IO User -getRelayUser db = - ExceptT . firstRow toUser SERelayUserNotFound $ +getRelayUser db = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) SERelayUserNotFound $ DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 @@ -229,38 +234,45 @@ getUserIdByName db uName = DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) -getUserByAConnId db agentConnId = - maybeFirstRow toUser $ +getUserByAConnId db agentConnId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) getUserByASndFileId :: DB.Connection -> AgentSndFileId -> IO (Maybe User) -getUserByASndFileId db aSndFileId = - maybeFirstRow toUser $ +getUserByASndFileId db aSndFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.agent_snd_file_id = ?") (Only aSndFileId) getUserByARcvFileId :: DB.Connection -> AgentRcvFileId -> IO (Maybe User) -getUserByARcvFileId db aRcvFileId = - maybeFirstRow toUser $ +getUserByARcvFileId db aRcvFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id JOIN rcv_files r ON r.file_id = f.file_id WHERE r.agent_rcv_file_id = ?") (Only aRcvFileId) getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User -getUserByContactId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByContactId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId) getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User -getUserByGroupId db groupId = - ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ +getUserByGroupId db groupId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByGroupId groupId) $ DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) getUserByNoteFolderId :: DB.Connection -> NoteFolderId -> ExceptT StoreError IO User -getUserByNoteFolderId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByNoteFolderId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN note_folders nf ON nf.user_id = u.user_id WHERE nf.note_folder_id = ?") (Only contactId) getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User -getUserByFileId db fileId = - ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ +getUserByFileId db fileId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] @@ -309,10 +321,10 @@ updateUserAutoAcceptMemberContacts db User {userId} autoAccept = updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do - updateContactProfile_ db userId profileId p' currentTs <- getCurrentTime + updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -322,9 +334,9 @@ updateUserProfile db user p' db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId p' currentTs + updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -332,11 +344,38 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' - profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences +-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) +updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId)) + +-- store the user's own badge credential; touches only the badge columns. +-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User +setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} + setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do ts <- getCurrentTime @@ -366,7 +405,7 @@ getUserContactProfiles db User {userId} = (Only userId) where toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} + toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences, badge = Nothing} createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 89ef373af8..5bf628b062 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -155,6 +155,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -309,7 +310,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..d263d63a2b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260516_supporter_badges :: Query +m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified INTEGER; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx INTEGER; +|] + +down_m20260516_supporter_badges :: Query +down_m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 14c9226d2c..803e012773 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -125,6 +125,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -156,11 +157,13 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -394,10 +397,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -462,7 +466,16 @@ Query: short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -673,7 +686,8 @@ Query: c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 @@ -1024,6 +1038,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -1308,6 +1323,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -1316,12 +1332,14 @@ Query: rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -1374,6 +1392,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1930,6 +1949,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -2011,10 +2031,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2040,10 +2061,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2069,10 +2091,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3602,7 +3625,8 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -4976,6 +5000,14 @@ Query: Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? @@ -4994,7 +5026,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5002,7 +5035,17 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5319,6 +5362,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5356,6 +5400,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5386,6 +5431,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5404,10 +5450,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.business_group_id = ? @@ -5419,10 +5466,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? AND cr.contact_request_id = ? @@ -5434,6 +5482,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5461,6 +5510,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5481,6 +5531,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5500,6 +5551,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5519,6 +5571,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5538,6 +5591,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5557,6 +5611,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5576,6 +5631,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5595,6 +5651,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5614,6 +5671,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5633,6 +5691,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -5823,7 +5882,8 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5835,7 +5895,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5848,7 +5909,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5861,7 +5923,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5875,7 +5938,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5888,7 +5952,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5901,7 +5966,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5914,7 +5980,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5927,7 +5994,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5939,7 +6007,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6531,11 +6600,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index ccff26b38d..2d7ea7ff70 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -19,7 +19,16 @@ CREATE TABLE contact_profiles( preferences TEXT, contact_link BLOB, short_descr TEXT, - chat_peer_type TEXT + chat_peer_type TEXT, + badge_proof BLOB, + badge_pres_header BLOB, + badge_expiry TEXT, + badge_type TEXT, + badge_verified INTEGER, + badge_extra TEXT, + badge_master_key BLOB, + badge_signature BLOB, + badge_key_idx INTEGER ) STRICT; CREATE TABLE users( user_id INTEGER PRIMARY KEY, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f7b525243c..bd0d22f379 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -32,6 +32,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Remote.Types import Simplex.Chat.Types @@ -406,18 +407,19 @@ setCommandConnId db User {userId} cmdId connId = do |] (connId, updatedAt, userId, cmdId) -createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () -createContact db user profile = do +createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () +createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs + void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs)) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db DB.execute db @@ -484,13 +486,13 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow type ContactRow = Only ContactId :. ContactRow' -toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} +toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -516,22 +518,24 @@ toGroupDirectInvitation (Just groupDirectInvLink, fromGroupId_, fromGroupMemberI Just $ GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile -getProfileById db userId profileId = - ExceptT . firstRow rowToLocalProfile (SEProfileNotFound profileId) $ +getProfileById db userId profileId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (rowToLocalProfile currentTs) (SEProfileNotFound profileId) $ DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow -toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer)) = do - let profile = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} +toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt} @@ -539,17 +543,18 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay)) = +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) :. BadgeRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay) :. badgeRow) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes, userChatRelay = BoolDef userChatRelay} where - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ @@ -671,11 +676,11 @@ type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolIn type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow -toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = + let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -718,9 +723,9 @@ toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) toGroupKeys _ _ = Nothing -toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - let memberProfile = rowToLocalProfile profileRow +toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember +toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + let memberProfile = rowToLocalProfile now profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById @@ -745,6 +750,7 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, @@ -756,13 +762,13 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember cxt User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} +toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember now cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} -rowToLocalProfile :: ProfileRow -> LocalProfile -rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences} +rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow) = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} @@ -789,6 +795,7 @@ groupInfoQueryFields = mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link |] @@ -877,8 +884,9 @@ addGroupChatTags db g@GroupInfo {groupId} = do getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b4264d121d..7155d407e8 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -40,6 +40,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -47,6 +48,8 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -367,7 +370,7 @@ data UserContactRequest = UserContactRequest cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, - profile :: Profile, + profile :: LocalProfile, createdAt :: UTCTime, updatedAt :: UTCTime, xContactId :: Maybe XContactId, @@ -685,7 +688,8 @@ data Profile = Profile image :: Maybe ImageData, contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, - peerType :: Maybe ChatPeerType + peerType :: Maybe ChatPeerType, + badge :: Maybe BadgeProof -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -718,7 +722,7 @@ instance TextEncoding ChatPeerType where profileFromName :: ContactName -> Profile profileFromName displayName = - Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing} + Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing} -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -727,6 +731,15 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 +-- equal for profile-update detection: badge proofs are re-generated for every presentation, +-- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op +sameProfileContent :: Profile -> Profile -> Bool +sameProfileContent p@Profile {badge = b} p'@Profile {badge = b'} = + p {badge = Nothing} == p' {badge = Nothing} && (proofInfo <$> b) == (proofInfo <$> b') + where + proofInfo :: BadgeProof -> BadgeInfo + proofInfo (BadgeProof _ _ _ info) = info + data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -758,6 +771,7 @@ data LocalProfile = LocalProfile contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, + localBadge :: Maybe LocalBadge, localAlias :: LocalAlias } deriving (Eq, Show) @@ -765,13 +779,37 @@ data LocalProfile = LocalProfile localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile -toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} localAlias = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localAlias} +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} localAlias now verified = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias} + where + localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile -fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge} = + Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge} + where + -- any stored peer proof rides the wire (receivers verify independently); the own credential is presented fresh, and a display-only badge never sends + wireBadge :: LocalBadge -> Maybe BadgeProof + wireBadge = \case + PeerBadge b _ -> Just b + OwnBadge _ _ -> Nothing + ShownBadge _ _ -> Nothing + +profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) +profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = + case (localBadge, newBadge) of + (_, Nothing) -> pure (Just False) + -- an unchanged badge that verified before stays verified; failed or unknown-key badges + -- are re-verified, so an unknown key heals once an app update adds it + (Just lb, Just (BadgeProof _ _ _ newInfo)) + | localBadgeInfo lb == newInfo && localBadgeStatus lb `notElem` [BSFailed, BSUnknownKey] -> pure (Just True) + (_, Just newB) -> verifyBadge keys newB + +-- a failed or unknown-key badge is re-verified on the next profile update even when its disclosed content +-- is unchanged, so it heals once an app update adds the issuer key +badgeNeedsReverify :: LocalProfile -> Bool +badgeNeedsReverify LocalProfile {localBadge} = maybe False ((`elem` [BSFailed, BSUnknownKey]) . localBadgeStatus) localBadge data GroupType = GTChannel @@ -2035,7 +2073,7 @@ type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` -- parameter. Built from config by mkStoreCxt; more fields are added here over time. -newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 838d15245a..004f6af825 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Help import Simplex.Chat.Library.Commands (maxImageSize) import Simplex.Chat.Markdown +import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -111,7 +112,7 @@ chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logL chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case - CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes + CRActiveUser User {profile = p@LocalProfile {localBadge}, uiThemes} -> viewUserProfile localBadge (fromLocalProfile p) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] @@ -193,7 +194,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci - CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix hu outputRH u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info @@ -452,7 +453,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft @@ -618,8 +619,8 @@ viewUsersList us = in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n - userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType}, activeUser, showNtfs, viewPwdHash} count) - | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName shortDescr <> infoStr <> bot + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType, localBadge}, activeUser, showNtfs, viewPwdHash} count) + | activeUser || isNothing viewPwdHash = Just $ ttyFullNameBadge n fullName shortDescr localBadge <> infoStr <> bot | otherwise = Nothing where infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" @@ -1507,9 +1508,9 @@ viewContactAndMemberAssociated ct g m ct' = "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] -viewUserProfile :: Profile -> [StyledString] -viewUserProfile Profile {displayName, fullName, shortDescr, peerType, preferences} = - [ "user profile: " <> ttyFullName displayName fullName shortDescr <> bot, +viewUserProfile :: Maybe LocalBadge -> Profile -> [StyledString] +viewUserProfile localBadge Profile {displayName, fullName, shortDescr, peerType, preferences} = + [ "user profile: " <> ttyFullNameBadge displayName fullName shortDescr localBadge <> bot, "use " <> highlight' "/p []" <> " to change it" ] ++ viewCommands @@ -1752,9 +1753,22 @@ smpProxyModeStr :: SMPProxyMode -> SMPProxyFallback -> String smpProxyModeStr SPMNever _ = "private message routing disabled." smpProxyModeStr mode fallback = T.unpack $ safeDecodeUtf8 $ "private message routing mode: " <> strEncode mode <> ", fallback: " <> strEncode fallback +viewContactBadge :: Maybe LocalBadge -> [StyledString] +viewContactBadge = maybe [] $ \lb -> + let BadgeInfo {badgeType, badgeExpiry} = localBadgeInfo lb + st = case localBadgeStatus lb of + BSActive -> "active" + BSExpired -> "expired" + BSExpiredOld -> "expired (old)" + BSFailed -> "verification failed" + BSUnknownKey -> "unknown key" + expiry = maybe "no expiry" (("expires " <>) . T.pack . formatTime defaultTimeLocale "%Y-%m-%d") badgeExpiry + in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] + <> viewContactBadge localBadge <> maybe [] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> maybe @@ -1787,10 +1801,11 @@ viewCustomData :: Maybe CustomData -> [StyledString] viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)]) viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink}, activeConn} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] + <> viewContactBadge localBadge <> maybe ["member not connected"] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] @@ -2785,9 +2800,47 @@ ttyContact = styled (colored Green) . viewName ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c +-- Supporter badge: a colored star marks an active badge (only the star is colored). +-- supporter cyan, legend blue, investor yellow, unknown cyan; business has no star. +badgeStarColor :: BadgeType -> Maybe Color +badgeStarColor = \case + BTSupporter -> Just Cyan + BTLegend -> Just Blue + BTInvestor -> Just Yellow + BTUnknown _ -> Just Cyan + +-- (star color, type word) for an active, colorable badge +activeBadge :: Maybe LocalBadge -> Maybe (Color, Text) +activeBadge lb_ = do + lb <- lb_ + case localBadgeStatus lb of + BSActive -> let BadgeInfo {badgeType} = localBadgeInfo lb in (\col -> (col, textEncode badgeType)) <$> badgeStarColor badgeType + _ -> Nothing + +badgeStar :: Color -> StyledString +badgeStar col = styled (colored col) ("*" :: Text) + +-- " *" (space + colored star) for sender prefixes, "" if no active badge +badgeStarSep :: Maybe LocalBadge -> StyledString +badgeStarSep lb_ = maybe "" (\(c, _) -> " " <> badgeStar c) (activeBadge lb_) + +-- name + badge for full-name contexts: "alice (Alice, * supporter)" / "alice (* supporter)" / "alice (Alice)" / "alice" +ttyFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +ttyFullNameBadge c fullName shortDescr lb_ = ttyContact c <> optFullNameBadge c fullName shortDescr lb_ + +optFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +optFullNameBadge c fullName shortDescr lb_ = case activeBadge lb_ of + Nothing -> optFullName c fullName shortDescr + Just (color, typeWord) -> " (" <> nameInner <> badgeStar color <> plain (" " <> typeWord) <> ")" + where + nameInner = maybe "" (\t -> plain (t <> ", ")) innerName + innerName + | T.null fullName || c == fullName = shortDescr + | otherwise = Just fullName + ttyFullContact :: Contact -> StyledString -ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr}} = - ttyFullName localDisplayName fullName shortDescr +ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr, localBadge}} = + ttyFullNameBadge localDisplayName fullName shortDescr localBadge ttyMember :: GroupMember -> StyledString ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName @@ -2816,7 +2869,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (vie ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString -ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") +ttyFromContact ct@Contact {localDisplayName = c, profile = LocalProfile {localBadge}} = + ctIncognito ct <> ttyFrom (viewName c) <> badgeStarSep localBadge <> ttyFrom "> " ttyFromContactEdited :: Contact -> StyledString ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ") diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs new file mode 100644 index 0000000000..90e3e9ae7a --- /dev/null +++ b/tests/BadgeTests.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeTests (badgeTests) where + +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import qualified Data.Aeson as J +import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges +import Simplex.Messaging.Crypto.BBS +import Test.Hspec + +badgeTests :: Spec +badgeTests = do + it "full workflow: request, issue, verify credential, generate and verify proof" testFullWorkflow + it "should reject badge with tampered type" testTamperedType + it "should reject badge with tampered expiry" testTamperedExpiry + it "should reject badge with wrong server key" testWrongKey + it "should report a key index missing from configured keys" testUnknownKeyIdx + it "should compute badge status correctly" testExpiryCheck + it "should treat lifetime badges as always active" testLifetimeBadge + it "should accept unknown badge types" testUnknownBadgeType + it "credential serializes to a paste-able token and back" testCredentialSerialization + +proofOf :: BadgeProof -> BBSProof +proofOf (BadgeProof _ _ p _) = p + +proofInfo :: BadgeProof -> BadgeInfo +proofInfo (BadgeProof _ _ _ i) = i + +testKeyIdx :: Int +testKeyIdx = 1 + +keysFor :: BBSPublicKey -> Map Int BBSPublicKey +keysFor = M.singleton testKeyIdx + +testFullWorkflow :: IO () +testFullWorkflow = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}} + Just vreq <- verifyPayment (BPRedeemCode "TEST") req + Right cred <- issueBadge testKeyIdx sk vreq + let BadgeCredential idx mk' _ _ = cred + idx `shouldBe` testKeyIdx + mk' `shouldBe` mk + verifyCredential pk cred >>= (`shouldBe` True) + Right badge <- generateBadgeProof pk cred (BBSPresHeader "nonce-1") + -- the proof inherits the credential's key index, so receivers find the right key + let BadgeProof {badgeKeyIdx} = badge + badgeKeyIdx `shouldBe` testKeyIdx + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + Right badge2 <- generateBadgeProof pk cred (BBSPresHeader "nonce-2") + verifyBadge (keysFor pk) badge2 >>= (`shouldBe` Just True) + proofOf badge `shouldNotBe` proofOf badge2 + +testTamperedType :: IO () +testTamperedType = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeType = BTLegend}) >>= (`shouldBe` Just False) + +testTamperedExpiry :: IO () +testTamperedExpiry = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeExpiry = Just pastTime}) >>= (`shouldBe` Just False) + +testWrongKey :: IO () +testWrongKey = do + (_, badge) <- issueBadgeProof BTSupporter (Just futureTime) + Right (pk2, _) <- bbsKeyGen + verifyBadge (keysFor pk2) badge >>= (`shouldBe` Just False) + +testUnknownKeyIdx :: IO () +testUnknownKeyIdx = do + (pk, badge) <- issueBadgeProof BTSupporter (Just futureTime) + -- a key index not in the configured keys cannot be verified at all (Nothing) + verifyBadge (M.singleton (testKeyIdx + 1) pk) badge >>= (`shouldBe` Nothing) + +testExpiryCheck :: IO () +testExpiryCheck = do + now <- getCurrentTime + let info expiry = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""} + futureInfo = info (Just futureTime) + mkBadgeStatus now (Just True) futureInfo `shouldBe` BSActive + mkBadgeStatus now (Just True) (info (Just (addUTCTime (-nominalDay) now))) `shouldBe` BSExpired + mkBadgeStatus now (Just True) (info (Just pastTime)) `shouldBe` BSExpiredOld + mkBadgeStatus now (Just False) futureInfo `shouldBe` BSFailed + mkBadgeStatus now Nothing futureInfo `shouldBe` BSUnknownKey + +testLifetimeBadge :: IO () +testLifetimeBadge = do + now <- getCurrentTime + (pk, badge) <- issueBadgeProof BTInvestor Nothing + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + mkBadgeStatus now (Just True) (proofInfo badge) `shouldBe` BSActive + +testUnknownBadgeType :: IO () +testUnknownBadgeType = do + (pk, badge) <- issueBadgeProof (BTUnknown "future_type") (Just futureTime) + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + +testCredentialSerialization :: IO () +testCredentialSerialization = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let mkCred expiry = do + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}}) + pure cred + dated <- mkCred (Just futureTime) + lifetime <- mkCred Nothing + J.eitherDecode (J.encode dated) `shouldBe` Right dated + J.eitherDecode (J.encode lifetime) `shouldBe` Right lifetime + -- a decoded credential still verifies against the issuing key + case J.eitherDecode (J.encode dated) of + Right cred -> verifyCredential pk cred >>= (`shouldBe` True) + Left e -> expectationFailure e + +-- Helpers + +futureTime :: UTCTime +futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31 + +pastTime :: UTCTime +pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +issueBadgeProof :: BadgeType -> Maybe UTCTime -> IO (BBSPublicKey, BadgeProof) +issueBadgeProof bt expiry = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}} + Right cred <- issueBadge testKeyIdx sk vreq + Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") + pure (pk, badge) diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index f56a4d803d..051ee6b304 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -33,7 +33,7 @@ withBroadcastBot opts test = bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile -broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7fdd34061f..a3a48e7d29 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -96,7 +96,7 @@ directoryServiceTests = do it "should update subscriber count periodically" testLinkCheckUpdatesCount directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 2502f3e262..0e2052b259 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -18,11 +19,18 @@ import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Map.Strict as M +import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite @@ -40,6 +48,13 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage it "use multiword profile names" testMultiWordProfileNames + it "present supporter badge to contacts" testUserBadgeBroadcast + it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect + it "supporter badge sent to member joining via group link" testUserBadgeGroupLink + it "expired supporter badge shows as expired" testUserBadgeExpired + it "long-expired supporter badge is not presented" testUserBadgeExpiredOld + 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 @@ -185,6 +200,210 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +-- the test issuer key under index 1 in the test config +testBadgeKeys :: BBSPublicKey -> M.Map Int BBSPublicKey +testBadgeKeys = M.singleton 1 + +-- issue a supporter badge credential with the given expiry (test issuer) +issueTestBadge :: BBSSecretKey -> Maybe UTCTime -> IO BadgeCredential +issueTestBadge sk badgeExpiry = do + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""} + Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} + Right cred <- issueBadge 1 sk vreq + pure cred + +-- the same single-line JSON `simplex-chat badge sign` prints, pasted into the app +addTestBadge :: HasCallStack => TestCC -> BadgeCredential -> IO () +addTestBadge cc cred = do + cc ##> ("/badge add " <> T.unpack (encodeJSON cred)) + cc <## "ok" + +testUserBadgeBroadcast :: HasCallStack => TestParams -> IO () +testUserBadgeBroadcast ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk Nothing + -- own badge is shown (add succeeded) + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- the badge XInfo is delivered in order before this message, so the contact has stored it + alice #> "@bob hi" + bob <# "alice *> hi" + +testUserBadgeOnConnect :: HasCallStack => TestParams -> IO () +testUserBadgeOnConnect ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- a contact connecting after the badge is attached receives it in the connection handshake + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + 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 + +testUserBadgeGroupLink :: HasCallStack => TestParams -> IO () +testUserBadgeGroupLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + -- the host's profile (x.grp.link.mem) is sent over the same connection as group messages, + -- so receiving a message guarantees the badge arrived + alice #> "#team hello" + bob <# "#team alice> hello" + -- no prior contact: the host's badge arrives via the group link handshake + bob ##> "/i #team alice" + bob <## "group ID: 1" + bob <##. "member ID: " + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () +testUserBadgeContactAddress ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk 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 (pk, sk) <- bbsKeyGen + -- expired recently (within 31 days), so the badge is still presented and shown as expired + expiry <- addUTCTime (-2 * nominalDay) <$> getCurrentTime + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk expiry) ps + where + test sk expiry alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just expiry) + -- expired badge: no star + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - expired" + bob <## ("expires " <> formatTime defaultTimeLocale "%Y-%m-%d" 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 + +testUserBadgeExpiredOld :: HasCallStack => TestParams -> IO () +testUserBadgeExpiredOld ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just pastDate) + -- a badge that expired over a month ago is not presented to contacts at all + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + 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 + pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +testUserBadgeIncognito :: HasCallStack => TestParams -> IO () +testUserBadgeIncognito ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- an incognito identity must not carry the badge + bob ##> "/connect" + inv <- getInvitation bob + alice ##> ("/connect incognito " <> inv) + alice <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob to print out this incognito profile again" + ] + bob ##> ("/i " <> aliceIncognito) + bob <## "contact ID: 2" + 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 + testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ @@ -279,7 +498,7 @@ testMultiWordProfileNames = aliceProfile' = baseProfile {displayName = "Alice Jones"} bobProfile' = baseProfile {displayName = "Bob James"} cathProfile' = baseProfile {displayName = "Cath Johnson"} - baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} + baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = @@ -1187,13 +1406,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1208,7 +1427,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1249,13 +1468,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1270,7 +1489,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 27c36568ec..b83b79c3a9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -85,7 +85,7 @@ chatRelayProfile :: Profile chatRelayProfile = mkProfile "relay" "Relay" Nothing mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile -mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} +mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) it name test = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d57411a598..bc0cc30a78 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -32,8 +32,10 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat +import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) +import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -81,6 +83,8 @@ mobileTests = do describe "Parsers" $ do it "should parse server address" testChatParseServer it "should parse and sanitize URI" testChatParseUri + describe "Badges" $ do + it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi noActiveUser :: LB.ByteString noActiveUser = @@ -308,6 +312,25 @@ testChatParseUri :: TestParams -> IO () testChatParseUri _ = do pure () +-- Generate a server keypair and issue a badge credential via the C FFI, +-- constructing the request from the typed records, then verify the issued +-- credential's BBS signature on the Haskell side. +testBadgeKeygenIssueCApi :: TestParams -> IO () +testBadgeKeygenIssueCApi _ = do + g <- C.newRandom + IssuerKeyPair {publicKey, secretKey} <- ffiResult =<< (peekCString =<< cChatBadgeKeygen) + mk <- generateMasterKey g + let req = BadgeIssueReq {badgeKeyIdx = 1, secretKey, request = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = ""}}} + cred <- ffiResult =<< (peekCString =<< cChatBadgeIssue =<< newCString (LB.unpack (J.encode req))) + verifyCredential publicKey cred `shouldReturn` True + +-- Decode an FFI `BadgeResult` envelope, returning the result or failing on error. +ffiResult :: FromJSON r => String -> IO r +ffiResult s = case J.eitherDecode (LB.pack s) of + Right (BadgeResult r) -> pure r + Right (BadgeError e) -> error $ "badge FFI error: " <> show e + Left e -> error $ "badge FFI decode failed: " <> e <> " in " <> s + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aef41e90d2..10f8808015 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -104,7 +104,7 @@ testGroupPreferences :: Maybe GroupPreferences testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} +testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} @@ -218,7 +218,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XInfo testProfile it "x.info with empty full name" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing} it "x.contact with xContactId" $ "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing diff --git a/tests/Test.hs b/tests/Test.hs index 639708441e..874428bc1f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -11,6 +11,7 @@ import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import BadgeTests import JSONTests import MarkdownTests import MemberRelationsTests @@ -60,6 +61,7 @@ main = do #endif around tmpBracket $ describe "WebRTC encryption" webRTCTests #endif + describe "Supporter badges" badgeTests describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests