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