Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2026-04-11 21:02:01 +01:00
32 changed files with 523 additions and 119 deletions
@@ -77,6 +77,24 @@ struct CIMsgError: View {
}
}
struct RcvMsgErrorItemView: View {
@ObservedObject var chat: Chat
var rcvMsgError: RcvMsgError
var chatItem: ChatItem
var body: some View {
CIMsgError(chat: chat, chatItem: chatItem) {
AlertManager.shared.showAlertMsg(
title: "Message error",
message: switch rcvMsgError {
case let .dropped(attempts): "The app removed this message after \(attempts) attempts to receive it."
case let .parseError(parseError): "\(parseError)"
}
)
}
}
}
struct IntegrityErrorItemView_Previews: PreviewProvider {
static var previews: some View {
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
@@ -145,6 +145,7 @@ struct ChatItemContentView<Content: View>: View {
} else {
ZStack {}
}
case let .rcvMsgError(rcvMsgError): RcvMsgErrorItemView(chat: chat, rcvMsgError: rcvMsgError, chatItem: chatItem)
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
@@ -195,7 +196,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func pendingReviewEventItemText() -> Text {
Text(chatItem.content.text)
Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel))
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.bold)
@@ -209,9 +210,9 @@ struct ChatItemContentView<Content: View>: View {
.font(.caption)
.foregroundColor(secondaryColor)
.fontWeight(.light)
+ chatEventText(chatItem, secondaryColor)
+ chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
} else {
return chatEventText(chatItem, secondaryColor)
return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
}
}
@@ -234,7 +235,7 @@ struct ChatItemContentView<Content: View>: View {
return if count <= 1 {
nil
} else if ns.count == 0 {
Text("\(count) group events")
chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events")
} else if count > ns.count {
Text(members) + textSpace + Text("and \(count - ns.count) other events")
} else {
@@ -275,8 +276,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor
chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text {
chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor)
}
struct ChatItemView_Previews: PreviewProvider {
@@ -296,7 +296,7 @@ struct ChatPreviewView: View {
}
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
return (Text(AttributedString(r.string)), r.hasSecrets)
@@ -37,6 +37,7 @@ struct ChatItemClipped: ViewModifier {
.rcvMsgContent,
.rcvDecryptionError,
.rcvIntegrityError,
.rcvMsgError,
.invalidJSON:
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
false
+18 -18
View File
@@ -187,8 +187,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.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.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 */; };
@@ -563,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.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.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>"; };
@@ -726,8 +726,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -813,8 +813,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -2029,7 +2029,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2079,7 +2079,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2121,7 +2121,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2141,7 +2141,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2166,7 +2166,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2203,7 +2203,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2240,7 +2240,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2291,7 +2291,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2342,7 +2342,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2376,7 +2376,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 325;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+78 -44
View File
@@ -1652,6 +1652,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var isChannel: Bool {
groupInfo?.useRelays == true
}
// this works for features that are common for contacts and groups
public func featureEnabled(_ feature: ChatFeature) -> Bool {
switch self {
@@ -3191,11 +3195,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var timestampText: Text { meta.timestampText }
public var text: String {
switch (content.text, content.msgContent, file) {
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch (content.text(isChannel: isChannel), content.msgContent, file) {
case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))"
case let ("", _, .some(file)): return file.fileName
default: return content.text
default: return content.text(isChannel: isChannel)
}
}
@@ -3265,6 +3271,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case .rcvCall: return false // notification is shown on .callInvitation instead
case .rcvIntegrityError: return false
case .rcvDecryptionError: return false
case .rcvMsgError: return false
case .rcvGroupInvitation: return true
case .sndGroupInvitation: return false
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
@@ -4022,6 +4029,7 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case rcvCall(status: CICallStatus, duration: Int)
case rcvIntegrityError(msgError: MsgErrorType)
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
case rcvMsgError(rcvMsgError: RcvMsgError)
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
@@ -4047,42 +4055,43 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case chatBanner
case invalidJSON(json: Data?)
public var text: String {
get {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvMsgError(rcvMsgError): return rcvMsgError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel)
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel)
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
}
@@ -4150,6 +4159,7 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case .rcvCall: return true
case .rcvIntegrityError: return true
case .rcvDecryptionError: return true
case .rcvMsgError: return true
case .rcvGroupInvitation: return true
case .rcvModerated: return true
case .rcvBlocked: return true
@@ -5076,6 +5086,20 @@ public enum MsgErrorType: Decodable, Hashable {
}
}
public enum RcvMsgError: Decodable, Hashable {
case dropped(attempts: Int)
case parseError(parseError: String)
var text: String {
switch self {
case let .dropped(attempts):
String.localizedStringWithFormat(NSLocalizedString("removed (%d attempts)", comment: "receive error chat item"), attempts)
case let .parseError(parseError):
String.localizedStringWithFormat(NSLocalizedString("error: %@", comment: "receive error chat item"), parseError)
}
}
}
public struct CIGroupInvitation: Decodable, Hashable {
public var groupId: Int64
public var groupMemberId: Int64
@@ -5153,7 +5177,9 @@ public enum RcvGroupEvent: Decodable, Hashable {
case memberProfileUpdated(fromProfile: Profile, toProfile: Profile)
case newMemberPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberAdded(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName)
@@ -5175,8 +5201,12 @@ public enum RcvGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item")
case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .groupDeleted: return isChannel
? NSLocalizedString("deleted channel", comment: "rcv group event chat item")
: NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("updated channel profile", comment: "rcv group event chat item")
: NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item")
case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item")
case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile)
@@ -5208,7 +5238,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case memberAccepted(groupMemberId: Int64, profile: Profile)
case userPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
@@ -5223,7 +5255,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName)
case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item")
case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("channel profile updated", comment: "snd group event chat item")
: NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item")
case .userPendingReview:
return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item")
+3 -3
View File
@@ -74,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
return createNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: title,
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"),
targetContentIdentifier: cInfo.id,
userInfo: ["userId": user.userId],
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
@@ -197,7 +197,7 @@ public func createNotification(
}
// Spec: spec/services/notifications.md#hideSecrets
func hideSecrets(_ cItem: ChatItem) -> String {
func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
@@ -213,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String {
if case let .report(text, reason) = mc {
return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text)
} else {
return cItem.text
return cItem.text(isChannel: isChannel)
}
}
}
+2 -2
View File
@@ -118,8 +118,8 @@ kotlin {
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
implementation("net.java.dev.jna:jna:5.14.0")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
}
@@ -1748,6 +1748,9 @@ sealed class ChatInfo: SomeChat, NamedChat {
is Group -> groupInfo
else -> null
}
val isChannel: Boolean
get() = groupInfo_?.useRelays == true
}
@Serializable
@@ -2891,12 +2894,14 @@ data class ChatItem (
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val text: String get() {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String {
val mc = content.msgContent
return when {
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration))
content.text == "" && file != null -> file.fileName
else -> content.text
content.text(isChannel) == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration))
content.text(isChannel) == "" && file != null -> file.fileName
else -> content.text(isChannel)
}
}
@@ -3035,6 +3040,7 @@ data class ChatItem (
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
is CIContent.RcvIntegrityError -> false
is CIContent.RcvDecryptionError -> false
is CIContent.RcvMsgErrorContent -> false
is CIContent.RcvGroupInvitation -> true
is CIContent.SndGroupInvitation -> false
is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
@@ -3729,6 +3735,7 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvMsgError") class RcvMsgErrorContent(val rcvMsgError: RcvMsgError): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@@ -3754,7 +3761,9 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when (this) {
override val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(MR.strings.deleted_description)
@@ -3763,11 +3772,12 @@ sealed class CIContent: ItemContent {
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvDecryptionError -> msgDecryptError.text
is RcvMsgErrorContent -> rcvMsgError.text
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvDirectEventContent -> rcvDirectEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text(isChannel)
is SndGroupEventContent -> sndGroupEvent.text(isChannel)
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
is RcvChatFeature -> featureText(feature, enabled.text, param)
@@ -3803,6 +3813,7 @@ sealed class CIContent: ItemContent {
is RcvCall -> true
is RcvIntegrityError -> true
is RcvDecryptionError -> true
is RcvMsgErrorContent -> true
is RcvGroupInvitation -> true
is RcvModerated -> true
is RcvBlocked -> true
@@ -4714,6 +4725,17 @@ sealed class MsgErrorType() {
}
}
@Serializable
sealed class RcvMsgError() {
@Serializable @SerialName("dropped") class Dropped(val attempts: Int): RcvMsgError()
@Serializable @SerialName("parseError") class ParseError(val parseError: String): RcvMsgError()
val text: String get() = when (this) {
is Dropped -> String.format(generalGetString(MR.strings.rcv_msg_error_dropped), attempts)
is ParseError -> String.format(generalGetString(MR.strings.rcv_msg_error_parse), parseError)
}
}
@Serializable
sealed class RcvDirectEvent() {
@Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
@@ -4764,7 +4786,9 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
val text: String get() = when (this) {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected)
is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName)
@@ -4779,8 +4803,8 @@ sealed class RcvGroupEvent() {
is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text)
is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile)
is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_updated_channel_profile else MR.strings.rcv_group_event_updated_group_profile)
is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link)
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
@@ -4812,7 +4836,9 @@ sealed class SndGroupEvent() {
@Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent()
val text: String get() = when (this) {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text)
is MemberBlocked -> if (blocked) {
@@ -4822,7 +4848,7 @@ sealed class SndGroupEvent() {
}
is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left)
is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated)
is GroupUpdated -> generalGetString(if (isChannel) MR.strings.snd_channel_event_channel_profile_updated else MR.strings.snd_group_event_group_profile_updated)
is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted)
is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review)
}
@@ -44,7 +44,7 @@ abstract class NtfManager {
chatModel.chatId.value != cInfo.id ||
chatModel.remoteHostId() != rhId)
) {
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem, cInfo.isChannel))
}
}
@@ -119,7 +119,7 @@ abstract class NtfManager {
}
}
private fun hideSecrets(cItem: ChatItem): String {
private fun hideSecrets(cItem: ChatItem, isChannel: Boolean = false): String {
val md = cItem.formattedText
return if (md != null) {
var res = ""
@@ -130,9 +130,9 @@ abstract class NtfManager {
} else {
val mc = cItem.content.msgContent
if (mc is MsgContent.MCReport) {
generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text })
generalGetString(MR.strings.notification_group_report).format(cItem.text(isChannel).ifEmpty { mc.reason.text })
} else {
cItem.text
cItem.text(isChannel)
}
}
}
@@ -312,7 +312,6 @@ fun BoxScope.SelectionHandler(
manager.listState = listState
manager.onCopySelection = {
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode)))
manager.clearSelection()
showToast(generalGetString(MR.strings.copied))
}
@@ -509,7 +508,10 @@ fun SelectionCopyButton() {
.background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.clickable { manager.onCopySelection?.invoke() }
.clickable {
manager.onCopySelection?.invoke()
manager.clearSelection()
}
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -48,8 +48,8 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
chatEventText(ci.content.text, ci.timestampText)
fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString =
chatEventText(ci.content.text(isChannel), ci.timestampText)
fun chatEventText(eventText: String, ts: String): AnnotatedString =
buildAnnotatedString {
@@ -612,7 +612,7 @@ fun ChatItemView(
return if (count <= 1) {
null
} else if (ns.isEmpty()) {
generalGetString(MR.strings.rcv_group_events_count).format(count)
generalGetString(if (cInfo.isChannel) MR.strings.rcv_channel_events_count else MR.strings.rcv_group_events_count).format(count)
} else if (count > ns.size) {
members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size)
} else {
@@ -629,9 +629,9 @@ fun ChatItemView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(cItem))
}.plus(chatEventText(cItem, cInfo.isChannel))
} else {
chatEventText(cItem)
chatEventText(cItem, cInfo.isChannel)
}
}
@@ -643,7 +643,7 @@ fun ChatItemView(
@Composable fun PendingReviewEventItemView() {
Text(
buildAnnotatedString {
withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) }
withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) }
},
Modifier.padding(horizontal = 6.dp, vertical = 6.dp)
)
@@ -714,6 +714,9 @@ fun ChatItemView(
} else {
Box(Modifier.size(0.dp)) {}
}
is CIContent.RcvMsgErrorContent -> {
RcvMsgErrorItemView(c.rcvMsgError, cItem, showTimestamp, cInfo.timedMessagesTTL)
}
is CIContent.RcvDecryptionError -> {
CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
DeleteItemMenu()
@@ -1310,6 +1313,7 @@ fun shapeStyleWithTail(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVis
is CIContent.SndMsgContent,
is CIContent.RcvMsgContent,
is CIContent.RcvDecryptionError,
is CIContent.RcvMsgErrorContent,
is CIContent.SndDeleted,
is CIContent.RcvDeleted,
is CIContent.RcvIntegrityError,
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MsgErrorType
import chat.simplex.common.model.RcvMsgError
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
@@ -73,6 +74,19 @@ fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onC
}
}
@Composable
fun RcvMsgErrorItemView(rcvMsgError: RcvMsgError, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) {
CIMsgError(ci, showTimestamp, timedMessagesTTL) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.alert_title_msg_error),
text = when (rcvMsgError) {
is RcvMsgError.Dropped -> String.format(generalGetString(MR.strings.alert_text_msg_reception_error), rcvMsgError.attempts)
is RcvMsgError.ParseError -> rcvMsgError.parseError
}
)
}
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
@@ -299,7 +299,7 @@ fun MarkdownText (
}
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
val icon = remember { mutableStateOf(PointerIcon.Default) }
val icon = remember { mutableStateOf(PointerIcon.Text) }
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
@@ -336,7 +336,7 @@ fun MarkdownText (
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
PointerIcon.Hand
} else {
PointerIcon.Default
PointerIcon.Text
}
},
shouldConsumeEvent = { offset ->
@@ -431,7 +431,7 @@ private fun SelectableText(
BasicText(
text = text,
modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)),
modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)),
style = style,
maxLines = maxLines,
overflow = overflow,
@@ -241,7 +241,7 @@ fun ChatPreviewView(
Text(previewText.first, color = previewText.second)
} else if (ci != null && showChatPreviews) {
val (text: CharSequence, inlineTextContent) = when {
ci.meta.itemDeleted == null -> ci.text to null
ci.meta.itemDeleted == null -> ci.text(chat.chatInfo.isChannel) to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
@@ -1377,6 +1377,10 @@
<string name="integrity_msg_bad_hash">bad message hash</string>
<string name="integrity_msg_bad_id">bad message ID</string>
<string name="integrity_msg_duplicate">duplicate message</string>
<string name="rcv_msg_error_dropped">dropped (%1$d attempts)</string>
<string name="rcv_msg_error_parse">error: %s</string>
<string name="alert_title_msg_error">Message error</string>
<string name="alert_text_msg_reception_error">The app removed this message after %1$d attempts to receive it.</string>
<string name="alert_title_skipped_messages">Skipped messages</string>
<string name="alert_text_skipped_messages_it_can_happen_when">It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised.</string>
<string name="alert_title_msg_bad_hash">Bad message hash</string>
@@ -1707,7 +1711,9 @@
<string name="rcv_group_event_member_deleted">removed %1$s</string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="rcv_channel_event_channel_deleted">deleted channel</string>
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="rcv_channel_event_updated_channel_profile">updated channel profile</string>
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">requested connection</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
@@ -1718,6 +1724,7 @@
<string name="snd_group_event_member_deleted">you removed %1$s</string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="snd_channel_event_channel_profile_updated">channel profile updated</string>
<string name="snd_group_event_member_accepted">you accepted this member</string>
<string name="snd_group_event_user_pending_review">Please wait for group moderators to review your request to join the group.</string>
@@ -1726,6 +1733,7 @@
<string name="rcv_group_event_3_members_connected">%s, %s and %s connected</string>
<string name="rcv_group_event_n_members_connected">%s, %s and %d other members connected</string>
<string name="rcv_group_events_count">%d group events</string>
<string name="rcv_channel_events_count">%d channel events</string>
<string name="rcv_group_and_other_events">and %d other events</string>
<string name="group_members_2">%s and %s</string>
<string name="group_members_n">%s, %s and %d members</string>
+4 -4
View File
@@ -24,13 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.5-beta.7
android.version_code=339
android.version_name=6.5-beta.8
android.version_code=340
android.bundle=false
desktop.version_name=6.5-beta.7
desktop.version_code=134
desktop.version_name=6.5-beta.8
desktop.version_code=135
kotlin.version=2.1.20
gradle.plugin.version=8.7.0
+44
View File
@@ -51,6 +51,7 @@ This file is generated automatically.
- [Color](#color)
- [CommandError](#commanderror)
- [CommandErrorType](#commanderrortype)
- [CommentsGroupPreference](#commentsgrouppreference)
- [ComposedMessage](#composedmessage)
- [ConnStatus](#connstatus)
- [ConnType](#conntype)
@@ -69,6 +70,7 @@ This file is generated automatically.
- [CreatedConnLink](#createdconnlink)
- [CryptoFile](#cryptofile)
- [CryptoFileArgs](#cryptofileargs)
- [DroppedMsg](#droppedmsg)
- [E2EInfo](#e2einfo)
- [ErrorType](#errortype)
- [FeatureAllowed](#featureallowed)
@@ -148,6 +150,7 @@ This file is generated automatically.
- [RcvFileStatus](#rcvfilestatus)
- [RcvFileTransfer](#rcvfiletransfer)
- [RcvGroupEvent](#rcvgroupevent)
- [RcvMsgError](#rcvmsgerror)
- [RelayProfile](#relayprofile)
- [RelayStatus](#relaystatus)
- [ReportReason](#reportreason)
@@ -453,6 +456,10 @@ RcvDecryptionError:
- msgDecryptError: [MsgDecryptError](#msgdecrypterror)
- msgCount: word32
RcvMsgError:
- type: "rcvMsgError"
- rcvMsgError: [RcvMsgError](#rcvmsgerror)
RcvGroupInvitation:
- type: "rcvGroupInvitation"
- groupInvitation: [CIGroupInvitation](#cigroupinvitation)
@@ -1480,6 +1487,15 @@ LARGE:
- type: "LARGE"
---
## CommentsGroupPreference
**Record type**:
- enable: [GroupFeatureEnabled](#groupfeatureenabled)
- duration: int?
---
## ComposedMessage
@@ -1803,6 +1819,15 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py
- fileNonce: string
---
## DroppedMsg
**Record type**:
- brokerTs: UTCTime
- attempts: int
---
## E2EInfo
@@ -2070,6 +2095,7 @@ Phone:
- reports: [GroupPreference](#grouppreference)
- history: [GroupPreference](#grouppreference)
- sessions: [RoleGroupPreference](#rolegrouppreference)
- comments: [CommentsGroupPreference](#commentsgrouppreference)
- commands: [[ChatBotCommand](#chatbotcommand)]
@@ -2160,6 +2186,7 @@ MemberSupport:
- "reports"
- "history"
- "sessions"
- "comments"
---
@@ -2376,6 +2403,7 @@ Known:
- reports: [GroupPreference](#grouppreference)?
- history: [GroupPreference](#grouppreference)?
- sessions: [RoleGroupPreference](#rolegrouppreference)?
- comments: [CommentsGroupPreference](#commentsgrouppreference)?
- commands: [[ChatBotCommand](#chatbotcommand)]?
@@ -3179,6 +3207,21 @@ MsgBadSignature:
- type: "msgBadSignature"
---
## RcvMsgError
**Discriminated union type**:
Dropped:
- type: "dropped"
- attempts: int
ParseError:
- type: "parseError"
- parseError: string
---
## RelayProfile
@@ -3248,6 +3291,7 @@ A_CRYPTO:
A_DUPLICATE:
- type: "A_DUPLICATE"
- droppedMsg_: [DroppedMsg](#droppedmsg)?
A_QUEUE:
- type: "A_QUEUE"
+6
View File
@@ -237,6 +237,7 @@ chatTypesDocsData =
(sti @Color, STEnum, "", [], "", ""),
(sti @CommandError, STUnion, "", [], "", ""),
(sti @CommandErrorType, STUnion, "", [], "", ""),
(sti @CommentsGroupPreference, STRecord, "", [], "", ""),
(sti @ComposedMessage, STRecord, "", [], "", ""),
(sti @Connection, STRecord, "", [], "", ""),
(sti @ConnectionEntity, STUnion, "", [], "", ""),
@@ -252,6 +253,7 @@ chatTypesDocsData =
(sti @ContactUserPreferences, STRecord, "", [], "", ""),
(sti @CryptoFile, STRecord, "", [], "", ""),
(sti @CryptoFileArgs, STRecord, "", [], "", ""),
(sti @DroppedMsg, STRecord, "", [], "", ""),
(sti @E2EInfo, STRecord, "", [], "", ""),
(sti @ErrorType, STUnion, "", [], "", ""),
(sti @FeatureAllowed, STEnum, "FA", [], "", ""),
@@ -331,6 +333,7 @@ chatTypesDocsData =
(sti @RcvFileStatus, STUnion, "RFS", [], "", ""),
(sti @RcvFileTransfer, STRecord, "", [], "", ""),
(sti @RcvGroupEvent, STUnion, "RGE", [], "", ""),
(sti @RcvMsgError, STUnion, "RME", [], "", ""),
(sti @RelayProfile, STRecord, "", [], "", ""),
(sti @RelayStatus, STEnum, "RS", [], "", ""),
(sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""),
@@ -435,6 +438,7 @@ deriving instance Generic ClientNotice
deriving instance Generic Color
deriving instance Generic CommandError
deriving instance Generic CommandErrorType
deriving instance Generic CommentsGroupPreference
deriving instance Generic ComposedMessage
deriving instance Generic Connection
deriving instance Generic ConnectionEntity
@@ -450,6 +454,7 @@ deriving instance Generic ContactStatus
deriving instance Generic ContactUserPreferences
deriving instance Generic CryptoFile
deriving instance Generic CryptoFileArgs
deriving instance Generic DroppedMsg
deriving instance Generic E2EInfo
deriving instance Generic ErrorType
deriving instance Generic FeatureAllowed
@@ -535,6 +540,7 @@ deriving instance Generic RcvFileDescr
deriving instance Generic RcvFileStatus
deriving instance Generic RcvFileTransfer
deriving instance Generic RcvGroupEvent
deriving instance Generic RcvMsgError
deriving instance Generic RelayProfile
deriving instance Generic RelayStatus
deriving instance Generic ReportReason
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 97802a30fce1dfeea90f0b465e21fc8eca937abb
tag: bc5ea42bec3a63e46b191e4150dd79957f114e01
source-repository-package
type: git
@@ -278,6 +278,7 @@ export type CIContent =
| CIContent.RcvCall
| CIContent.RcvIntegrityError
| CIContent.RcvDecryptionError
| CIContent.RcvMsgError
| CIContent.RcvGroupInvitation
| CIContent.SndGroupInvitation
| CIContent.RcvDirectEvent
@@ -312,6 +313,7 @@ export namespace CIContent {
| "rcvCall"
| "rcvIntegrityError"
| "rcvDecryptionError"
| "rcvMsgError"
| "rcvGroupInvitation"
| "sndGroupInvitation"
| "rcvDirectEvent"
@@ -383,6 +385,11 @@ export namespace CIContent {
msgCount: number // word32
}
export interface RcvMsgError extends Interface {
type: "rcvMsgError"
rcvMsgError: RcvMsgError
}
export interface RcvGroupInvitation extends Interface {
type: "rcvGroupInvitation"
groupInvitation: CIGroupInvitation
@@ -1714,6 +1721,11 @@ export namespace CommandErrorType {
}
}
export interface CommentsGroupPreference {
enable: GroupFeatureEnabled
duration?: number // int
}
export interface ComposedMessage {
fileSource?: CryptoFile
quotedItemId?: number // int64
@@ -2070,6 +2082,11 @@ export interface CryptoFileArgs {
fileNonce: string
}
export interface DroppedMsg {
brokerTs: string // ISO-8601 timestamp
attempts: number // int
}
export interface E2EInfo {
pqEnabled?: boolean
}
@@ -2420,6 +2437,7 @@ export interface FullGroupPreferences {
reports: GroupPreference
history: GroupPreference
sessions: RoleGroupPreference
comments: CommentsGroupPreference
commands: ChatBotCommand[]
}
@@ -2492,6 +2510,7 @@ export enum GroupFeature {
Reports = "reports",
History = "history",
Sessions = "sessions",
Comments = "comments",
}
export enum GroupFeatureEnabled {
@@ -2669,6 +2688,7 @@ export interface GroupPreferences {
reports?: GroupPreference
history?: GroupPreference
sessions?: RoleGroupPreference
comments?: CommentsGroupPreference
commands?: ChatBotCommand[]
}
@@ -3598,6 +3618,26 @@ export namespace RcvGroupEvent {
}
}
export type RcvMsgError = RcvMsgError.Dropped | RcvMsgError.ParseError
export namespace RcvMsgError {
export type Tag = "dropped" | "parseError"
interface Interface {
type: Tag
}
export interface Dropped extends Interface {
type: "dropped"
attempts: number // int
}
export interface ParseError extends Interface {
type: "parseError"
parseError: string
}
}
export interface RelayProfile {
displayName: string
fullName: string
@@ -3673,6 +3713,7 @@ export namespace SMPAgentError {
export interface A_DUPLICATE extends Interface {
type: "A_DUPLICATE"
droppedMsg_?: DroppedMsg
}
export interface A_QUEUE extends Interface {
+1 -1
View File
@@ -24,7 +24,7 @@
"docs": "typedoc"
},
"dependencies": {
"@simplex-chat/types": "^0.3.0",
"@simplex-chat/types": "^0.4.0",
"extract-zip": "^2.0.1",
"fast-deep-equal": "^3.1.3",
"node-addon-api": "^8.5.0"
+30
View File
@@ -872,4 +872,34 @@ export class ChatApi {
const r = await this.sendChatCmd(CC.APISetContactPrefs.cmdString({contactId, preferences}))
if (r.type !== "contactPrefsUpdated") throw new ChatCommandError("error setting contact prefs", r)
}
/**
* Create a direct message contact with a group member.
* Returns the created contact.
* Network usage: interactive.
*/
async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise<T.Contact> {
const r: any = await this.sendChatCmd(`/_create member contact #${groupId} ${groupMemberId}`)
if (r.type === "newMemberContact") return r.contact
throw new ChatCommandError("error creating member contact", r)
}
/**
* Send a direct message invitation to a group member contact.
* The contact must have been created with {@link apiCreateMemberContact}.
* Network usage: interactive.
*/
async apiSendMemberContactInvitation(contactId: number, message?: T.MsgContent | string): Promise<T.Contact> {
let cmd = `/_invite member contact @${contactId}`
if (message !== undefined) {
if (typeof message === "string") {
cmd += ` text ${message}`
} else {
cmd += ` json ${JSON.stringify(message)}`
}
}
const r: any = await this.sendChatCmd(cmd)
if (r.type === "newMemberContactSentInv") return r.contact
throw new ChatCommandError("error sending member contact invitation", r)
}
}
@@ -64,4 +64,89 @@ describe("API tests (use preset servers)", () => {
expect(servers[0] !== servers[1]).toBe(true)
expect(eventCount > 0).toBe(true)
}, 30000)
it("should create member contact and send invitation", async () => {
// create 3 users and start chat controllers
const alice = await api.ChatApi.init(alicePath)
const bob = await api.ChatApi.init(bobPath)
const carolPath = path.join(tmpDir, "carol")
const carol = await api.ChatApi.init(carolPath)
const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
await bob.apiCreateActiveUser({displayName: "bob", fullName: ""})
await carol.apiCreateActiveUser({displayName: "carol", fullName: ""})
await alice.startChat()
await bob.startChat()
await carol.startChat()
// connect alice <-> bob
const aliceLink1 = await alice.apiCreateLink(aliceUser.userId)
await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation)
const [bobContact] = await Promise.all([
(await alice.wait("contactConnected")).contact,
(await bob.wait("contactConnected")).contact
])
// connect alice <-> carol
const aliceLink2 = await alice.apiCreateLink(aliceUser.userId)
await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation)
const [carolContact] = await Promise.all([
(await alice.wait("contactConnected")).contact,
(await carol.wait("contactConnected")).contact
])
// create group with direct messages enabled
const group = await alice.apiNewGroup(aliceUser.userId, {
displayName: "test-group",
fullName: "",
groupPreferences: {
directMessages: {enable: T.GroupFeatureEnabled.On},
},
})
const groupId = group.groupId
// add bob to the group
const bobInvP = bob.wait("receivedGroupInvitation", 15000)
await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member)
const bobInvEvt = await bobInvP
expect(bobInvEvt).toBeDefined()
const aliceBobConnP = alice.wait("connectedToGroupMember", 15000)
const bobAliceConnP = bob.wait("connectedToGroupMember", 15000)
await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId)
await Promise.all([aliceBobConnP, bobAliceConnP])
// add carol to the group
const carolInvP = carol.wait("receivedGroupInvitation", 30000)
await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member)
const carolInvEvt = await carolInvP
expect(carolInvEvt).toBeDefined()
// wait for carol to connect to both alice and bob (and vice versa)
const bobCarolConnP = bob.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
const carolAliceConnP = carol.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000)
const carolBobConnP = carol.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000)
const aliceCarolConnP = alice.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId)
await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP])
// find carol's memberId from bob's perspective
const members = await bob.apiListMembers(groupId)
const carolMember = members.find(m => m.memberProfile.displayName === "carol")
expect(carolMember).toBeDefined()
// test apiCreateMemberContact
const dmContact = await bob.apiCreateMemberContact(groupId, carolMember!.groupMemberId)
expect(dmContact).toBeDefined()
expect(dmContact.contactId).toBeDefined()
// test apiSendMemberContactInvitation
const carolDmP = carol.wait("newMemberContactReceivedInv" as CEvt.Tag, 30000)
const invContact = await bob.apiSendMemberContactInvitation(dmContact.contactId, "hello from bob")
expect(invContact).toBeDefined()
// carol should receive the member contact invitation
const carolDmEvt = await carolDmP
expect(carolDmEvt).toBeDefined()
expect((carolDmEvt as any).contact).toBeDefined()
// cleanup
await alice.stopChat()
await bob.stopChat()
await carol.stopChat()
await alice.close()
await bob.close()
await carol.close()
}, 90000)
})
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."97802a30fce1dfeea90f0b465e21fc8eca937abb" = "1pgrfzir7www4f2h1byvzlihs6vird85m1mhwiinl0xaqsycha1m";
"https://github.com/simplex-chat/simplexmq.git"."bc5ea42bec3a63e46b191e4150dd79957f114e01" = "0lswj7crfnwzh4g28kgxxl7g4i2a9pn03pxj7sqfqy3vs83m3bax";
"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";
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.5.0.14
version: 6.5.0.15
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+16 -2
View File
@@ -493,7 +493,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Left e -> do
atomically $ modifyTVar' tags ("error" :)
logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e
eToView (ChatError . CEException $ "error parsing chat message: " <> e)
createInternalChatItem user (CDDirectRcv ct') (CIRcvMsgError $ RMEParseError $ T.pack e) Nothing
`catchAllErrors` \_ -> pure ()
withRcpt <- checkSendRcpt ct' $ rights aChatMsgs -- not crucial to use ct'' from processEvent
pure (withRcpt, False)
where
@@ -687,6 +688,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
-- error cannot be AUTH error here
updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err)
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
ERR (AGENT (A_DUPLICATE (Just DroppedMsg {brokerTs, attempts}))) ->
createInternalChatItem user (CDDirectRcv ct) (CIRcvMsgError $ RMEDropped attempts) (Just brokerTs)
ERR err -> do
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
@@ -962,7 +965,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Left e -> do
atomically $ modifyTVar' tags ("error" :)
logInfo $ "group msg=error " <> eInfo <> " " <> tshow e
eToView (ChatError . CEException $ "error parsing chat message: " <> e)
if isRelay membership
then
eToView (ChatError . CEException $ "error parsing chat message: " <> e)
else
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvMsgError $ RMEParseError $ T.pack e) Nothing
`catchAllErrors` \_ -> pure ()
pure newDeliveryTasks
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
processEvent gInfo' m' verifiedMsg = do
@@ -1178,6 +1186,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
withStore' $ \db -> forM_ msgIds $ \msgId ->
updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure ()
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
ERR err@(AGENT (A_DUPLICATE (Just DroppedMsg {brokerTs, attempts})))
| isRelay membership ->
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
| otherwise -> do
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvMsgError $ RMEDropped attempts) (Just brokerTs)
ERR err -> do
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
+22
View File
@@ -145,6 +145,7 @@ data CIContent (d :: MsgDirection) where
CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv
CIRcvIntegrityError :: MsgErrorType -> CIContent 'MDRcv
CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv
CIRcvMsgError :: RcvMsgError -> CIContent 'MDRcv
CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv
CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd
CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv
@@ -196,6 +197,11 @@ data MsgDecryptError
| MDERatchetSync
deriving (Eq, Show)
data RcvMsgError
= RMEDropped {attempts :: Int}
| RMEParseError {parseError :: Text}
deriving (Eq, Show)
ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool
ciRequiresAttention content = case msgDirection @d of
SMDSnd -> True
@@ -205,6 +211,7 @@ ciRequiresAttention content = case msgDirection @d of
CIRcvCall {} -> True
CIRcvIntegrityError _ -> True
CIRcvDecryptionError {} -> True
CIRcvMsgError _ -> False
CIRcvGroupInvitation {} -> True
CIRcvDirectEvent rde -> case rde of
RDEContactDeleted -> False
@@ -275,6 +282,7 @@ ciContentToText = \case
CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration
CIRcvIntegrityError err -> msgIntegrityError err
CIRcvDecryptionError err n -> msgDecryptErrorText err n
CIRcvMsgError err -> rcvMsgErrorText err
CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole
CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole
CIRcvDirectEvent event -> rcvDirectEventToText event
@@ -421,6 +429,11 @@ msgIntegrityError = \case
MsgBadHash -> "incorrect message hash"
MsgDuplicate -> "duplicate message ID"
rcvMsgErrorText :: RcvMsgError -> Text
rcvMsgErrorText = \case
RMEDropped {attempts} -> "message removed after " <> tshow attempts <> " attempts"
RMEParseError {parseError} -> "message error: " <> parseError
msgDecryptErrorText :: MsgDecryptError -> Word32 -> Text
msgDecryptErrorText err n =
"decryption error, possibly due to the device change"
@@ -457,6 +470,7 @@ data JSONCIContent
| JCIRcvCall {status :: CICallStatus, duration :: Int}
| JCIRcvIntegrityError {msgError :: MsgErrorType}
| JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
| JCIRcvMsgError {rcvMsgError :: RcvMsgError}
| JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent}
@@ -492,6 +506,7 @@ jsonCIContent = \case
CIRcvCall status duration -> JCIRcvCall {status, duration}
CIRcvIntegrityError err -> JCIRcvIntegrityError err
CIRcvDecryptionError err n -> JCIRcvDecryptionError err n
CIRcvMsgError err -> JCIRcvMsgError err
CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole}
CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole}
CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent}
@@ -527,6 +542,7 @@ aciContentJSON = \case
JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration
JCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err
JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
JCIRcvMsgError err -> ACIContent SMDRcv $ CIRcvMsgError err
JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent
@@ -563,6 +579,7 @@ data DBJSONCIContent
| DBJCIRcvCall {status :: CICallStatus, duration :: Int}
| DBJCIRcvIntegrityError {msgError :: DBMsgErrorType}
| DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
| DBJCIRcvMsgError {rcvMsgError :: RcvMsgError}
| DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent}
@@ -598,6 +615,7 @@ dbJsonCIContent = \case
CIRcvCall status duration -> DBJCIRcvCall {status, duration}
CIRcvIntegrityError err -> DBJCIRcvIntegrityError $ DBME err
CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n
CIRcvMsgError err -> DBJCIRcvMsgError err
CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole}
CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole}
CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde
@@ -633,6 +651,7 @@ aciContentDBJSON = \case
DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration
DBJCIRcvIntegrityError (DBME err) -> ACIContent SMDRcv $ CIRcvIntegrityError err
DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
DBJCIRcvMsgError err -> ACIContent SMDRcv $ CIRcvMsgError err
DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde
@@ -693,6 +712,8 @@ $(JQ.deriveJSON defaultJSON ''E2EInfo)
$(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RME") ''RcvMsgError)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus)
$(JQ.deriveJSON defaultJSON ''CIGroupInvitation)
@@ -751,6 +772,7 @@ toCIContentTag ciContent = case ciContent of
CIRcvCall {} -> "rcvCall"
CIRcvIntegrityError _ -> "rcvIntegrityError"
CIRcvDecryptionError {} -> "rcvDecryptionError"
CIRcvMsgError _ -> "rcvMsgError"
CIRcvGroupInvitation {} -> "rcvGroupInvitation"
CISndGroupInvitation {} -> "sndGroupInvitation"
CIRcvDirectEvent _ -> "rcvDirectEvent"
+6 -2
View File
@@ -56,7 +56,7 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
import Simplex.Messaging.Compression (Compressed, compress1, decompress1)
import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
@@ -799,7 +799,11 @@ parseChatMessages msg = case B.head msg of
decodeCompressed :: ByteString -> [Either String AParsedMsg]
decodeCompressed s = case smpDecode s of
Left e -> [Left e]
Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1 maxDecompressedMsgLength) compressed
Right (compressed :: L.NonEmpty Compressed) -> case traverse decompressedSize compressed of
Nothing -> [Left "compressed size not specified"]
Just sizes
| sum sizes > maxDecompressedMsgLength -> [Left "decompressed size exceeds limit"]
| otherwise -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1) compressed
parseUncompressed' "" = [Left "empty string"]
parseUncompressed' s = parseUncompressed (B.head s) s
-- Binary batch format: '=' <count:1> (<len:2> <body>)*
+47 -3
View File
@@ -177,6 +177,7 @@ data GroupFeature
| GFReports
| GFHistory
| GFSessions
| GFComments
deriving (Show)
data SGroupFeature (f :: GroupFeature) where
@@ -190,6 +191,7 @@ data SGroupFeature (f :: GroupFeature) where
SGFReports :: SGroupFeature 'GFReports
SGFHistory :: SGroupFeature 'GFHistory
SGFSessions :: SGroupFeature 'GFSessions
SGFComments :: SGroupFeature 'GFComments
deriving instance Show (SGroupFeature f)
@@ -217,6 +219,7 @@ groupFeatureNameText = \case
GFReports -> "Member reports"
GFHistory -> "Recent history"
GFSessions -> "Chat sessions"
GFComments -> "Comments"
groupFeatureNameText' :: SGroupFeature f -> Text
groupFeatureNameText' = groupFeatureNameText . toGroupFeature
@@ -230,6 +233,11 @@ groupFeatureMemberAllowed' feature role prefs =
let pref = getGroupPreference feature prefs
in getField @"enable" pref == FEOn && maybe True (role >=) (getField @"role" pref)
-- TODO: some preferences are channel-only (e.g., comments) and should not generate
-- UI items or be configurable in regular groups. Currently they are simply excluded
-- from this list. When more channel-only or group-only preferences are added,
-- consider adding a scope property to GroupFeatureI (e.g., GFScopeAll | GFScopeChannel | GFScopeGroup)
-- and filtering at the call sites in createGroupFeatureItems_ / createGroupFeatureChangedItems.
allGroupFeatures :: [AGroupFeature]
allGroupFeatures =
[ AGF SGFTimedMessages,
@@ -244,7 +252,7 @@ allGroupFeatures =
]
groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f)
groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions} = case f of
groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of
SGFTimedMessages -> timedMessages
SGFDirectMessages -> directMessages
SGFFullDelete -> fullDelete
@@ -255,6 +263,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac
SGFReports -> reports
SGFHistory -> history
SGFSessions -> sessions
SGFComments -> comments
toGroupFeature :: SGroupFeature f -> GroupFeature
toGroupFeature = \case
@@ -268,6 +277,7 @@ toGroupFeature = \case
SGFReports -> GFReports
SGFHistory -> GFHistory
SGFSessions -> GFSessions
SGFComments -> GFComments
class GroupPreferenceI p where
getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f
@@ -279,7 +289,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs)
instance GroupPreferenceI FullGroupPreferences where
getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions} = case f of
getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of
SGFTimedMessages -> timedMessages
SGFDirectMessages -> directMessages
SGFFullDelete -> fullDelete
@@ -290,6 +300,7 @@ instance GroupPreferenceI FullGroupPreferences where
SGFReports -> reports
SGFHistory -> history
SGFSessions -> sessions
SGFComments -> comments
{-# INLINE getGroupPreference #-}
-- collection of optional group preferences
@@ -304,6 +315,7 @@ data GroupPreferences = GroupPreferences
reports :: Maybe ReportsGroupPreference,
history :: Maybe HistoryGroupPreference,
sessions :: Maybe SessionsGroupPreference,
comments :: Maybe CommentsGroupPreference,
commands :: Maybe [ChatBotCommand]
}
deriving (Eq, Show)
@@ -354,6 +366,7 @@ setGroupPreference_ f pref prefs =
SGFReports -> prefs {reports = pref}
SGFHistory -> prefs {history = pref}
SGFSessions -> prefs {sessions = pref}
SGFComments -> prefs {comments = pref}
setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences
setGroupTimedMessagesPreference pref prefs_ =
@@ -396,6 +409,7 @@ data FullGroupPreferences = FullGroupPreferences
reports :: ReportsGroupPreference,
history :: HistoryGroupPreference,
sessions :: SessionsGroupPreference,
comments :: CommentsGroupPreference,
commands :: ListDef ChatBotCommand
}
deriving (Eq, Show)
@@ -465,11 +479,12 @@ defaultGroupPrefs =
reports = ReportsGroupPreference {enable = FEOn},
history = HistoryGroupPreference {enable = FEOff},
sessions = SessionsGroupPreference {enable = FEOff, role = Nothing},
comments = CommentsGroupPreference {enable = FEOff, duration = Nothing},
commands = ListDef []
}
emptyGroupPrefs :: GroupPreferences
emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
businessGroupPrefs :: Preferences -> GroupPreferences
businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} =
@@ -501,6 +516,7 @@ defaultBusinessGroupPrefs =
reports = Just $ ReportsGroupPreference FEOff,
history = Just $ HistoryGroupPreference FEOn,
sessions = Just $ SessionsGroupPreference FEOn Nothing,
comments = Just $ CommentsGroupPreference FEOff Nothing,
commands = Nothing
}
@@ -635,6 +651,14 @@ data SessionsGroupPreference = SessionsGroupPreference
{enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole}
deriving (Eq, Show)
-- Channel comments. ``duration` is time in seconds since post creation
-- after which a channel post stops accepting new comments; `Nothing` means accept comments indefinitely.
data CommentsGroupPreference = CommentsGroupPreference
{ enable :: GroupFeatureEnabled,
duration :: Maybe Int
}
deriving (Eq, Show)
class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where
type GroupFeaturePreference (f :: GroupFeature) = p | p -> f
sGroupFeature :: SGroupFeature f
@@ -678,6 +702,9 @@ instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where
instance HasField "enable" SessionsGroupPreference GroupFeatureEnabled where
hasField p@SessionsGroupPreference {enable} = (\e -> p {enable = e}, enable)
instance HasField "enable" CommentsGroupPreference GroupFeatureEnabled where
hasField p@CommentsGroupPreference {enable} = (\e -> p {enable = e}, enable)
instance GroupFeatureI 'GFTimedMessages where
type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference
sGroupFeature = SGFTimedMessages
@@ -738,6 +765,12 @@ instance GroupFeatureI 'GFSessions where
groupPrefParam _ = Nothing
groupPrefRole SessionsGroupPreference {role} = role
instance GroupFeatureI 'GFComments where
type GroupFeaturePreference 'GFComments = CommentsGroupPreference
sGroupFeature = SGFComments
groupPrefParam CommentsGroupPreference {duration} = duration
groupPrefRole _ = Nothing
instance GroupFeatureNoRoleI 'GFTimedMessages
instance GroupFeatureNoRoleI 'GFFullDelete
@@ -748,6 +781,8 @@ instance GroupFeatureNoRoleI 'GFReports
instance GroupFeatureNoRoleI 'GFHistory
instance GroupFeatureNoRoleI 'GFComments
instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where
hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role)
@@ -788,6 +823,7 @@ groupPrefStateText feature pref param role =
groupParamText_ :: GroupFeature -> Maybe Int -> Text
groupParamText_ feature param = case feature of
GFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param
GFComments -> maybe "" (\p -> " (close after " <> timedTTLText p <> ")") param
_ -> ""
groupPreferenceText :: forall f. GroupFeatureI f => GroupFeaturePreference f -> Text
@@ -938,6 +974,7 @@ mergeGroupPreferences groupPreferences =
reports = pref SGFReports,
history = pref SGFHistory,
sessions = pref SGFSessions,
comments = pref SGFComments,
commands = ListDef $ fromMaybe [] $ groupPreferences >>= commands_
}
where
@@ -957,6 +994,7 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd
reports = pref SGFReports,
history = pref SGFHistory,
sessions = pref SGFSessions,
comments = pref SGFComments,
commands = Just cmds
}
where
@@ -1091,6 +1129,12 @@ instance FromJSON SessionsGroupPreference where
parseJSON v = $(J.mkParseJSON defaultJSON ''SessionsGroupPreference) v
omittedField = Just SessionsGroupPreference {enable = FEOff, role = Nothing}
$(J.deriveToJSON defaultJSON ''CommentsGroupPreference)
instance FromJSON CommentsGroupPreference where
parseJSON v = $(J.mkParseJSON defaultJSON ''CommentsGroupPreference) v
omittedField = Just CommentsGroupPreference {enable = FEOff, duration = Nothing}
$(J.deriveJSON defaultJSON ''GroupPreferences)
instance ToField GroupPreferences where
+7 -1
View File
@@ -671,6 +671,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa
CIDirectRcv -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta
CIRcvMsgError err -> viewRcvMsgError from err ts tz meta
CIRcvGroupEvent {} -> showRcvItemProhibited from
_ -> showRcvItem from
where
@@ -694,6 +695,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa
rcvGroupItem m_ = case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta
CIRcvMsgError err -> viewRcvMsgError from err ts tz meta
CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from
CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False
CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False
@@ -715,6 +717,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa
CILocalRcv -> case content of
CIRcvMsgContent mc -> withLocalFile from $ rcvMsg from context mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta
CIRcvMsgError err -> viewRcvMsgError from err ts tz meta
CIRcvGroupEvent {} -> showRcvItemProhibited from
_ -> showRcvItem from
where
@@ -991,6 +994,9 @@ viewRcvIntegrityError from msgErr ts tz meta = receivedWithTime_ ts tz from [] m
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = [ttyError $ msgIntegrityError err]
viewRcvMsgError :: StyledString -> RcvMsgError -> CurrentTime -> TimeZone -> CIMeta c 'MDRcv -> [StyledString]
viewRcvMsgError from rcvErr ts tz meta = receivedWithTime_ ts tz from [] meta [ttyError $ rcvMsgErrorText rcvErr] False
viewInvalidConnReq :: [StyledString]
viewInvalidConnReq =
[ "",
@@ -2656,7 +2662,7 @@ viewChatError isCmd logLevel testView = \case
BRContent -> "content violates conditions of use"
BROKER _ (NETWORK _) | not isCmd -> []
BROKER _ TIMEOUT | not isCmd -> []
AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd]
AGENT A_DUPLICATE {} -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd]
AGENT (A_PROHIBITED e) -> [withConnEntity <> "error: AGENT A_PROHIBITED, " <> plain e | logLevel <= CLLWarning || isCmd]
CONN NOT_FOUND _ -> [withConnEntity <> "error: CONN NOT_FOUND" | logLevel <= CLLWarning || isCmd]
CRITICAL restart e -> [plain $ "critical error: " <> e] <> ["please restart the app" | restart]
+1 -1
View File
@@ -101,7 +101,7 @@ testChatPreferences :: Maybe Preferences
testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing}
testGroupPreferences :: Maybe GroupPreferences
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, sessions = Nothing, commands = Nothing}
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing}
testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences}