mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-17 04:22:01 +00:00
Merge branch 'stable' into stable-android
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+91
-5
@@ -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()
|
||||
)
|
||||
|
||||
+20
-7
@@ -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))
|
||||
|
||||
+10
-8
@@ -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
|
||||
)
|
||||
|
||||
+17
-4
@@ -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,
|
||||
|
||||
+12
-5
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+21
-7
@@ -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))
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+7
-4
@@ -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)
|
||||
|
||||
+3
-9
@@ -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 ->
|
||||
|
||||
+6
-12
@@ -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)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+3
-3
@@ -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 {
|
||||
|
||||
+12
-4
@@ -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) }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+14
-1
@@ -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,
|
||||
|
||||
+148
@@ -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(
|
||||
|
||||
+21
-6
@@ -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() {
|
||||
|
||||
+3
@@ -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 = {
|
||||
|
||||
+5
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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 |
+11
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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?
|
||||
|
||||
@@ -202,6 +202,7 @@ cliCommands =
|
||||
"AbortSwitchGroupMember",
|
||||
"AcceptContact",
|
||||
"AcceptMember",
|
||||
"AddBadge",
|
||||
"AddContact",
|
||||
"AddMember",
|
||||
"AllowRelayGroup",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -198,7 +198,11 @@ toTypeInfo tr =
|
||||
"AgentInvId",
|
||||
"AgentRcvFileId",
|
||||
"AgentSndFileId",
|
||||
"BadgeMasterKey",
|
||||
"B64UrlByteString",
|
||||
"BBSProof",
|
||||
"BBSPresHeader",
|
||||
"BBSSignature",
|
||||
"CbNonce",
|
||||
"ConnectionLink",
|
||||
"ConnShortLink",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user