Merge branch 'stable' into stable-android

This commit is contained in:
Evgeny Poberezkin
2026-06-15 23:00:43 +01:00
122 changed files with 4046 additions and 1564 deletions
@@ -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
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffee9b"/>
<stop offset="5%" stop-color="#ffee9b"/>
<stop offset="50%" stop-color="#ffc233"/>
<stop offset="100%" stop-color="#e67700"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#26dee8"/>
<stop offset="50%" stop-color="#3064ea"/>
<stop offset="100%" stop-color="#001064"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#29f5ff"/>
<stop offset="95%" stop-color="#527eed"/>
<stop offset="100%" stop-color="#3669e9"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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) {
+8 -16
View File
@@ -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)
@@ -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
}
@@ -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 {
@@ -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<Bool>) {
init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
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))."
)
}
}
@@ -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)
@@ -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 {
+6 -6
View File
@@ -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 {
@@ -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
@@ -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)
@@ -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")
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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) {
@@ -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)
@@ -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 {
@@ -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()
@@ -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)
}
}
}
+13 -1
View File
@@ -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<Content: View>(
profileName: String,
profileFullName: String,
profileImage: Content,
profileBadge: LocalBadge? = nil,
theme: AppTheme,
subtitle: String? = nil,
information: String? = nil,
@@ -383,6 +394,7 @@ func showOpenChatAlert<Content: View>(
profileName: profileName,
profileFullName: profileFullName,
profileImage: hostedView,
profileBadge: profileBadge,
subtitle: subtitle,
information: information,
cancelTitle: cancelTitle,
@@ -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:
@@ -526,12 +526,14 @@ func settingsRow<Content : View>(_ 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)
}
}
}
+12 -8
View File
@@ -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 = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
@@ -561,8 +563,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a"; sourceTree = "<group>"; };
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 = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -880,6 +882,7 @@
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */,
CE11BADE0000000000000001 /* NameBadge.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -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 */,
+85 -2
View File
@@ -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 }
+23 -4
View File
@@ -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
}
}
+1 -1
View File
@@ -297,7 +297,7 @@ private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String
let name = ns.deletingPathExtension
let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)"
let f = ext.isEmpty ? "\(name)\(suffix)" : "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
}
return tryCombine(fileName, 0)
@@ -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,
@@ -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<BadgeType> {
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()
)
@@ -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<String, InlineTextContent> = 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<String, InlineTextContent> = 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))
@@ -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
)
@@ -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,
@@ -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<ComposeState>.onFilesAttached(uris: List<URI>) {
fun MutableState<ComposeState>.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<ComposeState>.processPickedFile(uri: URI?, text: String?) {
}
suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
val maxFileSize = value.maxFileSize
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
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) {
@@ -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
@@ -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
@@ -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
)
}
@@ -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<String, InlineTextContent> = 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<String, InlineTextContent> = 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))
@@ -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
)
}
@@ -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
)
}
@@ -33,6 +33,7 @@ fun CIFileView(
edited: Boolean,
showMenu: MutableState<Boolean>,
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)
@@ -35,6 +35,7 @@ fun CIImageView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
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<ImageBitmap, ByteArray, String>? {
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 ->
@@ -34,6 +34,7 @@ fun CIVideoView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
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)))
)
}
}
@@ -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")
@@ -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 {
@@ -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) }
}
@@ -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,
@@ -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
)
}
@@ -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
)
@@ -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
@@ -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,
@@ -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(
@@ -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
@@ -383,7 +387,7 @@ fun uniqueCombine(fileName: String, dir: File): String {
val ext = orig.extension
fun tryCombine(n: Int): String {
val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext"
val f = if (ext.isEmpty()) "$name$suffix" else "$name$suffix.$ext"
return if (File(dir, f).exists()) tryCombine(n + 1) else f
}
return tryCombine(0)
@@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // 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() {
@@ -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 = {
@@ -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
)
}
@@ -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,
@@ -3105,4 +3105,12 @@
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
<string name="badge_supports_simplex">%s supports SimpleX Chat.</string>
<string name="badge_supported_simplex">%1$s supported SimpleX Chat. The badge expired on %2$s.</string>
<string name="badge_support_from_v7">You can support SimpleX starting from v7 of the app.</string>
<string name="badge_invested">%s invested in SimpleX Chat crowdfunding.</string>
<string name="badge_unverified_title">Unverified badge</string>
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
<string name="badge_unknown_key_title">Badge cannot be verified</string>
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
</resources>
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffee9b"/>
<stop offset="5%" stop-color="#ffee9b"/>
<stop offset="50%" stop-color="#ffc233"/>
<stop offset="100%" stop-color="#e67700"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#26dee8"/>
<stop offset="50%" stop-color="#3064ea"/>
<stop offset="100%" stop-color="#001064"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#29f5ff"/>
<stop offset="95%" stop-color="#527eed"/>
<stop offset="100%" stop-color="#3669e9"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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)
}
}
}
}
+7 -1
View File
@@ -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)
@@ -204,7 +204,7 @@ linkCheckThread_ opts env@ServiceState {eventQ}
threadDelay $ linkCheckInterval opts * 1000000
u <- readTVarIO $ currentUser cc
forM_ u $ \user ->
withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db user) >>= \case
withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db (storeCxt cc) user) >>= \case
Left e -> logError $ "linkCheckThread error: " <> T.pack e
Right grs -> forM_ grs $ \(gInfo, gr) ->
unless (groupRemoved $ groupRegStatus gr) $
@@ -462,7 +462,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember)
getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of
Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId
Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (storeCxt cc) user gId mId
Nothing -> pure $ Left "no owner member in group registration"
deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO ()
@@ -556,7 +556,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) ->
case dbOwnerMemberId gr of
Just ownerGMId ->
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case
Right ownerMember
| let GroupMember {memberRole = role} = ownerMember, role >= GROwner ->
setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g')
@@ -813,7 +813,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
_ -> False
checkValidOwner dbOwnerMemberId owners onValid = case dbOwnerMemberId of
Just ownerGMId ->
withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case
withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case
Right GroupMember {memberId, memberPubKey}
| any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid
_ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' ->
@@ -985,7 +985,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
addGroupReg notifyAdminUsers st cc ct gInfo GRSProposed $ \_ -> pure ()
sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case
Right CRStartedConnectionToGroup {groupInfo = gInfo'} ->
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user gInfo' mId) >>= \case
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user gInfo' mId) >>= \case
Right ownerMember ->
void $ setGroupRegOwner cc gId ownerMember
Left e -> do
@@ -998,7 +998,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
deReregistration ct g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} profileChanged LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)} = do
let mId = MemberId oIdBytes
gt = maybe "group" groupTypeStr' pg_
withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user g mId) >>= \case
withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user g mId) >>= \case
Right ownerMember@GroupMember {memberRole = role, memberStatus} ->
if
| role >= GROwner && memberStatus /= GSMemUnknown ->
@@ -1451,7 +1451,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))]
getOwnersInfo gs =
fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db ->
mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs
mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (storeCxt cc) user $ dbContactId gr) gs
sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO ()
sendGroupsInfo ct ciId isAdmin (gs, n) = do
@@ -1519,7 +1519,7 @@ updateGroupListingFiles cc u dir =
Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e
getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact)
getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId
getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (storeCxt cc) user ctId
getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink)
getGroupLink' cc user gInfo =
@@ -85,7 +85,6 @@ import Data.Time.Clock.System (systemEpochDay)
import Directory.Search
import Directory.Util
import Simplex.Chat.Controller
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Store
import Simplex.Chat.Store.Groups
@@ -314,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 (vr 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 (vr 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 (vr 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 (vr cc) user
getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user
getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)]
getAllListedGroups_ db vr' user@User {userId, userContactId} =
getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)]
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 vr' 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
@@ -358,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
@@ -370,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
@@ -382,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 (vr 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,22 +399,25 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
)
|]
getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)]
getAllGroupRegs_ db user@User {userId, userContactId} =
map (toGroupInfoReg supportedChatVRange user)
getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)]
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 (vr 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 (vr 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)
@@ -418,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 (vr 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 :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg)
toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) =
(toGroupInfo vr' 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)
@@ -18,10 +18,9 @@ import Directory.Listing
import Directory.Options
import Directory.Store
import Simplex.Chat (createChatDatabase)
import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..))
import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..), mkStoreCxt)
import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store.Groups (getHostMember)
import Simplex.Chat.Store.Profiles (getUsers)
import Simplex.Chat.Store.Shared (getGroupInfo)
@@ -62,7 +61,7 @@ checkDirectoryLog opts cfg =
runDirectoryMigrations opts cfg st
gs <- readDirectoryLogData logFile
withActiveUser st $ \user -> withTransaction st $ \db -> do
mapM_ (verifyGroupRegistration db user) gs
mapM_ (verifyGroupRegistration (mkStoreCxt cfg) db user) gs
putStrLn $ show (length gs) <> " group registrations OK"
importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO ()
@@ -73,7 +72,7 @@ importDirectoryLogToDB opts cfg = do
ctRegs <- TM.emptyIO
withActiveUser st $ \user -> withTransaction st $ \db -> do
forM_ gs $ \gr ->
whenM (verifyGroupRegistration db user gr) $ do
whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ do
putStrLn $ "importing group " <> show (dbGroupId gr)
insertGroupReg db =<< fixUserGroupRegId ctRegs gr
renamePath logFile (logFile ++ ".bak")
@@ -101,28 +100,28 @@ exportDBToDirectoryLog opts cfg =
runDirectoryMigrations opts cfg st
withActiveUser st $ \user -> do
gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do
gs <- getAllGroupRegs_ db user
gs <- getAllGroupRegs_ db (mkStoreCxt cfg) user
forM_ gs $ \(_, gr) ->
whenM (verifyGroupRegistration db user gr) $
whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $
B.hPutStrLn h $ strEncode $ GRCreate gr
pure gs
putStrLn $ show (length gs) <> " group registrations exported"
saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO ()
saveGroupListingFiles opts _cfg = case webFolder opts of
saveGroupListingFiles opts cfg = case webFolder opts of
Nothing -> exit "use --web-folder to generate listings"
Just dir ->
withChatStore opts $ \st -> withActiveUser st $ \user ->
withTransaction st $ \db ->
getAllListedGroups_ db supportedChatVRange user >>= generateListing dir
getAllListedGroups_ db (mkStoreCxt cfg) user >>= generateListing dir
verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool
verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} =
runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case
verifyGroupRegistration :: StoreCxt -> DB.Connection -> User -> GroupReg -> IO Bool
verifyGroupRegistration cxt db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} =
runExceptT (getGroupInfo db cxt user gId) >>= \case
Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e)
Right GroupInfo {localDisplayName} -> do
let groupRef = show gId <> " " <> T.unpack localDisplayName
runExceptT (getHostMember db supportedChatVRange user gId) >>= \case
runExceptT (getHostMember db cxt user gId) >>= \case
Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e)
Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of
Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus))
@@ -15,9 +15,9 @@ import Simplex.Messaging.Agent.Store.Common (withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Util (catchAll)
vr :: ChatController -> VersionRangeChat
vr ChatController {config = ChatConfig {chatVRange}} = chatVRange
{-# INLINE vr #-}
storeCxt :: ChatController -> StoreCxt
storeCxt ChatController {config} = mkStoreCxt config
{-# INLINE storeCxt #-}
withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a)
withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a
+61 -1
View File
@@ -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=<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?
+1
View File
@@ -202,6 +202,7 @@ cliCommands =
"AbortSwitchGroupMember",
"AcceptContact",
"AcceptMember",
"AddBadge",
"AddContact",
"AddMember",
"AllowRelayGroup",
+11
View File
@@ -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
+4
View File
@@ -198,7 +198,11 @@ toTypeInfo tr =
"AgentInvId",
"AgentRcvFileId",
"AgentSndFileId",
"BadgeMasterKey",
"B64UrlByteString",
"BBSProof",
"BBSPresHeader",
"BBSSignature",
"CbNonce",
"ConnectionLink",
"ConnShortLink",
+1 -1
View File
@@ -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: 376d6a261a1074717aed65ad97bb6f2a9532011b
source-repository-package
type: git
+4
View File
@@ -329,6 +329,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;
@@ -344,6 +345,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;
@@ -363,6 +365,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;
@@ -378,6 +381,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;
+2
View File
@@ -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
@@ -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
@@ -4,7 +4,7 @@ const path = require('path');
const extract = require('extract-zip');
const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
const RELEASE_TAG = 'v6.5.2';
const RELEASE_TAG = 'v6.5.4';
const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
@@ -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]
+80
View File
@@ -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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7" = "0wpri01w30rd3wwzw630yngnj9fmyb7rschl3ic1cjd926vpg9b7";
"https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y";
"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";
+7 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.5.4.1
version: 6.5.5.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -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
+12
View File
@@ -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
+414
View File
@@ -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
+87
View File
@@ -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
+11
View File
@@ -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,
@@ -169,6 +173,12 @@ data ChatConfig = ChatConfig
chatHooks :: ChatHooks
}
-- | 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, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys
{-# INLINE mkStoreCxt #-}
data RandomAgentServers = RandomAgentServers
{ smpServers :: NonEmpty (ServerCfg 'PSMP),
xftpServers :: NonEmpty (ServerCfg 'PXFTP)
@@ -569,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)
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
+74
View File
@@ -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
+1 -1
View File
@@ -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)
+5 -1
View File
@@ -49,6 +49,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
import Data.Type.Equality
import Data.Typeable (Typeable)
import Data.Word (Word32)
import Simplex.Chat.Badges (LocalBadge)
import Simplex.Chat.Call
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types
@@ -1483,7 +1484,10 @@ instance FromField (ChatMessage 'Json) where
data ContactShortLinkData = ContactShortLinkData
{ profile :: Profile,
message :: Maybe MsgContent,
business :: Bool
business :: Bool,
-- set by the receiving client for the UI: the link profile's badge, verified and crypto-free.
-- never part of the published link data (the link carries the proof inside profile).
localBadge :: Maybe LocalBadge
}
deriving (Show)
+29 -23
View File
@@ -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
@@ -74,8 +75,8 @@ getChatLockEntity db agentConnId = do
-- TODO consider whether ConnFailed connections should be excluded:
-- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash
-- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub
getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
getConnectionEntity :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do
c@Connection {connType, entityId} <- getConnection_
case entityId of
Nothing ->
@@ -90,7 +91,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
where
getConnection_ :: ExceptT StoreError IO Connection
getConnection_ = ExceptT $ do
firstRow (toConnection vr) (SEConnectionNotFound agentConnId) $
firstRow (toConnection cxt) (SEConnectionNotFound agentConnId) $
DB.query
db
[sql|
@@ -104,8 +105,9 @@ getConnectionEntity db vr 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 vr 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 vr 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 vr 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 vr 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 vr 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
@@ -191,17 +197,17 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId}
userContact_ _ = Left SEUserContactLinkNotFound
getConnectionEntityByConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity)
getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do
getConnectionEntityByConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity)
getConnectionEntityByConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do
connId_ <-
maybeFirstRow fromOnly $
DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_
getConnectionEntityViaShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity))
getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do
getConnectionEntityViaShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity))
getConnectionEntityViaShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do
(cReq, connId) <- ExceptT getConnReqConnId
(cReq,) <$> getConnectionEntity db vr user connId
(cReq,) <$> getConnectionEntity db cxt user connId
where
getConnReqConnId =
firstRow' toConnReqConnId (SEInternalError "connection not found") $
@@ -222,8 +228,8 @@ getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap either
-- multiple connections can have same via_contact_uri_hash if request was repeated;
-- this function searches for latest connection with contact so that "known contact" plan would be chosen;
-- deleted connections are filtered out to allow re-connecting via same contact address
getContactConnEntityByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity)
getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do
getContactConnEntityByConnReqHash :: DB.Connection -> StoreCxt -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity)
getContactConnEntityByConnReqHash db cxt user@User {userId} (cReqHash1, cReqHash2) = do
connId_ <-
maybeFirstRow fromOnly $
DB.query
@@ -240,7 +246,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2
) c
|]
(userId, cReqHash1, cReqHash2, ConnDeleted)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_
getContactConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId]
getContactConnsToSub db User {userId} filterToSubscribe =
+38 -21
View File
@@ -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
@@ -49,7 +50,7 @@ import Database.SQLite.Simple.QQ (sql)
createOrUpdateContactRequest ::
DB.Connection ->
TVar ChaChaDRG ->
VersionRangeChat ->
StoreCxt ->
User ->
Int64 ->
UserContactLink ->
@@ -65,14 +66,14 @@ createOrUpdateContactRequest ::
createOrUpdateContactRequest
db
gVar
vr
cxt
user@User {userId, userContactId}
uclId
UserContactLink {addressSettings = AddressSettings {businessAddress}}
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_
@@ -89,7 +90,7 @@ createOrUpdateContactRequest
Nothing ->
liftIO (getAcceptedBusinessChat xContactId) >>= \case
Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do
clientMember <- getGroupMemberByMemberId db vr user gInfo customerId
clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId
cr <- liftIO $ getContactRequestByXContactId xContactId
pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember)
Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest
@@ -103,8 +104,9 @@ createOrUpdateContactRequest
where
getAcceptedContact :: XContactId -> IO (Maybe Contact)
getAcceptedContact xContactId = do
currentTs <- getCurrentTime
ct_ <-
maybeFirstRow (toContact vr 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 vr 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
@@ -200,12 +207,12 @@ createOrUpdateContactRequest
"UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?"
(contactId, contactRequestId)
ucr <- getContactRequest db user contactRequestId
ct <- getContact db vr user contactId
ct <- getContact db cxt user contactId
pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct)
createBusinessChat = do
let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user
(gInfo@GroupInfo {groupId}, clientMember) <-
createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences
createBusinessRequestGroup db cxt gVar user cReqChatVRange profile profileId ldn groupPreferences
liftIO $
DB.execute
db
@@ -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
@@ -278,13 +295,13 @@ createOrUpdateContactRequest
getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} =
case (contactId_, businessGroupId_) of
(Just contactId, Nothing) -> do
ct <- getContact db vr user contactId
ct <- getContact db cxt user contactId
pure $ Just (REContact ct)
(Nothing, Just businessGroupId) -> do
gInfo <- getGroupInfo db vr user businessGroupId
gInfo <- getGroupInfo db cxt user businessGroupId
case gInfo of
GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do
clientMember <- getGroupMemberByMemberId db vr user gInfo customerId
clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId
pure $ Just (REBusinessChat gInfo clientMember)
_ -> throwError SEInvalidBusinessChatContactRequest
(Nothing, Nothing) -> pure Nothing
+5 -4
View File
@@ -332,8 +332,8 @@ updateDeliveryJobStatus_ db jobId status errReason_ = do
(status, errReason_, currentTs, jobId)
-- TODO [relays] possible improvement is to prioritize owners and "active" members
getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember]
getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do
getGroupMembersByCursor :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember]
getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do
gmIds :: [Int64] <-
map fromOnly <$> case cursorGMId_ of
Nothing ->
@@ -351,13 +351,14 @@ getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} curs
:. (cursorGMId, count)
)
#if defined(dbPostgres)
map (toContactMember vr user) <$>
currentTs <- getCurrentTime
map (toContactMember currentTs cxt user) <$>
DB.query
db
(groupMemberQuery <> " WHERE m.group_member_id IN ?")
(Only (In gmIds))
#else
rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds
rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds
#endif
where
query =
+108 -92
View File
@@ -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
@@ -243,8 +244,8 @@ createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupM
where
customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo
createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection
createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do
createRelayTestConnection :: DB.Connection -> StoreCxt -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection
createRelayTestConnection db cxt user@User {userId} agentConnId connStatus chatV subMode = do
currentTs <- liftIO getCurrentTime
liftIO $
DB.execute
@@ -261,7 +262,7 @@ createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV
:. (BI True, currentTs, currentTs)
)
connId <- liftIO $ insertedRowId db
getConnectionById db vr user connId
getConnectionById db cxt user connId
updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO ()
updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do
@@ -285,13 +286,13 @@ setPreparedGroupStartedConnection db groupId = do
"UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE group_id = ?"
(BI True, currentTs, groupId)
getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact)
getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 =
getContactByConnReqHash db vr user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right)
getConnReqContactXContactId :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact)
getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 =
getContactByConnReqHash db cxt user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right)
where
getConnection :: IO (Maybe Connection)
getConnection =
maybeFirstRow (toConnection vr) $
maybeFirstRow (toConnection cxt) $
DB.query
db
[sql|
@@ -305,10 +306,11 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 =
|]
(userId, cReqHash1, userId, cReqHash2)
getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact)
getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do
getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact)
getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do
currentTs <- getCurrentTime
ct <-
maybeFirstRow (toContact vr user []) $
maybeFirstRow (toContact currentTs cxt user []) $
DB.query
db
[sql|
@@ -318,6 +320,7 @@ getContactByConnReqHash db vr 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,
@@ -394,18 +397,18 @@ createIncognitoProfile db User {userId} p = do
createdAt <- getCurrentTime
createIncognitoProfile_ db userId createdAt p
createPreparedContact :: DB.Connection -> VersionRangeChat -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact
createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId = do
createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact
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
getContact db vr user contactId
contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs
getContact db cxt user contactId
updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact
updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact
updatePreparedContactUser
db
vr
cxt
user
Contact {contactId, localDisplayName = oldLDN, profile = profile@LocalProfile {profileId, displayName}}
newUser@User {userId = newUserId} = do
@@ -438,15 +441,15 @@ updatePreparedContactUser
|]
(newUserId, currentTs, contactId)
safeDeleteLDN db user oldLDN
getContact db vr newUser contactId
getContact db cxt newUser contactId
createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db vr user Connection {connId, localAlias} p = do
createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
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 vr user contactId
getContact db cxt user contactId
deleteContactConnections :: DB.Connection -> User -> Contact -> IO ()
deleteContactConnections db User {userId} Contact {contactId} = do
@@ -500,13 +503,13 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis
deleteUnusedIncognitoProfileById_ db user profileId
-- TODO remove in future versions: only used for legacy contact cleanup
getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact]
getDeletedContacts db vr user@User {userId} = do
getDeletedContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact]
getDeletedContacts db cxt user@User {userId} = do
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId)
rights <$> mapM (runExceptT . getDeletedContact db vr user) contactIds
rights <$> mapM (runExceptT . getDeletedContact db cxt user) contactIds
getDeletedContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact
getDeletedContact db vr user contactId = getContact_ db vr user contactId True
getDeletedContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact
getDeletedContact db cxt user contactId = getContact_ db cxt user contactId True
deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO ()
deleteContactProfile_ db userId 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
@@ -756,15 +765,15 @@ updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt
(newName, updatedAt, userId, contactId)
safeDeleteLDN db user displayName
getContactByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO Contact
getContactByName db vr user localDisplayName = do
getContactByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO Contact
getContactByName db cxt user localDisplayName = do
cId <- getContactIdByName db user localDisplayName
getContact db vr user cId
getContact db cxt user cId
getUserContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact]
getUserContacts db vr user@User {userId} = do
getUserContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact]
getUserContacts db cxt user@User {userId} = do
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId)
contacts <- rights <$> mapM (runExceptT . getContact db vr user) contactIds
contacts <- rights <$> mapM (runExceptT . getContact db cxt user) contactIds
pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts
getUserContactLinkIdByCReq :: DB.Connection -> Int64 -> ExceptT StoreError IO (Maybe Int64)
@@ -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,
@@ -890,22 +903,23 @@ getContactIdByName db User {userId} cName =
ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName)
getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> VersionRangeChat -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact))
getContactViaShortLinkToConnect db vr user@User {userId} shortLink = do
getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> StoreCxt -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact))
getContactViaShortLinkToConnect db cxt user@User {userId} shortLink = do
liftIO (maybeFirstRow id $ DB.query db "SELECT contact_id, conn_full_link_to_connect FROM contacts WHERE user_id = ? AND conn_short_link_to_connect = ?" (userId, shortLink)) >>= \case
Just (ctId :: Int64, Just (ACR cMode cReq)) ->
case testEquality cMode (sConnectionMode @c) of
Just Refl -> Just . (cReq,) <$> getContact db vr user ctId
Just Refl -> Just . (cReq,) <$> getContact db cxt user ctId
Nothing -> pure Nothing
_ -> pure Nothing
getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact
getContact db vr user contactId = getContact_ db vr user contactId False
getContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact
getContact db cxt user contactId = getContact_ db cxt user contactId False
getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact
getContact_ db vr user@User {userId} contactId deleted = do
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 vr user chatTags) (SEContactNotFound contactId) $
ExceptT . firstRow (toContact currentTs cxt user chatTags) (SEContactNotFound contactId) $
DB.query
db
[sql|
@@ -915,6 +929,7 @@ getContact_ db vr 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,12 +943,13 @@ getContact_ db vr 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 -> VersionRangeChat -> UserId -> Contact -> IO [Connection]
getContactConnections db vr userId Contact {contactId} =
getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection]
getContactConnections db cxt userId Contact {contactId} =
connections =<< liftIO getConnections_
where
getConnections_ =
@@ -950,11 +966,11 @@ getContactConnections db vr userId Contact {contactId} =
|]
(userId, userId, contactId)
connections [] = pure []
connections rows = pure $ map (toConnection vr) rows
connections rows = pure $ map (toConnection cxt) rows
getConnectionById :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Connection
getConnectionById db vr User {userId} connId = ExceptT $ do
firstRow (toConnection vr) (SEConnectionNotFoundById connId) $
getConnectionById :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Connection
getConnectionById db cxt User {userId} connId = ExceptT $ do
firstRow (toConnection cxt) (SEConnectionNotFoundById connId) $
DB.query
db
[sql|

Some files were not shown because too many files have changed in this diff Show More