diff --git a/.gitignore b/.gitignore
index 44fd61e359..d7106ec8fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,7 @@ website/src/docs/
website/translations.json
website/src/img/images/
website/src/images/
+website/src/js/lottie.min.js
# Generated files
website/package/generated*
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 74395302a8..2ac9d51e9c 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -171,6 +171,12 @@ func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgR
throw r
}
+func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
+ let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
+ if case .cmdOk = r { return }
+ throw r
+}
+
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index 134c9679d9..0c43ebe41a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -62,6 +62,7 @@ struct CIFileView: View {
case .rcvComplete: return true
case .rcvCancelled: return false
case .rcvError: return false
+ case .invalid: return false
}
}
return false
@@ -149,6 +150,7 @@ struct CIFileView: View {
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
}
} else {
fileIcon("doc.fill")
@@ -195,7 +197,7 @@ struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .file("")),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index a9eadc5aa2..b13ee52829 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -99,6 +99,7 @@ struct CIImageView: View {
case .rcvTransfer: progressView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
+ case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index de7b3e251e..996afd0485 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -13,6 +13,7 @@ struct CIMetaView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var metaColor = Color.secondary
+ var paleMetaColor = Color(UIColor.tertiaryLabel)
var body: some View {
if chatItem.isDeletedContent {
@@ -21,12 +22,23 @@ struct CIMetaView: View {
let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL
switch meta.itemStatus {
- case .sndSent:
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
- case .sndRcvd:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
+ case let .sndSent(sndProgress):
+ switch sndProgress {
+ case .complete: ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
+ case .partial: ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .sent)
+ }
+ case let .sndRcvd(_, sndProgress):
+ switch sndProgress {
+ case .complete:
+ ZStack {
+ ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
+ ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
+ }
+ case .partial:
+ ZStack {
+ ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd1)
+ ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd2)
+ }
}
default:
ciMetaText(meta, chatTTL: ttl, color: metaColor)
@@ -61,7 +73,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
switch sent {
case nil: r = r + t1
case .sent: r = r + t1 + gap
- case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : color.opacity(0.67)) + gap
+ case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
}
r = r + Text(" ")
@@ -78,8 +90,12 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
Group {
- CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
- CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index 0232be700b..4387614918 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -212,6 +212,7 @@ struct CIVideoView: View {
}
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
+ case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index e6ce74953b..167823934e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -144,6 +144,7 @@ struct VoiceMessagePlayer: View {
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
+ case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
@@ -268,7 +269,7 @@ struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
index e45b5bd183..f5ae761e84 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
@@ -32,7 +32,7 @@ func emojiText(_ text: String) -> Text {
struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
+ EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
}
.previewLayout(.fixed(width: 360, height: 70))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index 4446131a75..3f7ca3f836 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -75,14 +75,14 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWithQuote: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 9888ae7e8e..3a31ee4508 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -349,8 +349,8 @@ struct FramedItemView_Previews: PreviewProvider {
Group{
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
@@ -363,10 +363,10 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
@@ -381,10 +381,10 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
index 1442f1a2a3..96d3c8eca3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -46,7 +46,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
- MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
+ MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
}
.previewLayout(.fixed(width: 360, height: 200))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index 4db32bc74f..032d7880a4 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -14,11 +14,23 @@ struct ChatItemInfoView: View {
var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
+ @State private var alert: CIInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum CIInfoTab {
case history
case quote
+ case delivery
+ }
+
+ enum CIInfoViewAlert: Identifiable {
+ case deliveryStatusAlert(status: CIStatus)
+
+ var id: String {
+ switch self {
+ case .deliveryStatusAlert: return "deliveryStatusAlert"
+ }
+ }
}
var body: some View {
@@ -31,6 +43,11 @@ struct ChatItemInfoView: View {
}
}
}
+ .alert(item: $alert) { alertItem in
+ switch(alertItem) {
+ case let .deliveryStatusAlert(status): return deliveryStatusAlert(status)
+ }
+ }
}
}
@@ -40,19 +57,44 @@ struct ChatItemInfoView: View {
: NSLocalizedString("Received message", comment: "message info title")
}
+ private var numTabs: Int {
+ var numTabs = 1
+ if chatItemInfo?.memberDeliveryStatuses != nil {
+ numTabs += 1
+ }
+ if ci.quotedItem != nil {
+ numTabs += 1
+ }
+ return numTabs
+ }
+
@ViewBuilder private func itemInfoView() -> some View {
- if let qi = ci.quotedItem {
+ if numTabs > 1 {
TabView(selection: $selection) {
+ if let mdss = chatItemInfo?.memberDeliveryStatuses {
+ deliveryTab(mdss)
+ .tabItem {
+ Label("Delivery", systemImage: "checkmark.message")
+ }
+ .tag(CIInfoTab.delivery)
+ }
historyTab()
.tabItem {
Label("History", systemImage: "clock")
}
.tag(CIInfoTab.history)
- quoteTab(qi)
- .tabItem {
- Label("In reply to", systemImage: "arrowshape.turn.up.left")
- }
- .tag(CIInfoTab.quote)
+ if let qi = ci.quotedItem {
+ quoteTab(qi)
+ .tabItem {
+ Label("In reply to", systemImage: "arrowshape.turn.up.left")
+ }
+ .tag(CIInfoTab.quote)
+ }
+ }
+ .onAppear {
+ if chatItemInfo?.memberDeliveryStatuses != nil {
+ selection = .delivery
+ }
}
} else {
historyTab()
@@ -217,9 +259,89 @@ struct ChatItemInfoView: View {
: Color(uiColor: .tertiarySystemGroupedBackground)
}
+ @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ details()
+ Divider().padding(.vertical)
+ Text("Delivery")
+ .font(.title2)
+ .padding(.bottom, 4)
+ memberDeliveryStatusesView(memberDeliveryStatuses)
+ }
+ .padding()
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ let mss = membersStatuses(memberDeliveryStatuses)
+ if !mss.isEmpty {
+ ForEach(mss, id: \.0.groupMemberId) { memberStatus in
+ memberDeliveryStatusView(memberStatus.0, memberStatus.1)
+ }
+ } else {
+ Text("No info on delivery")
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
+ memberDeliveryStatuses.compactMap({ mds in
+ if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
+ return (mem, mds.memberDeliveryStatus)
+ } else {
+ return nil
+ }
+ })
+ }
+
+ private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
+ HStack{
+ ProfileImage(imageStr: member.image)
+ .frame(width: 30, height: 30)
+ .padding(.trailing, 2)
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Spacer()
+ Group {
+ if let (icon, statusColor) = status.statusIcon(Color.secondary) {
+ switch status {
+ case .sndRcvd:
+ ZStack(alignment: .trailing) {
+ Image(systemName: icon)
+ .foregroundColor(statusColor.opacity(0.67))
+ .padding(.trailing, 6)
+ Image(systemName: icon)
+ .foregroundColor(statusColor.opacity(0.67))
+ }
+ default:
+ Image(systemName: icon)
+ .foregroundColor(statusColor)
+ }
+ } else {
+ Image(systemName: "ellipsis")
+ .foregroundColor(Color.secondary)
+ }
+ }
+ .onTapGesture {
+ alert = .deliveryStatusAlert(status: status)
+ }
+ }
+ }
+
+ func deliveryStatusAlert(_ status: CIStatus) -> Alert {
+ Alert(
+ title: Text(status.statusText),
+ message: Text(status.statusDescription)
+ )
+ }
+
private func itemInfoShareText() -> String {
let meta = ci.meta
- var shareText: [String] = [title, ""]
+ var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, #
"), title), ""]
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
if !ci.chatDir.sent {
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
@@ -245,7 +367,7 @@ struct ChatItemInfoView: View {
]
}
if let qi = ci.quotedItem {
- shareText += ["", NSLocalizedString("In reply to", comment: "copied message info")]
+ shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
let t = qi.text
shareText += [""]
if let sender = qi.getSender(nil) {
@@ -262,9 +384,23 @@ struct ChatItemInfoView: View {
}
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
}
+ if let mdss = chatItemInfo?.memberDeliveryStatuses {
+ let mss = membersStatuses(mdss)
+ if !mss.isEmpty {
+ shareText += ["", NSLocalizedString("## Delivery", comment: "copied message info")]
+ shareText += [""]
+ for (member, status) in mss {
+ shareText += [String.localizedStringWithFormat(
+ NSLocalizedString("%@: %@", comment: "copied message info, : "),
+ member.chatViewName,
+ status.statusDescription
+ )]
+ }
+ }
+ }
if let chatItemInfo = chatItemInfo,
!chatItemInfo.itemVersions.isEmpty {
- shareText += ["", NSLocalizedString("History", comment: "copied message info")]
+ shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
let t = itemVersion.msgContent.text
shareText += [
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index 245b55c921..20a04250f5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -125,9 +125,9 @@ struct ChatItemView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, itemLive: true), revealed: Binding.constant(true))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index bd57841a79..ae6308e213 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -770,6 +770,12 @@ struct ChatView: View {
await MainActor.run {
chatItemInfo = ciInfo
}
+ if case let .group(gInfo) = chat.chatInfo {
+ let groupMembers = await apiListMembers(gInfo.groupId)
+ await MainActor.run {
+ ChatModel.shared.groupMembers = groupMembers
+ }
+ }
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 87954def69..0f125ca8f0 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -9,6 +9,8 @@
import SwiftUI
import SimpleXChat
+let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
+
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@@ -21,6 +23,8 @@ struct GroupChatInfoView: View {
@State private var showAddMembersSheet: Bool = false
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
+ @State private var sendReceipts = SendReceipts.userDefault(true)
+ @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@@ -30,6 +34,7 @@ struct GroupChatInfoView: View {
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
+ case largeGroupReceiptsDisabled
var id: GroupChatInfoViewAlert { get { self } }
}
@@ -52,6 +57,11 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
groupPreferencesButton($groupInfo)
+ if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
+ sendReceiptsOption()
+ } else {
+ sendReceiptsOptionDisabled()
+ }
} header: {
Text("")
} footer: {
@@ -115,9 +125,14 @@ struct GroupChatInfoView: View {
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
+ case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
}
}
.onAppear {
+ if let currentUser = chatModel.currentUser {
+ sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
+ }
+ sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
do {
if let link = try apiGetGroupLink(groupInfo.groupId) {
(groupLink, groupLinkMemberRole) = link
@@ -328,6 +343,38 @@ struct GroupChatInfoView: View {
secondaryButton: .cancel()
)
}
+
+ private func sendReceiptsOption() -> some View {
+ Picker(selection: $sendReceipts) {
+ ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
+ Text(opt.text)
+ }
+ } label: {
+ Label("Send receipts", systemImage: "checkmark.message")
+ }
+ .frame(height: 36)
+ .onChange(of: sendReceipts) { _ in
+ setSendReceipts()
+ }
+ }
+
+ private func setSendReceipts() {
+ var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
+ chatSettings.sendRcpts = sendReceipts.bool()
+ updateChatSettings(chat, chatSettings: chatSettings)
+ }
+
+ private func sendReceiptsOptionDisabled() -> some View {
+ HStack {
+ Label("Send receipts", systemImage: "checkmark.message")
+ Spacer()
+ Text("disabled")
+ .foregroundStyle(.secondary)
+ }
+ .onTapGesture {
+ alert = .largeGroupReceiptsDisabled
+ }
+ }
}
func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View {
@@ -356,6 +403,13 @@ func cantInviteIncognitoAlert() -> Alert {
)
}
+func largeGroupReceiptsDisabledAlert() -> Alert {
+ Alert(
+ title: Text("Receipts are disabled"),
+ message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
+ )
+}
+
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 7f2493dc26..d6cd977b9e 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -258,20 +258,20 @@ struct ChatPreviewView_Previews: PreviewProvider {
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
index 821464be0f..554daaebb1 100644
--- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
}
.disabled(currentNetCfg == NetCfg.proxyDefaults)
- timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
- timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000], label: secondsLabel)
- timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [5_000, 10_000, 20_000, 40_000], label: secondsLabel)
+ timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
+ timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
+ timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [10_000, 20_000, 40_000, 75_000, 100_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -153,7 +153,9 @@ struct AdvancedNetworkSettings: View {
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding, values: [Int], label: String) -> some View {
Picker(title, selection: selection) {
- ForEach(values, id: \.self) { value in
+ let v = selection.wrappedValue
+ let vs = values.contains(v) ? values : values + [v]
+ ForEach(vs, id: \.self) { value in
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index 789a5330e5..c7f668daf7 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -21,6 +21,10 @@ struct PrivacySettings: View {
@State private var contactReceiptsReset = false
@State private var contactReceiptsOverrides = 0
@State private var contactReceiptsDialogue = false
+ @State private var groupReceipts = false
+ @State private var groupReceiptsReset = false
+ @State private var groupReceiptsOverrides = 0
+ @State private var groupReceiptsDialogue = false
@State private var alert: PrivacySettingsViewAlert?
enum PrivacySettingsViewAlert: Identifiable {
@@ -89,15 +93,15 @@ struct PrivacySettings: View {
settingsRow("person") {
Toggle("Contacts", isOn: $contactReceipts)
}
-// settingsRow("person.2") {
-// Toggle("Small groups (max 20)", isOn: Binding.constant(false))
-// }
+ settingsRow("person.2") {
+ Toggle("Small groups (max 20)", isOn: $groupReceipts)
+ }
} header: {
Text("Send delivery receipts to")
} footer: {
VStack(alignment: .leading) {
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
- Text("They can be overridden in contact settings")
+ Text("They can be overridden in contact and group settings.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -113,19 +117,44 @@ struct PrivacySettings: View {
contactReceipts.toggle()
}
}
+ .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
+ Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
+ setSendReceiptsGroups(groupReceipts, clearOverrides: false)
+ }
+ Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
+ setSendReceiptsGroups(groupReceipts, clearOverrides: true)
+ }
+ Button("Cancel", role: .cancel) {
+ groupReceiptsReset = true
+ groupReceipts.toggle()
+ }
+ }
}
}
- .onChange(of: contactReceipts) { _ in // sometimes there is race with onAppear
+ .onChange(of: contactReceipts) { _ in
if contactReceiptsReset {
contactReceiptsReset = false
} else {
setOrAskSendReceiptsContacts(contactReceipts)
}
}
+ .onChange(of: groupReceipts) { _ in
+ if groupReceiptsReset {
+ groupReceiptsReset = false
+ } else {
+ setOrAskSendReceiptsGroups(groupReceipts)
+ }
+ }
.onAppear {
- if let u = m.currentUser, contactReceipts != u.sendRcptsContacts {
- contactReceiptsReset = true
- contactReceipts = u.sendRcptsContacts
+ if let u = m.currentUser {
+ if contactReceipts != u.sendRcptsContacts {
+ contactReceiptsReset = true
+ contactReceipts = u.sendRcptsContacts
+ }
+ if groupReceipts != u.sendRcptsSmallGroups {
+ groupReceiptsReset = true
+ groupReceipts = u.sendRcptsSmallGroups
+ }
}
}
.alert(item: $alert) { alert in
@@ -179,7 +208,55 @@ struct PrivacySettings: View {
}
}
} catch let error {
- alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
+ alert = .error(title: "Error setting contact delivery receipts!", error: "Error: \(responseError(error))")
+ }
+ }
+ }
+
+ private func setOrAskSendReceiptsGroups(_ enable: Bool) {
+ groupReceiptsOverrides = m.chats.reduce(0) { count, chat in
+ let sendRcpts = chat.chatInfo.groupInfo?.chatSettings.sendRcpts
+ return count + (sendRcpts == nil || sendRcpts == enable ? 0 : 1)
+ }
+ if groupReceiptsOverrides == 0 {
+ setSendReceiptsGroups(enable, clearOverrides: false)
+ } else {
+ groupReceiptsDialogue = true
+ }
+ }
+
+ private var groupReceiptsDialogTitle: LocalizedStringKey {
+ groupReceipts
+ ? "Sending receipts is disabled for \(groupReceiptsOverrides) groups"
+ : "Sending receipts is enabled for \(groupReceiptsOverrides) groups"
+ }
+
+ private func setSendReceiptsGroups(_ enable: Bool, clearOverrides: Bool) {
+ Task {
+ do {
+ if let currentUser = m.currentUser {
+ let userMsgReceiptSettings = UserMsgReceiptSettings(enable: enable, clearOverrides: clearOverrides)
+ try await apiSetUserGroupReceipts(currentUser.userId, userMsgReceiptSettings: userMsgReceiptSettings)
+ privacyDeliveryReceiptsSet.set(true)
+ await MainActor.run {
+ var updatedUser = currentUser
+ updatedUser.sendRcptsSmallGroups = enable
+ m.updateUser(updatedUser)
+ if clearOverrides {
+ m.chats.forEach { chat in
+ if var groupInfo = chat.chatInfo.groupInfo {
+ let sendRcpts = groupInfo.chatSettings.sendRcpts
+ if sendRcpts != nil && sendRcpts != enable {
+ groupInfo.chatSettings.sendRcpts = nil
+ m.updateGroup(groupInfo)
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch let error {
+ alert = .error(title: "Error setting group delivery receipts!", error: "Error: \(responseError(error))")
}
}
}
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 2a5735990a..c9fcbbfa91 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -137,11 +137,6 @@
5CE2BA97284537A800EC33A6 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA96284537A800EC33A6 /* dummy.m */; };
5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; platformFilter = ios; };
- 5CE381E12A6C103D004FB9E1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DC2A6C103D004FB9E1 /* libffi.a */; };
- 5CE381E22A6C103D004FB9E1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */; };
- 5CE381E32A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */; };
- 5CE381E42A6C103D004FB9E1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DF2A6C103D004FB9E1 /* libgmp.a */; };
- 5CE381E52A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */; };
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
@@ -176,10 +171,20 @@
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
+ 64C9F3CF2A73C538002C80AF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CA2A73C538002C80AF /* libgmpxx.a */; };
+ 64C9F3D02A73C538002C80AF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CB2A73C538002C80AF /* libgmp.a */; };
+ 64C9F3D12A73C538002C80AF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CC2A73C538002C80AF /* libffi.a */; };
+ 64C9F3D22A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */; };
+ 64C9F3D32A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
+ 64EC94052A77EC4F0025EAA3 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94002A77EC4F0025EAA3 /* libffi.a */; };
+ 64EC94062A77EC4F0025EAA3 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94012A77EC4F0025EAA3 /* libgmpxx.a */; };
+ 64EC94072A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94022A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a */; };
+ 64EC94082A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94032A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a */; };
+ 64EC94092A77EC4F0025EAA3 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94042A77EC4F0025EAA3 /* libgmp.a */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
@@ -415,11 +420,6 @@
5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = SimpleXChat.docc; sourceTree = ""; };
5CE2BA8A2845332200EC33A6 /* SimpleX.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SimpleX.h; sourceTree = ""; };
5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; };
- 5CE381DC2A6C103D004FB9E1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
- 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a"; sourceTree = ""; };
- 5CE381DF2A6C103D004FB9E1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
- 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a"; sourceTree = ""; };
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; };
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; };
@@ -454,11 +454,21 @@
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; };
+ 64C9F3CA2A73C538002C80AF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 64C9F3CB2A73C538002C80AF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 64C9F3CC2A73C538002C80AF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a"; sourceTree = ""; };
+ 64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a"; sourceTree = ""; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; };
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; };
+ 64EC94002A77EC4F0025EAA3 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 64EC94012A77EC4F0025EAA3 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 64EC94022A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a"; sourceTree = ""; };
+ 64EC94032A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a"; sourceTree = ""; };
+ 64EC94042A77EC4F0025EAA3 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
@@ -501,13 +511,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5CE381E22A6C103D004FB9E1 /* libgmpxx.a in Frameworks */,
+ 64C9F3CF2A73C538002C80AF /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
- 5CE381E32A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a in Frameworks */,
- 5CE381E52A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a in Frameworks */,
- 5CE381E12A6C103D004FB9E1 /* libffi.a in Frameworks */,
- 5CE381E42A6C103D004FB9E1 /* libgmp.a in Frameworks */,
+ 64C9F3D22A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a in Frameworks */,
+ 64C9F3D02A73C538002C80AF /* libgmp.a in Frameworks */,
+ 64C9F3D12A73C538002C80AF /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
+ 64C9F3D32A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -568,11 +578,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5CE381DC2A6C103D004FB9E1 /* libffi.a */,
- 5CE381DF2A6C103D004FB9E1 /* libgmp.a */,
- 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */,
- 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */,
- 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */,
+ 64C9F3CC2A73C538002C80AF /* libffi.a */,
+ 64C9F3CB2A73C538002C80AF /* libgmp.a */,
+ 64C9F3CA2A73C538002C80AF /* libgmpxx.a */,
+ 64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */,
+ 64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */,
);
path = Libraries;
sourceTree = "";
@@ -1478,7 +1488,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 160;
+ CURRENT_PROJECT_VERSION = 161;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1499,7 +1509,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 5.2;
+ MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1520,7 +1530,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 160;
+ CURRENT_PROJECT_VERSION = 161;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1541,7 +1551,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 5.2;
+ MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1600,7 +1610,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 160;
+ CURRENT_PROJECT_VERSION = 161;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1613,7 +1623,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 5.2;
+ MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1632,7 +1642,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 160;
+ CURRENT_PROJECT_VERSION = 161;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1645,7 +1655,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 5.2;
+ MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift
index 344359b38c..5658d7a091 100644
--- a/apps/ios/SimpleXChat/APITypes.swift
+++ b/apps/ios/SimpleXChat/APITypes.swift
@@ -19,6 +19,7 @@ public enum ChatCommand {
case apiSetActiveUser(userId: Int64, viewPwd: String?)
case setAllContactReceipts(enable: Bool)
case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
+ case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
case apiHideUser(userId: Int64, viewPwd: String)
case apiUnhideUser(userId: Int64, viewPwd: String)
case apiMuteUser(userId: Int64)
@@ -128,6 +129,9 @@ public enum ChatCommand {
case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings):
let umrs = userMsgReceiptSettings
return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
+ case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings):
+ let umrs = userMsgReceiptSettings
+ return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
case let .apiMuteUser(userId): return "/_mute user \(userId)"
@@ -257,6 +261,7 @@ public enum ChatCommand {
case .apiSetActiveUser: return "apiSetActiveUser"
case .setAllContactReceipts: return "setAllContactReceipts"
case .apiSetUserContactReceipts: return "apiSetUserContactReceipts"
+ case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts"
case .apiHideUser: return "apiHideUser"
case .apiUnhideUser: return "apiUnhideUser"
case .apiMuteUser: return "apiMuteUser"
@@ -1052,9 +1057,9 @@ public struct NetCfg: Codable, Equatable {
public static let defaults: NetCfg = NetCfg(
socksProxy: nil,
sessionMode: TransportSessionMode.user,
- tcpConnectTimeout: 10_000_000,
- tcpTimeout: 7_000_000,
- tcpTimeoutPerKb: 10_000,
+ tcpConnectTimeout: 15_000_000,
+ tcpTimeout: 10_000_000,
+ tcpTimeoutPerKb: 20_000,
tcpKeepAlive: KeepAliveOpts.defaults,
smpPingInterval: 1200_000_000,
smpPingCount: 3,
@@ -1064,9 +1069,9 @@ public struct NetCfg: Codable, Equatable {
public static let proxyDefaults: NetCfg = NetCfg(
socksProxy: nil,
sessionMode: TransportSessionMode.user,
- tcpConnectTimeout: 20_000_000,
- tcpTimeout: 15_000_000,
- tcpTimeoutPerKb: 20_000,
+ tcpConnectTimeout: 30_000_000,
+ tcpTimeout: 20_000_000,
+ tcpTimeoutPerKb: 40_000,
tcpKeepAlive: KeepAliveOpts.defaults,
smpPingInterval: 1200_000_000,
smpPingCount: 3,
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index 31930fd31c..368961492b 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -1200,6 +1200,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
+ public var groupInfo: GroupInfo? {
+ switch self {
+ case let .group(groupInfo): return groupInfo
+ default: return nil
+ }
+ }
+
// this works for features that are common for contacts and groups
public func featureEnabled(_ feature: ChatFeature) -> Bool {
switch self {
@@ -2263,18 +2270,7 @@ public struct CIMeta: Decodable {
}
public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
- switch itemStatus {
- case .sndSent: return ("checkmark", metaColor)
- case let .sndRcvd(msgRcptStatus):
- switch msgRcptStatus {
- case .ok: return ("checkmark", metaColor) // ("checkmark.circle", metaColor)
- case .badMsgHash: return ("checkmark", .red) // ("checkmark.circle", .red)
- }
- case .sndErrorAuth: return ("multiply", .red)
- case .sndError: return ("exclamationmark.triangle.fill", .yellow)
- case .rcvNew: return ("circlebadge.fill", Color.accentColor)
- default: return nil
- }
+ itemStatus.statusIcon(metaColor)
}
public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, editable: Bool = true) -> CIMeta {
@@ -2337,12 +2333,13 @@ private func recent(_ date: Date) -> Bool {
public enum CIStatus: Decodable {
case sndNew
- case sndSent
- case sndRcvd(msgRcptStatus: MsgReceiptStatus)
+ case sndSent(sndProgress: SndCIStatusProgress)
+ case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress)
case sndErrorAuth
case sndError(agentError: String)
case rcvNew
case rcvRead
+ case invalid(text: String)
var id: String {
switch self {
@@ -2353,6 +2350,50 @@ public enum CIStatus: Decodable {
case .sndError: return "sndError"
case .rcvNew: return "rcvNew"
case .rcvRead: return "rcvRead"
+ case .invalid: return "invalid"
+ }
+ }
+
+ public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
+ switch self {
+ case .sndNew: return nil
+ case .sndSent: return ("checkmark", metaColor)
+ case let .sndRcvd(msgRcptStatus, _):
+ switch msgRcptStatus {
+ case .ok: return ("checkmark", metaColor)
+ case .badMsgHash: return ("checkmark", .red)
+ }
+ case .sndErrorAuth: return ("multiply", .red)
+ case .sndError: return ("exclamationmark.triangle.fill", .yellow)
+ case .rcvNew: return ("circlebadge.fill", Color.accentColor)
+ case .rcvRead: return nil
+ case .invalid: return ("questionmark", metaColor)
+ }
+ }
+
+ public var statusText: String {
+ switch self {
+ case .sndNew: return NSLocalizedString("Sending message", comment: "item status text")
+ case .sndSent: return NSLocalizedString("Message sent", comment: "item status text")
+ case .sndRcvd: return NSLocalizedString("Sent message received", comment: "item status text")
+ case .sndErrorAuth: return NSLocalizedString("Error sending message", comment: "item status text")
+ case .sndError: return NSLocalizedString("Error sending message", comment: "item status text")
+ case .rcvNew: return NSLocalizedString("Message received", comment: "item status text")
+ case .rcvRead: return NSLocalizedString("Message read", comment: "item status text")
+ case .invalid: return NSLocalizedString("Invalid status", comment: "item status text")
+ }
+ }
+
+ public var statusDescription: String {
+ switch self {
+ case .sndNew: return NSLocalizedString("Sending message is in progress or pending.", comment: "item status description")
+ case .sndSent: return NSLocalizedString("Message has been sent to the recipient's relay.", comment: "item status description")
+ case .sndRcvd: return NSLocalizedString("Message has been received by the recipient.", comment: "item status description")
+ case .sndErrorAuth: return NSLocalizedString("Message delivery error. Most likely this recipient has deleted the connection with you.", comment: "item status description")
+ case let .sndError(agentError): return String.localizedStringWithFormat(NSLocalizedString("Unexpected message delivery error: %@", comment: "item status description"), agentError)
+ case .rcvNew: return NSLocalizedString("New message from this sender.", comment: "item status description")
+ case .rcvRead: return NSLocalizedString("You've read this received message.", comment: "item status description")
+ case let .invalid(text): return text
}
}
}
@@ -2362,6 +2403,11 @@ public enum MsgReceiptStatus: String, Decodable {
case badMsgHash
}
+public enum SndCIStatusProgress: String, Decodable {
+ case partial
+ case complete
+}
+
public enum CIDeleted: Decodable {
case deleted(deletedTs: Date?)
case moderated(deletedTs: Date?, byGroupMember: GroupMember)
@@ -2615,6 +2661,7 @@ public struct CIFile: Decodable {
case .rcvCancelled: return false
case .rcvComplete: return true
case .rcvError: return false
+ case .invalid: return false
}
}
}
@@ -2638,6 +2685,7 @@ public struct CIFile: Decodable {
case .rcvCancelled: return nil
case .rcvComplete: return nil
case .rcvError: return nil
+ case .invalid: return nil
}
}
}
@@ -2698,6 +2746,7 @@ public enum CIFileStatus: Decodable, Equatable {
case rcvComplete
case rcvCancelled
case rcvError
+ case invalid(text: String)
var id: String {
switch self {
@@ -2712,6 +2761,7 @@ public enum CIFileStatus: Decodable, Equatable {
case .rcvComplete: return "rcvComplete"
case .rcvCancelled: return "rcvCancelled"
case .rcvError: return "rcvError"
+ case .invalid: return "invalid"
}
}
}
@@ -3205,6 +3255,7 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
public struct ChatItemInfo: Decodable {
public var itemVersions: [ChatItemVersion]
+ public var memberDeliveryStatuses: [MemberDeliveryStatus]?
}
public struct ChatItemVersion: Decodable {
@@ -3214,3 +3265,8 @@ public struct ChatItemVersion: Decodable {
public var itemVersionTs: Date
public var createdAt: Date
}
+
+public struct MemberDeliveryStatus: Decodable {
+ public var groupMemberId: Int64
+ public var memberDeliveryStatus: CIStatus
+}
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
index b8c517ddfa..55d8202f89 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
@@ -107,39 +107,18 @@ fun processNotificationIntent(intent: Intent?) {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
if (chatId != null) {
- withBGApi {
- awaitChatStartedIfNeeded(chatModel)
- if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
- chatModel.controller.changeActiveUser(userId, null)
- }
- val cInfo = chatModel.getChat(chatId)?.chatInfo
- chatModel.clearOverlays.value = true
- if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
- }
+ ntfManager.openChatAction(userId, chatId)
}
}
NtfManager.ShowChatsAction -> {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
- withBGApi {
- awaitChatStartedIfNeeded(chatModel)
- if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
- chatModel.controller.changeActiveUser(userId, null)
- }
- chatModel.chatId.value = null
- chatModel.clearOverlays.value = true
- }
+ ntfManager.showChatsAction(userId)
}
NtfManager.AcceptCallAction -> {
val chatId = intent.getStringExtra("chatId")
if (chatId == null || chatId == "") return
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
- chatModel.clearOverlays.value = true
- val invitation = chatModel.callInvitations[chatId]
- if (invitation == null) {
- AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
- } else {
- chatModel.callManager.acceptIncomingCall(invitation = invitation)
- }
+ ntfManager.acceptCallAction(chatId)
}
}
}
@@ -201,19 +180,6 @@ fun processExternalIntent(intent: Intent?) {
fun isMediaIntent(intent: Intent): Boolean =
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
-suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
- // Still decrypting database
- if (chatModel.chatRunning.value == null) {
- val step = 50L
- for (i in 0..(timeout / step)) {
- if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
- break
- }
- delay(step)
- }
- }
-}
-
//fun testJson() {
// val str: String = """
// """.trimIndent()
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
index 3174e06265..208beff21c 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -36,6 +36,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
initHaskell()
initMultiplatform()
tmpDir.deleteRecursively()
+ tmpDir.mkdir()
withBGApi {
initChatController()
@@ -139,14 +140,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
androidAppContext = this
APPLICATION_ID = BuildConfig.APPLICATION_ID
ntfManager = object : chat.simplex.common.platform.NtfManager() {
- override fun notifyContactConnected(user: User, contact: Contact) = NtfManager.notifyContactConnected(user, contact)
- override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = NtfManager.notifyContactRequestReceived(user, cInfo)
- override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) = NtfManager.notifyMessageReceived(user, cInfo, cItem)
override fun notifyCallInvitation(invitation: RcvCallInvitation) = NtfManager.notifyCallInvitation(invitation)
override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId)
override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId)
- override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions)
- override fun createNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
+ override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first })
+ override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
override fun cancelCallNotification() = NtfManager.cancelCallNotification()
override fun cancelAllNotifications() = NtfManager.cancelAllNotifications()
}
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
similarity index 88%
rename from apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt
rename to apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
index 5752d3fd6b..95d2520f56 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
@@ -15,7 +15,6 @@ import chat.simplex.app.*
import chat.simplex.app.TAG
import chat.simplex.app.views.call.IncomingCallActivity
import chat.simplex.app.views.call.getKeyguardManager
-import chat.simplex.common.views.chatlist.acceptContactRequest
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
@@ -82,31 +81,6 @@ object NtfManager {
}
}
- fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
- displayNotification(
- user = user,
- chatId = cInfo.id,
- displayName = cInfo.displayName,
- msgText = generalGetString(MR.strings.notification_new_contact_request),
- image = cInfo.image,
- listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
- )
- }
-
- fun notifyContactConnected(user: User, contact: Contact) {
- displayNotification(
- user = user,
- chatId = contact.id,
- displayName = contact.displayName,
- msgText = generalGetString(MR.strings.notification_contact_connected)
- )
- }
-
- fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
- if (!cInfo.ntfsEnabled) return
- displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
- }
-
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) {
if (!user.showNotifications) return
Log.d(TAG, "notifyMessageReceived $chatId")
@@ -243,19 +217,6 @@ object NtfManager {
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
- private fun hideSecrets(cItem: ChatItem): String {
- val md = cItem.formattedText
- return if (md != null) {
- var res = ""
- for (ft in md) {
- res += if (ft.format is Format.Secret) "..." else ft.text
- }
- res
- } else {
- cItem.text
- }
- }
-
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
@@ -299,18 +260,7 @@ object NtfManager {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
when (intent.action) {
- NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
- val isCurrentUser = m.currentUser.value?.userId == userId
- val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
- (m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
- } else {
- null
- }
- val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
- acceptContactRequest(apiId, cInfo, isCurrentUser, m)
- cancelNotificationsForChat(chatId)
- }
-
+ NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId)
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {
diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts
index 94cecd1725..bd5da47a14 100644
--- a/apps/multiplatform/build.gradle.kts
+++ b/apps/multiplatform/build.gradle.kts
@@ -55,6 +55,7 @@ allprojects {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ maven("https://oss.sonatype.org/content/repositories/snapshots")
maven("https://jitpack.io")
}
}
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index 1d7eb6b3c7..45a963b057 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -95,6 +95,7 @@ kotlin {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
+ implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7")
}
}
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
index c8ad31f760..67c41c3d79 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
@@ -277,6 +277,7 @@ actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? {
return try {
val ext = if (asPng) "png" else "jpg"
+ tmpDir.mkdir()
return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply {
outputStream().use { out ->
image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out)
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt
index 0944ff4aab..605c40445a 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt
@@ -13,14 +13,14 @@ actual fun SetNotificationsModeAdditions() {
val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(notificationsPermissionState.hasPermission) {
if (notificationsPermissionState.hasPermission) {
- ntfManager.createNtfChannelsMaybeShowAlert()
+ ntfManager.androidCreateNtfChannelsMaybeShowAlert()
} else {
notificationsPermissionState.launchPermissionRequest()
}
}
} else {
LaunchedEffect(Unit) {
- ntfManager.createNtfChannelsMaybeShowAlert()
+ ntfManager.androidCreateNtfChannelsMaybeShowAlert()
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
index fe8dcbd536..7b6c032c8a 100644
--- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
+++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
@@ -1,4 +1,7 @@
#include
+//#include
+//#include
+//#include
// from the RTS
void hs_init(int * argc, char **argv[]);
@@ -69,6 +72,9 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
+ //jint length = (jint) (*env)->GetStringUTFLength(env, msg);
+ //for (int i = 0; i < length; ++i)
+ // __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
index 2c3e123fd5..8e869ca2d9 100644
--- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
+++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
@@ -1,20 +1,10 @@
#include
#include
+#include
// from the RTS
void hs_init(int * argc, char **argv[]);
-//extern void __svfscanf(void){};
-//extern void __vfwscanf(void){};
-//extern void __memset_chk_fail(void){};
-//extern void __strcpy_chk_generic(void){};
-//extern void __strcat_chk_generic(void){};
-//extern void __libc_globals(void){};
-//extern void __rel_iplt_start(void){};
-
-// Android 9 only, not 13
-//extern void reallocarray(void){};
-
JNIEXPORT void JNICALL
Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) {
hs_init(NULL, NULL);
@@ -33,7 +23,7 @@ extern char *chat_password_hash(const char *pwd, const char *salt);
// As a reference: https://stackoverflow.com/a/60002045
-jstring correct_string_utf8(JNIEnv *env, char *string) {
+jstring decode_to_utf8_string(JNIEnv *env, char *string) {
jobject bb = (*env)->NewDirectByteBuffer(env, (void *)string, strlen(string));
jclass cls_charset = (*env)->FindClass(env, "java/nio/charset/Charset");
jmethodID mid_charset_forName = (*env)->GetStaticMethodID(env, cls_charset, "forName", "(Ljava/lang/String;)Ljava/nio/charset/Charset;");
@@ -52,14 +42,33 @@ jstring correct_string_utf8(JNIEnv *env, char *string) {
return res;
}
+char * encode_to_utf8_chars(JNIEnv *env, jstring string) {
+ if (!string) return "";
+
+ const jclass cls_string = (*env)->FindClass(env, "java/lang/String");
+ const jmethodID mid_getBytes = (*env)->GetMethodID(env, cls_string, "getBytes", "(Ljava/lang/String;)[B");
+ const jbyteArray jbyte_array = (jbyteArray) (*env)->CallObjectMethod(env, string, mid_getBytes, (*env)->NewStringUTF(env, "UTF-8"));
+ jint length = (jint) (*env)->GetArrayLength(env, jbyte_array);
+ jbyte *jbytes = malloc(length + 1);
+ (*env)->GetByteArrayRegion(env, jbyte_array, 0, length, jbytes);
+ // char * should be null terminated but jbyte * isn't. Terminate it with \0. Otherwise, Haskell will not see the end of string
+ jbytes[length] = '\0';
+
+ //for (int i = 0; i < length; ++i)
+ // fprintf(stderr, "%d: %02x\n", i, jbytes[i]);
+
+ (*env)->DeleteLocalRef(env, jbyte_array);
+ (*env)->DeleteLocalRef(env, cls_string);
+ return (char *) jbytes;
+}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
- const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
- const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
- const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
- jlong _ctrl = (jlong) 0;
- jstring res = correct_string_utf8(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
+ const char *_dbPath = encode_to_utf8_chars(env, dbPath);
+ const char *_dbKey = encode_to_utf8_chars(env, dbKey);
+ const char *_confirm = encode_to_utf8_chars(env, confirm);
+ long int *_ctrl = (long) 0;
+ jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
@@ -78,43 +87,43 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz, jlong controller, jstring msg) {
- const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
- jstring res = correct_string_utf8(env, chat_send_cmd((void*)controller, _msg));
+ const char *_msg = encode_to_utf8_chars(env, msg);
+ jstring res = decode_to_utf8_string(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) {
- return correct_string_utf8(env, chat_recv_msg((void*)controller));
+ return decode_to_utf8_string(env, chat_recv_msg((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) {
- return correct_string_utf8(env, chat_recv_msg_wait((void*)controller, wait));
+ return decode_to_utf8_string(env, chat_recv_msg_wait((void*)controller, wait));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, jclass clazz, jstring str) {
- const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
- jstring res = correct_string_utf8(env, chat_parse_markdown(_str));
+ const char *_str = encode_to_utf8_chars(env, str);
+ jstring res = decode_to_utf8_string(env, chat_parse_markdown(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass clazz, jstring str) {
- const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
- jstring res = correct_string_utf8(env, chat_parse_server(_str));
+ const char *_str = encode_to_utf8_chars(env, str);
+ jstring res = decode_to_utf8_string(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) {
- const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
- const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
- jstring res = correct_string_utf8(env, chat_password_hash(_pwd, _salt));
+ const char *_pwd = encode_to_utf8_chars(env, pwd);
+ const char *_salt = encode_to_utf8_chars(env, salt);
+ jstring res = decode_to_utf8_string(env, chat_password_hash(_pwd, _salt));
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index 220d717585..3e83ef76fe 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -1625,18 +1625,12 @@ data class CIMeta (
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
- fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair? =
- when (itemStatus) {
- is CIStatus.SndSent -> MR.images.ic_check_filled to metaColor
- is CIStatus.SndRcvd -> when(itemStatus.msgRcptStatus) {
- MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor
- MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
- }
- is CIStatus.SndErrorAuth -> MR.images.ic_close to Color.Red
- is CIStatus.SndError -> MR.images.ic_warning_filled to WarningYellow
- is CIStatus.RcvNew -> MR.images.ic_circle_filled to primaryColor
- else -> null
- }
+ fun statusIcon(
+ primaryColor: Color,
+ metaColor: Color = CurrentColors.value.colors.secondary,
+ paleMetaColor: Color = CurrentColors.value.colors.secondary
+ ): Pair? =
+ itemStatus.statusIcon(primaryColor, metaColor, paleMetaColor)
companion object {
fun getSample(
@@ -1715,12 +1709,60 @@ fun localTimestamp(t: Instant): String {
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
- @Serializable @SerialName("sndSent") class SndSent: CIStatus()
- @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus): CIStatus()
+ @Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus()
+ @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus()
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
+ @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()
+
+ fun statusIcon(
+ primaryColor: Color,
+ metaColor: Color = CurrentColors.value.colors.secondary,
+ paleMetaColor: Color = CurrentColors.value.colors.secondary
+ ): Pair? =
+ when (this) {
+ is SndNew -> null
+ is SndSent -> when (this.sndProgress) {
+ SndCIStatusProgress.Complete -> MR.images.ic_check_filled to metaColor
+ SndCIStatusProgress.Partial -> MR.images.ic_check_filled to paleMetaColor
+ }
+ is SndRcvd -> when(this.msgRcptStatus) {
+ MsgReceiptStatus.Ok -> when (this.sndProgress) {
+ SndCIStatusProgress.Complete -> MR.images.ic_double_check to metaColor
+ SndCIStatusProgress.Partial -> MR.images.ic_double_check to paleMetaColor
+ }
+ MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
+ }
+ is SndErrorAuth -> MR.images.ic_close to Color.Red
+ is SndError -> MR.images.ic_warning_filled to WarningYellow
+ is RcvNew -> MR.images.ic_circle_filled to primaryColor
+ is RcvRead -> null
+ is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
+ }
+
+ val statusText: String get() = when (this) {
+ is SndNew -> generalGetString(MR.strings.item_status_snd_new_text)
+ is SndSent -> generalGetString(MR.strings.item_status_snd_sent_text)
+ is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_text)
+ is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_text)
+ is SndError -> generalGetString(MR.strings.item_status_snd_error_text)
+ is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_text)
+ is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_text)
+ is Invalid -> "Invalid status"
+ }
+
+ val statusDescription: String get() = when (this) {
+ is SndNew -> generalGetString(MR.strings.item_status_snd_new_desc)
+ is SndSent -> generalGetString(MR.strings.item_status_snd_sent_desc)
+ is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_desc)
+ is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_auth_desc)
+ is SndError -> String.format(generalGetString(MR.strings.item_status_snd_error_unexpected_desc), this.agentError)
+ is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_desc)
+ is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_desc)
+ is Invalid -> this.text
+ }
}
@Serializable
@@ -1729,6 +1771,12 @@ enum class MsgReceiptStatus {
@SerialName("badMsgHash") BadMsgHash;
}
+@Serializable
+enum class SndCIStatusProgress {
+ @SerialName("partial") Partial,
+ @SerialName("complete") Complete;
+}
+
@Serializable
sealed class CIDeleted {
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
@@ -1958,6 +2006,7 @@ class CIFile(
is CIFileStatus.RcvCancelled -> false
is CIFileStatus.RcvComplete -> true
is CIFileStatus.RcvError -> false
+ is CIFileStatus.Invalid -> false
}
@Transient
@@ -1978,6 +2027,7 @@ class CIFile(
is CIFileStatus.RcvCancelled -> null
is CIFileStatus.RcvComplete -> null
is CIFileStatus.RcvError -> null
+ is CIFileStatus.Invalid -> null
}
companion object {
@@ -2047,6 +2097,7 @@ sealed class CIFileStatus {
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
+ @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus()
}
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@@ -2489,6 +2540,7 @@ sealed class ChatItemTTL: Comparable {
@Serializable
class ChatItemInfo(
val itemVersions: List,
+ val memberDeliveryStatuses: List?
)
@Serializable
@@ -2500,6 +2552,12 @@ data class ChatItemVersion(
val createdAt: Instant,
)
+@Serializable
+data class MemberDeliveryStatus(
+ val groupMemberId: Long,
+ val memberDeliveryStatus: CIStatus
+)
+
enum class NotificationPreviewMode {
MESSAGE, CONTACT, HIDDEN;
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index 0e2f53a581..ad2dfe47f4 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -471,13 +471,19 @@ object ChatController {
suspend fun apiSetAllContactReceipts(enable: Boolean) {
val r = sendCmd(CC.SetAllContactReceipts(enable))
if (r is CR.CmdOk) return
- throw Exception("failed to enable receipts for all users ${r.responseType} ${r.details}")
+ throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}")
}
suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) {
val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings))
if (r is CR.CmdOk) return
- throw Exception("failed to enable receipts for user contacts ${r.responseType} ${r.details}")
+ throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) {
+ val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings))
+ if (r is CR.CmdOk) return
+ throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}")
}
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
@@ -1785,6 +1791,7 @@ sealed class CC {
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
class SetAllContactReceipts(val enable: Boolean): CC()
class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
+ class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
class ApiUnhideUser(val userId: Long, val viewPwd: String): CC()
class ApiMuteUser(val userId: Long): CC()
@@ -1884,6 +1891,10 @@ sealed class CC {
val mrs = userMsgReceiptSettings
"/_set receipts contacts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
}
+ is ApiSetUserGroupReceipts -> {
+ val mrs = userMsgReceiptSettings
+ "/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
+ }
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"
is ApiMuteUser -> "/_mute user $userId"
@@ -1981,6 +1992,7 @@ sealed class CC {
is ApiSetActiveUser -> "apiSetActiveUser"
is SetAllContactReceipts -> "setAllContactReceipts"
is ApiSetUserContactReceipts -> "apiSetUserContactReceipts"
+ is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts"
is ApiHideUser -> "apiHideUser"
is ApiUnhideUser -> "apiUnhideUser"
is ApiMuteUser -> "apiMuteUser"
@@ -2343,9 +2355,9 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
- tcpConnectTimeout = 10_000_000,
- tcpTimeout = 7_000_000,
- tcpTimeoutPerKb = 10_000,
+ tcpConnectTimeout = 15_000_000,
+ tcpTimeout = 10_000_000,
+ tcpTimeoutPerKb = 20_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
@@ -2357,9 +2369,9 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
- tcpConnectTimeout = 20_000_000,
- tcpTimeout = 15_000_000,
- tcpTimeoutPerKb = 20_000,
+ tcpConnectTimeout = 30_000_000,
+ tcpTimeout = 20_000_000,
+ tcpTimeoutPerKb = 40_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt
index 1a722950ce..0e4a67f221 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt
@@ -2,6 +2,12 @@ package chat.simplex.common.platform
import chat.simplex.common.model.*
import chat.simplex.common.views.call.RcvCallInvitation
+import chat.simplex.common.views.chatlist.acceptContactRequest
+import chat.simplex.common.views.chatlist.openChat
+import chat.simplex.common.views.helpers.*
+import chat.simplex.common.views.onboarding.OnboardingStage
+import chat.simplex.res.MR
+import kotlinx.coroutines.delay
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
@@ -10,14 +16,104 @@ enum class NotificationAction {
lateinit var ntfManager: NtfManager
abstract class NtfManager {
- abstract fun notifyContactConnected(user: User, contact: Contact)
- abstract fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest)
- abstract fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem)
+ fun notifyContactConnected(user: User, contact: Contact) = displayNotification(
+ user = user,
+ chatId = contact.id,
+ displayName = contact.displayName,
+ msgText = generalGetString(MR.strings.notification_contact_connected)
+ )
+
+ fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = displayNotification(
+ user = user,
+ chatId = cInfo.id,
+ displayName = cInfo.displayName,
+ msgText = generalGetString(MR.strings.notification_new_contact_request),
+ image = cInfo.image,
+ listOf(NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, cInfo.id) })
+ )
+
+ fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
+ if (!cInfo.ntfsEnabled) return
+ displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
+ }
+
+ fun acceptContactRequestAction(userId: Long?, chatId: ChatId) {
+ val isCurrentUser = ChatModel.currentUser.value?.userId == userId
+ val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
+ (ChatModel.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
+ } else {
+ null
+ }
+ val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
+ acceptContactRequest(apiId, cInfo, isCurrentUser, ChatModel)
+ cancelNotificationsForChat(chatId)
+ }
+
+ fun openChatAction(userId: Long?, chatId: ChatId) {
+ withBGApi {
+ awaitChatStartedIfNeeded(chatModel)
+ if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
+ chatModel.controller.changeActiveUser(userId, null)
+ }
+ val cInfo = chatModel.getChat(chatId)?.chatInfo
+ chatModel.clearOverlays.value = true
+ if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
+ }
+ }
+
+ fun showChatsAction(userId: Long?) {
+ withBGApi {
+ awaitChatStartedIfNeeded(chatModel)
+ if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
+ chatModel.controller.changeActiveUser(userId, null)
+ }
+ chatModel.chatId.value = null
+ chatModel.clearOverlays.value = true
+ }
+ }
+
+ fun acceptCallAction(chatId: ChatId) {
+ chatModel.clearOverlays.value = true
+ val invitation = chatModel.callInvitations[chatId]
+ if (invitation == null) {
+ AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
+ } else {
+ chatModel.callManager.acceptIncomingCall(invitation = invitation)
+ }
+ }
+
abstract fun notifyCallInvitation(invitation: RcvCallInvitation)
abstract fun hasNotificationsForChat(chatId: String): Boolean
abstract fun cancelNotificationsForChat(chatId: String)
- abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList())
- abstract fun createNtfChannelsMaybeShowAlert()
+ abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List Unit>> = emptyList())
abstract fun cancelCallNotification()
abstract fun cancelAllNotifications()
+ // Android only
+ abstract fun androidCreateNtfChannelsMaybeShowAlert()
+
+ private suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
+ // Still decrypting database
+ if (chatModel.chatRunning.value == null) {
+ val step = 50L
+ for (i in 0..(timeout / step)) {
+ if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
+ break
+ }
+ delay(step)
+ }
+ }
+ }
+
+ private fun hideSecrets(cItem: ChatItem): String {
+ val md = cItem.formattedText
+ return if (md != null) {
+ var res = ""
+ for (ft in md) {
+ res += if (ft.format is Format.Secret) "..." else ft.text
+ }
+ res
+ } else {
+ cItem.text
+ }
+ }
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt
index 0ab8c4633a..f69116be82 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt
@@ -3,6 +3,7 @@ package chat.simplex.common.views.chat
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
+import SectionItemView
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -19,28 +20,30 @@ import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
-import chat.simplex.common.ui.theme.CurrentColors
-import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.shareText
+import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
-enum class CIInfoTab {
- History, Quote
+sealed class CIInfoTab {
+ class Delivery(val memberDeliveryStatuses: List): CIInfoTab()
+ object History: CIInfoTab()
+ class Quote(val quotedItem: CIQuote): CIInfoTab()
}
@Composable
-fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
+fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
val sent = ci.chatDir.sent
val appColors = CurrentColors.collectAsState().value.appColors
val uriHandler = LocalUriHandler.current
- val selection = remember { mutableStateOf(CIInfoTab.History) }
+ val selection = remember { mutableStateOf(CIInfoTab.History) }
@Composable
fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) {
@@ -160,10 +163,12 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
if (itemDeleted.deletedTs != null) {
InfoRow(stringResource(MR.strings.info_row_deleted_at), localTimestamp(itemDeleted.deletedTs))
}
+
is CIDeleted.Moderated ->
if (itemDeleted.deletedTs != null) {
InfoRow(stringResource(MR.strings.info_row_moderated_at), localTimestamp(itemDeleted.deletedTs))
}
+
else -> {}
}
val deleteAt = ci.meta.itemTimed?.deleteAt
@@ -214,55 +219,154 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
}
}
+ @Composable
+ fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) {
+ SectionItemView(
+ padding = PaddingValues(horizontal = 0.dp)
+ ) {
+ ProfileImage(size = 36.dp, member.image)
+ Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
+ Text(
+ member.chatViewName,
+ modifier = Modifier.weight(10f, fill = true),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+ val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
+ Box(
+ Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .clickable {
+ AlertManager.shared.showAlertMsg(
+ title = status.statusText,
+ text = status.statusDescription
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ if (statusIcon != null) {
+ val (icon, statusColor) = statusIcon
+ Icon(
+ painterResource(icon),
+ contentDescription = null,
+ tint = statusColor
+ )
+ } else {
+ Icon(
+ painterResource(MR.images.ic_more_horiz),
+ contentDescription = null,
+ tint = CurrentColors.value.colors.secondary
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun DeliveryTab(memberDeliveryStatuses: List) {
+ Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
+ Details()
+ SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
+ val mss = membersStatuses(chatModel, memberDeliveryStatuses)
+ if (mss.isNotEmpty()) {
+ SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
+ Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
+ mss.forEach { (member, status) ->
+ MemberDeliveryStatusView(member, status)
+ }
+ }
+ } else {
+ SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(MR.strings.no_info_on_delivery), color = MaterialTheme.colors.secondary)
+ }
+ }
+ }
+ SectionBottomSpacer()
+ }
+ }
+
@Composable
fun tabTitle(tab: CIInfoTab): String {
return when (tab) {
- CIInfoTab.History -> stringResource(MR.strings.edit_history)
- CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to)
+ is CIInfoTab.Delivery -> stringResource(MR.strings.delivery)
+ is CIInfoTab.History -> stringResource(MR.strings.edit_history)
+ is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to)
}
}
fun tabIcon(tab: CIInfoTab): ImageResource {
return when (tab) {
- CIInfoTab.History -> MR.images.ic_history
- CIInfoTab.Quote -> MR.images.ic_reply
+ is CIInfoTab.Delivery -> MR.images.ic_double_check
+ is CIInfoTab.History -> MR.images.ic_history
+ is CIInfoTab.Quote -> MR.images.ic_reply
}
}
- Column {
+ fun numTabs(): Int {
+ var numTabs = 1
+ if (ciInfo.memberDeliveryStatuses != null) {
+ numTabs += 1
+ }
if (ci.quotedItem != null) {
+ numTabs += 1
+ }
+ return numTabs
+ }
+
+ Column {
+ if (numTabs() > 1) {
Column(
Modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
+ LaunchedEffect(Unit) {
+ if (ciInfo.memberDeliveryStatuses != null) {
+ selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
+ }
+ }
Column(Modifier.weight(1f)) {
- when (selection.value) {
- CIInfoTab.History -> {
+ when (val sel = selection.value) {
+ is CIInfoTab.Delivery -> {
+ DeliveryTab(sel.memberDeliveryStatuses)
+ }
+
+ is CIInfoTab.History -> {
HistoryTab()
}
- CIInfoTab.Quote -> {
- QuoteTab(ci.quotedItem)
+ is CIInfoTab.Quote -> {
+ QuoteTab(sel.quotedItem)
}
}
}
+ val availableTabs = mutableListOf()
+ if (ciInfo.memberDeliveryStatuses != null) {
+ availableTabs.add(CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses))
+ }
+ availableTabs.add(CIInfoTab.History)
+ if (ci.quotedItem != null) {
+ availableTabs.add(CIInfoTab.Quote(ci.quotedItem))
+ }
TabRow(
- selectedTabIndex = selection.value.ordinal,
+ selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
- CIInfoTab.values().forEachIndexed { index, it ->
+ availableTabs.forEach { ciInfoTab ->
Tab(
- selected = selection.value.ordinal == index,
+ selected = selection.value::class == ciInfoTab::class,
onClick = {
- selection.value = CIInfoTab.values()[index]
+ selection.value = ciInfoTab
},
- text = { Text(tabTitle(it), fontSize = 13.sp) },
+ text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
icon = {
Icon(
- painterResource(tabIcon(it)),
- tabTitle(it)
+ painterResource(tabIcon(ciInfoTab)),
+ tabTitle(ciInfoTab)
)
},
selectedContentColor = MaterialTheme.colors.primary,
@@ -277,10 +381,18 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
}
}
-fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String {
+private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> {
+ return memberDeliveryStatuses.mapNotNull { mds ->
+ chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem ->
+ mem to mds.memberDeliveryStatus
+ }
+ }
+}
+
+fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String {
val meta = ci.meta
val sent = ci.chatDir.sent
- val shareText = mutableListOf(generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "")
+ val shareText = mutableListOf("# " + generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "")
shareText.add(String.format(generalGetString(MR.strings.share_text_sent_at), localTimestamp(meta.itemTs)))
if (!ci.chatDir.sent) {
@@ -291,10 +403,12 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea
if (itemDeleted.deletedTs != null) {
shareText.add(String.format(generalGetString(MR.strings.share_text_deleted_at), localTimestamp(itemDeleted.deletedTs)))
}
+
is CIDeleted.Moderated ->
if (itemDeleted.deletedTs != null) {
shareText.add(String.format(generalGetString(MR.strings.share_text_moderated_at), localTimestamp(itemDeleted.deletedTs)))
}
+
else -> {}
}
val deleteAt = ci.meta.itemTimed?.deleteAt
@@ -308,7 +422,7 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea
val qi = ci.quotedItem
if (qi != null) {
shareText.add("")
- shareText.add(generalGetString(MR.strings.in_reply_to))
+ shareText.add("## " + generalGetString(MR.strings.in_reply_to))
shareText.add("")
val ts = localTimestamp(qi.sentAt)
val sender = qi.sender(null)
@@ -320,10 +434,26 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea
val t = qi.text
shareText.add(if (t != "") t else generalGetString(MR.strings.item_info_no_text))
}
+ val mdss = chatItemInfo.memberDeliveryStatuses
+ if (mdss != null) {
+ val mss = membersStatuses(chatModel, mdss)
+ if (mss.isNotEmpty()) {
+ shareText.add("")
+ shareText.add("## " + generalGetString(MR.strings.delivery))
+ shareText.add("")
+ mss.forEach { (member, status) ->
+ shareText.add(String.format(
+ generalGetString(MR.strings.recipient_colon_delivery_status),
+ member.chatViewName,
+ status.statusDescription
+ ))
+ }
+ }
+ }
val versions = chatItemInfo.itemVersions
if (versions.isNotEmpty()) {
shareText.add("")
- shareText.add(generalGetString(MR.strings.edit_history))
+ shareText.add("## " + generalGetString(MR.strings.edit_history))
versions.forEachIndexed { index, itemVersion ->
val ts = localTimestamp(itemVersion.itemVersionTs)
shareText.add("")
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 209d1a80a9..0f7dd1a081 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -321,11 +321,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
+ if (chat.chatInfo is ChatInfo.Group) {
+ setGroupMembers(chat.chatInfo.groupInfo, chatModel)
+ }
ModalManager.end.closeModals()
ModalManager.end.showModal(endButtons = { ShareButton {
- clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
+ clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
} }) {
- ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
+ ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
index 4b37badfeb..402a9f28ca 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
@@ -26,26 +26,37 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
-import chat.simplex.common.views.chatlist.cantInviteIncognitoAlert
-import chat.simplex.common.views.chatlist.setGroupMembers
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
-import chat.simplex.common.views.chat.ClearChatButton
-import chat.simplex.common.views.chat.clearChatDialog
+import chat.simplex.common.views.chat.*
+import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
+const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
+
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
+ val currentUser = chatModel.currentUser.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
- if (chat != null && chat.chatInfo is ChatInfo.Group) {
+ if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
val groupInfo = chat.chatInfo.groupInfo
+ val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) }
GroupChatInfoLayout(
chat,
groupInfo,
+ currentUser,
+ sendReceipts = sendReceipts,
+ setSendReceipts = { sendRcpts ->
+ withApi {
+ val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(sendRcpts = sendRcpts.bool)
+ updateChatSettings(chat, chatSettings, chatModel)
+ sendReceipts.value = sendRcpts
+ }
+ },
members = chatModel.groupMembers
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
@@ -150,6 +161,9 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
fun GroupChatInfoLayout(
chat: Chat,
groupInfo: GroupInfo,
+ currentUser: User,
+ sendReceipts: State,
+ setSendReceipts: (SendReceipts) -> Unit,
members: List,
developerTools: Boolean,
groupLink: String?,
@@ -184,6 +198,11 @@ fun GroupChatInfoLayout(
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
+ if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
+ SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
+ } else {
+ SendReceiptsOptionDisabled()
+ }
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
@@ -269,6 +288,37 @@ private fun GroupPreferencesButton(onClick: () -> Unit) {
)
}
+@Composable
+private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) {
+ val values = remember {
+ mutableListOf(SendReceipts.Yes, SendReceipts.No, SendReceipts.UserDefault(currentUser.sendRcptsSmallGroups)).map { it to it.text }
+ }
+ ExposedDropDownSettingRow(
+ generalGetString(MR.strings.send_receipts),
+ values,
+ state,
+ icon = painterResource(MR.images.ic_double_check),
+ enabled = remember { mutableStateOf(true) },
+ onSelected = onSelected
+ )
+}
+
+@Composable
+fun SendReceiptsOptionDisabled() {
+ SettingsActionItemWithContent(
+ icon = painterResource(MR.images.ic_double_check),
+ text = generalGetString(MR.strings.send_receipts),
+ click = {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(MR.strings.send_receipts_disabled_alert_title),
+ text = String.format(generalGetString(MR.strings.send_receipts_disabled_alert_msg), SMALL_GROUPS_RCPS_MEM_LIMIT)
+ )
+ }
+ ) {
+ Text(generalGetString(MR.strings.send_receipts_disabled), color = MaterialTheme.colors.secondary)
+ }
+}
+
@Composable
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
SettingsActionItem(
@@ -429,6 +479,9 @@ fun PreviewGroupChatInfoLayout() {
chatItems = arrayListOf()
),
groupInfo = GroupInfo.sampleData,
+ User.sampleData,
+ sendReceipts = remember { mutableStateOf(SendReceipts.Yes) },
+ setSendReceipts = {},
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
index 6207c1648e..54e6ffbd6a 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
@@ -169,6 +169,7 @@ fun CIFileView(
is CIFileStatus.RcvComplete -> fileIcon()
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
+ is CIFileStatus.Invalid -> fileIcon(innerIcon = painterResource(MR.images.ic_question_mark))
}
} else {
fileIcon()
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
index 605a95d6d8..73fc3f41ac 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
@@ -76,6 +76,7 @@ fun CIImageView(
is CIFileStatus.RcvTransfer -> progressIndicator()
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
+ is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
else -> {}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
index 86cd0acbfe..ab121c6272 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
@@ -14,11 +14,27 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.model.*
+import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@Composable
-fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) {
+fun CIMetaView(
+ chatItem: ChatItem,
+ timedMessagesTTL: Int?,
+ metaColor: Color = MaterialTheme.colors.secondary,
+ paleMetaColor: Color = if (isInDarkTheme()) {
+ metaColor.copy(
+ red = metaColor.red * 0.67F,
+ green = metaColor.green * 0.67F,
+ blue = metaColor.red * 0.67F)
+ } else {
+ metaColor.copy(
+ red = minOf(metaColor.red * 1.33F, 1F),
+ green = minOf(metaColor.green * 1.33F, 1F),
+ blue = minOf(metaColor.red * 1.33F, 1F))
+ }
+) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
Text(
@@ -28,14 +44,14 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma
modifier = Modifier.padding(start = 3.dp)
)
} else {
- CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
+ CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor)
}
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
-private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
+private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) {
if (meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
Spacer(Modifier.width(3.dp))
@@ -48,7 +64,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
}
Spacer(Modifier.width(4.dp))
}
- val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
+ val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor)
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) {
@@ -138,7 +154,7 @@ fun PreviewCIMetaViewSendNoAuth() {
fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
- 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete)
),
null
)
@@ -176,7 +192,7 @@ fun PreviewCIMetaViewEditedSent() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
- status= CIStatus.SndSent()
+ status= CIStatus.SndSent(SndCIStatusProgress.Complete)
),
null
)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt
index 95bd6557a6..5d2d581b1d 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt
@@ -287,6 +287,7 @@ private fun loadingIndicator(file: CIFile?) {
}
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
+ is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
else -> {}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt
index 863266b387..ce09ee661c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt
@@ -154,19 +154,19 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
SectionItemView {
TimeoutSettingRow(
stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout,
- listOf(2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000), secondsLabel
+ listOf(5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel
)
}
SectionItemView {
TimeoutSettingRow(
stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout,
- listOf(1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000), secondsLabel
+ listOf(3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel
)
}
SectionItemView {
TimeoutSettingRow(
stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb,
- listOf(5_000, 10_000, 20_000, 40_000), secondsLabel
+ listOf(10_000, 20_000, 40_000, 75_000, 100_000), secondsLabel
)
}
SectionItemView {
@@ -341,7 +341,9 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List
DefaultExposedDropdownMenu(
expanded = expanded
) {
- values.forEach { selectionOption ->
+ val v = selection.value
+ val vs = if (values.contains(v)) values else values + v
+ vs.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
selection.value = selectionOption
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
index 9c7c4e25c5..a46decb3d0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
@@ -101,6 +101,29 @@ fun PrivacySettingsView(
}
}
+ fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) {
+ withApi {
+ val mrs = UserMsgReceiptSettings(enable, clearOverrides)
+ chatModel.controller.apiSetUserGroupReceipts(currentUser.userId, mrs)
+ chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
+ chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable)
+ if (clearOverrides) {
+ // For loop here is to prevent ConcurrentModificationException that happens with forEach
+ for (i in 0 until chatModel.chats.size) {
+ val chat = chatModel.chats[i]
+ if (chat.chatInfo is ChatInfo.Group) {
+ var groupInfo = chat.chatInfo.groupInfo
+ val sendRcpts = groupInfo.chatSettings.sendRcpts
+ if (sendRcpts != null && sendRcpts != enable) {
+ groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null))
+ chatModel.updateGroup(groupInfo)
+ }
+ }
+ }
+ }
+ }
+ }
+
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
@@ -117,6 +140,21 @@ fun PrivacySettingsView(
} else {
showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts)
}
+ },
+ setOrAskSendReceiptsGroups = { enable ->
+ val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
+ if (chat.chatInfo is ChatInfo.Group) {
+ val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
+ count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
+ } else {
+ count
+ }
+ }
+ if (groupReceiptsOverrides == 0) {
+ setSendReceiptsGroups(enable, clearOverrides = false)
+ } else {
+ showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups)
+ }
}
)
}
@@ -155,6 +193,7 @@ expect fun PrivacyDeviceSection(
private fun DeliveryReceiptsSection(
currentUser: User,
setOrAskSendReceiptsContacts: (Boolean) -> Unit,
+ setOrAskSendReceiptsGroups: (Boolean) -> Unit,
) {
SectionView(stringResource(MR.strings.settings_section_title_delivery_receipts)) {
SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.receipts_section_contacts)) {
@@ -165,6 +204,14 @@ private fun DeliveryReceiptsSection(
}
)
}
+ SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.receipts_section_groups)) {
+ DefaultSwitch(
+ checked = currentUser.sendRcptsSmallGroups ?: false,
+ onCheckedChange = { enable ->
+ setOrAskSendReceiptsGroups(enable)
+ }
+ )
+ }
}
SectionTextFooter(
remember(currentUser.displayName) {
@@ -215,6 +262,41 @@ private fun showUserContactsReceiptsAlert(
)
}
+private fun showUserGroupsReceiptsAlert(
+ enable: Boolean,
+ groupReceiptsOverrides: Int,
+ setSendReceiptsGroups: (Boolean, Boolean) -> Unit
+) {
+ AlertManager.shared.showAlertDialogButtonsColumn(
+ title = generalGetString(if (enable) MR.strings.receipts_groups_title_enable else MR.strings.receipts_groups_title_disable),
+ text = AnnotatedString(String.format(generalGetString(if (enable) MR.strings.receipts_groups_override_disabled else MR.strings.receipts_groups_override_enabled), groupReceiptsOverrides)),
+ buttons = {
+ Column {
+ SectionItemView({
+ AlertManager.shared.hideAlert()
+ setSendReceiptsGroups(enable, false)
+ }) {
+ val t = stringResource(if (enable) MR.strings.receipts_groups_enable_keep_overrides else MR.strings.receipts_groups_disable_keep_overrides)
+ Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
+ }
+ SectionItemView({
+ AlertManager.shared.hideAlert()
+ setSendReceiptsGroups(enable, true)
+ }
+ ) {
+ val t = stringResource(if (enable) MR.strings.receipts_groups_enable_for_all else MR.strings.receipts_groups_disable_for_all)
+ Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
+ }
+ SectionItemView({
+ AlertManager.shared.hideAlert()
+ }) {
+ Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground)
+ }
+ }
+ }
+ )
+}
+
private val laDelays = listOf(10, 30, 60, 180, 0)
@Composable
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 9ed0c6fbd4..a54a1a9320 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -222,6 +222,8 @@
History
No history
In reply to
+ Delivery
+ No info on delivery
Delete
Reveal
Hide
@@ -869,7 +871,7 @@
If you enter this passcode when opening the app, all app data will be irreversibly removed!
Set passcode
These settings are for your current profile
- They can be overridden in contact settings
+ They can be overridden in contact and group settings.
Contacts
Enable receipts?
Disable receipts?
@@ -879,6 +881,15 @@
Disable (keep overrides)
Enable for all
Disable for all
+ Small groups (max 20)
+ Enable receipts for groups?
+ Disable receipts for groups?
+ Sending receipts is enabled for %d groups
+ Sending receipts is disabled for %d groups
+ Enable (keep group overrides)
+ Disable (keep group overrides)
+ Enable for all groups
+ Disable for all groups
YOU
@@ -1161,6 +1172,9 @@
Share address
You can share this address with your contacts to let them connect with %s.
Send receipts
+ disabled
+ Receipts are disabled
+ This group has over %1$d members, delivery receipts are not sent.
FOR CONSOLE
@@ -1183,6 +1197,7 @@
%s at %s
%s (current)
no text
+ %s: %s
Remove member
@@ -1527,6 +1542,22 @@
You can enable them later via app Privacy & Security settings.
Error enabling delivery receipts!
+
+ Sending message
+ Message sent
+ Sent message received
+ Error sending message
+ Message received
+ Message read
+
+ Sending message is in progress or pending.
+ Message has been sent to the recipient\'s relay.
+ Message has been received by the recipient.
+ Message delivery error. Most likely this recipient has deleted the connection with you.
+ Unexpected message delivery error: %1$s
+ New message from this sender.
+ You\'ve read this received message.
+
Coming soon!
This feature is not yet supported. Try the next release.
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg
new file mode 100644
index 0000000000..9c2e7e110b
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
index 81e506a258..587786518f 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt
@@ -87,7 +87,7 @@ fun showApp() = application {
}
}
}
- var windowFocused by remember { mutableStateOf(true) }
+ var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
@@ -119,9 +119,11 @@ class SimplexWindowState {
val openMultipleDialog = DialogState>()
val saveDialog = DialogState()
val toasts = mutableStateListOf>()
+ var windowFocused = mutableStateOf(true)
}
data class DialogParams(
+ val filename: String? = null,
val allowMultiple: Boolean = false,
val fileFilter: ((File?) -> Boolean)? = null,
val fileFilterDescription: String = "",
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt
new file mode 100644
index 0000000000..1660a4974a
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt
@@ -0,0 +1,153 @@
+package chat.simplex.common.model
+
+import androidx.compose.ui.graphics.*
+import chat.simplex.common.platform.*
+import chat.simplex.common.simplexWindowState
+import chat.simplex.common.views.call.CallMediaType
+import chat.simplex.common.views.call.RcvCallInvitation
+import chat.simplex.common.views.helpers.*
+import chat.simplex.res.MR
+import com.sshtools.twoslices.*
+import java.awt.*
+import java.awt.TrayIcon.MessageType
+import java.io.File
+import javax.imageio.ImageIO
+
+object NtfManager {
+ private val prevNtfs = arrayListOf>()
+
+ fun notifyCallInvitation(invitation: RcvCallInvitation) {
+ if (simplexWindowState.windowFocused.value) return
+ val contactId = invitation.contact.id
+ Log.d(TAG, "notifyCallInvitation $contactId")
+ val image = invitation.contact.image
+ val text = generalGetString(
+ if (invitation.callType.media == CallMediaType.Video) {
+ if (invitation.sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
+ } else {
+ if (invitation.sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
+ }
+ )
+ val previewMode = appPreferences.notificationPreviewMode.get()
+ val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
+ generalGetString(MR.strings.notification_preview_somebody)
+ else
+ invitation.contact.displayName
+ val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
+ MR.images.icon_foreground_common.image.toComposeImageBitmap()
+ else
+ base64ToBitmap(image)
+
+ val actions = listOf(
+ generalGetString(MR.strings.accept) to { ntfManager.acceptCallAction(invitation.contact.id) },
+ generalGetString(MR.strings.reject) to { ChatModel.callManager.endCall(invitation = invitation) }
+ )
+ displayNotificationViaLib(contactId, title, text, prepareIconPath(largeIcon), actions) {
+ ntfManager.openChatAction(invitation.user.userId, contactId)
+ }
+ }
+
+ fun hasNotificationsForChat(chatId: ChatId) = false//prevNtfs.any { it.first == chatId }
+
+ fun cancelNotificationsForChat(chatId: ChatId) {
+ val ntf = prevNtfs.firstOrNull { it.first == chatId }
+ if (ntf != null) {
+ prevNtfs.remove(ntf)
+ /*try {
+ ntf.second.close()
+ } catch (e: Exception) {
+ // Can be java.lang.UnsupportedOperationException, for example. May do nothing
+ println("Failed to close notification: ${e.stackTraceToString()}")
+ }*/
+ }
+ }
+
+ fun cancelAllNotifications() {
+// prevNtfs.forEach { try { it.second.close() } catch (e: Exception) { println("Failed to close notification: ${e.stackTraceToString()}") } }
+ prevNtfs.clear()
+ }
+
+ fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List Unit>>) {
+ if (!user.showNotifications) return
+ Log.d(TAG, "notifyMessageReceived $chatId")
+ val previewMode = appPreferences.notificationPreviewMode.get()
+ val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(MR.strings.notification_preview_somebody) else displayName
+ val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(MR.strings.notification_preview_new_message) else msgText
+ val largeIcon = when {
+ actions.isEmpty() -> null
+ image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> MR.images.icon_foreground_common.image.toComposeImageBitmap()
+ else -> base64ToBitmap(image)
+ }
+
+ displayNotificationViaLib(chatId, title, content, prepareIconPath(largeIcon), actions.map { it.first.name to it.second }) {
+ ntfManager.openChatAction(user.userId, chatId)
+ }
+ }
+
+ private fun displayNotificationViaLib(
+ chatId: String,
+ title: String,
+ text: String,
+ iconPath: String?,
+ actions: List Unit>>,
+ defaultAction: (() -> Unit)?
+ ) {
+ val builder = Toast.builder()
+ .title(title)
+ .content(text)
+ if (iconPath != null) {
+ builder.icon(iconPath)
+ }
+ if (defaultAction != null) {
+ builder.defaultAction(defaultAction)
+ }
+ actions.forEach {
+ builder.action(it.first, it.second)
+ }
+ prevNtfs.add(chatId to builder.toast())
+ }
+
+ private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) {
+ tmpDir.mkdir()
+ val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png"))
+ try {
+ ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream())
+ newFile.absolutePath
+ } catch (e: Exception) {
+ println("Failed to write an icon to tmpDir: ${e.stackTraceToString()}")
+ null
+ }
+ } else null
+
+ private fun displayNotification(title: String, text: String, icon: ImageBitmap?) = when (desktopPlatform) {
+ DesktopPlatform.LINUX_X86_64, DesktopPlatform.LINUX_AARCH64 -> linuxDisplayNotification(title, text, prepareIconPath(icon))
+ DesktopPlatform.WINDOWS_X86_64 -> windowsDisplayNotification(title, text, icon)
+ DesktopPlatform.MAC_X86_64, DesktopPlatform.MAC_AARCH64 -> macDisplayNotification(title, text, prepareIconPath(icon))
+ }
+
+ private fun linuxDisplayNotification(title: String, text: String, iconPath: String?) {
+ if (iconPath != null) {
+ Runtime.getRuntime().exec(arrayOf("notify-send", "-i", iconPath, title, text))
+ } else {
+ Toast.toast(ToastType.INFO, title, text)
+ Runtime.getRuntime().exec(arrayOf("notify-send", title, text))
+ }
+ }
+
+ private fun windowsDisplayNotification(title: String, text: String, icon: ImageBitmap?) {
+ if (SystemTray.isSupported()) {
+ val tray = SystemTray.getSystemTray()
+ tray.remove(tray.trayIcons.firstOrNull { it.toolTip == "SimpleX" })
+ val trayIcon = TrayIcon(icon?.toAwtImage(), "SimpleX")
+ trayIcon.isImageAutoSize = true
+ tray.add(trayIcon)
+ trayIcon.displayMessage(title, text, MessageType.INFO)
+ } else {
+ Log.e(TAG, "System tray not supported!")
+ }
+ }
+
+ private fun macDisplayNotification(title: String, text: String, iconPath: String?) {
+ Runtime.getRuntime().exec(arrayOf("osascript", "-e", """display notification "${text.replace("\"", "\\\"")}" with title "${title.replace("\"", "\\\"")}""""))
+ }
+}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
index 54f73c3f52..18ae4a85a9 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
@@ -11,17 +11,14 @@ actual val appPlatform = AppPlatform.DESKTOP
val defaultLocale: Locale = Locale.getDefault()
fun initApp() {
- ntfManager = object : NtfManager() { // LALAL
- override fun notifyContactConnected(user: User, contact: Contact) {}
- override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {}
- override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {}
- override fun notifyCallInvitation(invitation: RcvCallInvitation) {}
- override fun hasNotificationsForChat(chatId: String): Boolean = false
- override fun cancelNotificationsForChat(chatId: String) {}
- override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List) {}
- override fun createNtfChannelsMaybeShowAlert() {}
+ ntfManager = object : NtfManager() {
+ override fun notifyCallInvitation(invitation: RcvCallInvitation) = chat.simplex.common.model.NtfManager.notifyCallInvitation(invitation)
+ override fun hasNotificationsForChat(chatId: String): Boolean = chat.simplex.common.model.NtfManager.hasNotificationsForChat(chatId)
+ override fun cancelNotificationsForChat(chatId: String) = chat.simplex.common.model.NtfManager.cancelNotificationsForChat(chatId)
+ override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List Unit>>) = chat.simplex.common.model.NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions)
+ override fun androidCreateNtfChannelsMaybeShowAlert() {}
override fun cancelCallNotification() {}
- override fun cancelAllNotifications() {}
+ override fun cancelAllNotifications() = chat.simplex.common.model.NtfManager.cancelAllNotifications()
}
applyAppLocale()
withBGApi {
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt
index 5cec4f2a77..3e60a1c9bd 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt
@@ -37,15 +37,19 @@ actual class FileChooserLauncher actual constructor() {
}
actual suspend fun launch(input: String) {
- val res = if (getContent) {
+ var res: File?
+ if (getContent) {
val params = DialogParams(
allowMultiple = false,
fileFilter = fileFilter(input),
fileFilterDescription = fileFilterDescription(input),
)
- simplexWindowState.openDialog.awaitResult(params)
+ res = simplexWindowState.openDialog.awaitResult(params)
} else {
- simplexWindowState.saveDialog.awaitResult()
+ res = simplexWindowState.saveDialog.awaitResult(DialogParams(filename = input))
+ if (res != null && res.isDirectory) {
+ res = File(res, input)
+ }
}
onResult(res?.toURI())
}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt
index 40f89d17f1..397f8a6966 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt
@@ -1,5 +1,5 @@
package chat.simplex.common.platform
-import chat.simplex.common.model.NotificationsMode
+import chat.simplex.common.simplexWindowState
-actual fun allowedToShowNotification(): Boolean = true
+actual fun allowedToShowNotification(): Boolean = !simplexWindowState.windowFocused.value
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt
index 58edc0d88b..e485ee479d 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt
@@ -41,9 +41,9 @@ fun FrameWindowScope.FileDialogChooser(
onResult: (result: List) -> Unit
) {
if (isLinux()) {
- FileDialogChooserMultiple(title, isLoad, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult)
+ FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult)
} else {
- FileDialogAwt(title, isLoad, params.allowMultiple, params.fileFilter, onResult)
+ FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult)
}
}
@@ -51,6 +51,7 @@ fun FrameWindowScope.FileDialogChooser(
fun FrameWindowScope.FileDialogChooserMultiple(
title: String,
isLoad: Boolean,
+ filename: String?,
allowMultiple: Boolean,
fileFilter: ((File?) -> Boolean)? = null,
fileFilterDescription: String? = null,
@@ -73,7 +74,11 @@ fun FrameWindowScope.FileDialogChooserMultiple(
val returned = if (isLoad) {
fileChooser.showOpenDialog(window)
} else {
- fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
+ if (filename != null) {
+ fileChooser.selectedFile = File(filename)
+ } else {
+ fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
+ }
fileChooser.showSaveDialog(window)
}
val result = when (returned) {
@@ -109,6 +114,7 @@ fun FrameWindowScope.FileDialogChooserMultiple(
private fun FrameWindowScope.FileDialogAwt(
title: String,
isLoad: Boolean,
+ filename: String?,
allowMultiple: Boolean,
fileFilter: ((File?) -> Boolean)? = null,
onResult: (result: List) -> Unit
@@ -128,6 +134,9 @@ private fun FrameWindowScope.FileDialogAwt(
}.apply {
this.title = title
this.isMultipleMode = allowMultiple && isLoad
+ if (!isLoad && filename != null) {
+ this.file = filename
+ }
if (fileFilter != null) {
this.setFilenameFilter { dir, file ->
fileFilter(File(dir.absolutePath + File.separator + file))
diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
index 542ade961a..4d97bc49b0 100644
--- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
+++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt
@@ -10,6 +10,7 @@ fun main() {
initHaskell()
initApp()
tmpDir.deleteRecursively()
+ tmpDir.mkdir()
return showApp()
}
diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties
index 77cbc79b93..429d1fa38b 100644
--- a/apps/multiplatform/gradle.properties
+++ b/apps/multiplatform/gradle.properties
@@ -25,8 +25,8 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
-android.version_name=5.2
-android.version_code=134
+android.version_name=5.3-beta.1
+android.version_code=138
desktop.version_name=1.0
diff --git a/package.yaml b/package.yaml
index 8dbb50ff29..9b588b1bac 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 5.3.0.0
+version: 5.3.0.1
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh
index a37e023130..5a8ac3d3fb 100755
--- a/scripts/desktop/build-lib-mac.sh
+++ b/scripts/desktop/build-lib-mac.sh
@@ -61,6 +61,13 @@ function copy_deps() {
copy_deps $LIB
rm deps/`basename $LIB`
+if [ -e deps/libHSdrct-*.$LIB_EXT ]; then
+ LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
+ install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct*.$LIB_EXT
+ cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT
+ chmod 755 deps/libcrypto.1.1.$LIB_EXT
+fi
+
cd -
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 4f6ebd795f..f2ff5f8cc6 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 5.3.0.0
+version: 5.3.0.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 514553a574..79745f198b 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -139,9 +139,11 @@ defaultChatConfig =
_defaultSMPServers :: NonEmpty SMPServerWithAuth
_defaultSMPServers =
L.fromList
- [ "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
- "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
- "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion"
+ [ "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
+ "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
+ "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
+ "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
+ "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion"
]
_defaultNtfServers :: [NtfServer]
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index daf59d82ac..33b6041841 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -501,6 +501,7 @@ data CIFileStatus (d :: MsgDirection) where
CIFSRcvComplete :: CIFileStatus 'MDRcv
CIFSRcvCancelled :: CIFileStatus 'MDRcv
CIFSRcvError :: CIFileStatus 'MDRcv
+ CIFSInvalid :: {text :: Text} -> CIFileStatus 'MDSnd
deriving instance Eq (CIFileStatus d)
@@ -519,6 +520,7 @@ ciFileEnded = \case
CIFSRcvCancelled -> True
CIFSRcvComplete -> True
CIFSRcvError -> True
+ CIFSInvalid {} -> True
instance ToJSON (CIFileStatus d) where
toJSON = J.toJSON . jsonCIFileStatus
@@ -545,25 +547,29 @@ instance MsgDirectionI d => StrEncoding (CIFileStatus d) where
CIFSRcvComplete -> "rcv_complete"
CIFSRcvCancelled -> "rcv_cancelled"
CIFSRcvError -> "rcv_error"
+ CIFSInvalid {} -> "invalid"
strP = (\(AFS _ st) -> checkDirection st) <$?> strP
instance StrEncoding ACIFileStatus where
strEncode (AFS _ s) = strEncode s
strP =
- A.takeTill (== ' ') >>= \case
- "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored
- "snd_transfer" -> AFS SMDSnd <$> progress CIFSSndTransfer
- "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled
- "snd_complete" -> pure $ AFS SMDSnd CIFSSndComplete
- "snd_error" -> pure $ AFS SMDSnd CIFSSndError
- "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation
- "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted
- "rcv_transfer" -> AFS SMDRcv <$> progress CIFSRcvTransfer
- "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete
- "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled
- "rcv_error" -> pure $ AFS SMDRcv CIFSRcvError
- _ -> fail "bad file status"
+ (statusP <* A.endOfInput) -- endOfInput to make it fail on partial correct parse
+ <|> (AFS SMDSnd . CIFSInvalid . safeDecodeUtf8 <$> A.takeByteString)
where
+ statusP =
+ A.takeTill (== ' ') >>= \case
+ "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored
+ "snd_transfer" -> AFS SMDSnd <$> progress CIFSSndTransfer
+ "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled
+ "snd_complete" -> pure $ AFS SMDSnd CIFSSndComplete
+ "snd_error" -> pure $ AFS SMDSnd CIFSSndError
+ "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation
+ "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted
+ "rcv_transfer" -> AFS SMDRcv <$> progress CIFSRcvTransfer
+ "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete
+ "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled
+ "rcv_error" -> pure $ AFS SMDRcv CIFSRcvError
+ _ -> fail "bad file status"
progress :: (Int64 -> Int64 -> a) -> A.Parser a
progress f = f <$> num <*> num <|> pure (f 0 1)
num = A.space *> A.decimal
@@ -580,6 +586,7 @@ data JSONCIFileStatus
| JCIFSRcvComplete
| JCIFSRcvCancelled
| JCIFSRcvError
+ | JCIFSInvalid {text :: Text}
deriving (Generic)
instance ToJSON JSONCIFileStatus where
@@ -599,6 +606,7 @@ jsonCIFileStatus = \case
CIFSRcvComplete -> JCIFSRcvComplete
CIFSRcvCancelled -> JCIFSRcvCancelled
CIFSRcvError -> JCIFSRcvError
+ CIFSInvalid text -> JCIFSInvalid text
aciFileStatusJSON :: JSONCIFileStatus -> ACIFileStatus
aciFileStatusJSON = \case
@@ -613,6 +621,7 @@ aciFileStatusJSON = \case
JCIFSRcvComplete -> AFS SMDRcv CIFSRcvComplete
JCIFSRcvCancelled -> AFS SMDRcv CIFSRcvCancelled
JCIFSRcvError -> AFS SMDRcv CIFSRcvError
+ JCIFSInvalid text -> AFS SMDSnd $ CIFSInvalid text
-- to conveniently read file data from db
data CIFileInfo = CIFileInfo
@@ -630,6 +639,7 @@ data CIStatus (d :: MsgDirection) where
CISSndError :: String -> CIStatus 'MDSnd
CISRcvNew :: CIStatus 'MDRcv
CISRcvRead :: CIStatus 'MDRcv
+ CISInvalid :: Text -> CIStatus 'MDSnd
deriving instance Eq (CIStatus d)
@@ -658,20 +668,25 @@ instance MsgDirectionI d => StrEncoding (CIStatus d) where
CISSndError e -> "snd_error " <> encodeUtf8 (T.pack e)
CISRcvNew -> "rcv_new"
CISRcvRead -> "rcv_read"
+ CISInvalid {} -> "invalid"
strP = (\(ACIStatus _ st) -> checkDirection st) <$?> strP
instance StrEncoding ACIStatus where
strEncode (ACIStatus _ s) = strEncode s
strP =
- A.takeTill (== ' ') >>= \case
- "snd_new" -> pure $ ACIStatus SMDSnd CISSndNew
- "snd_sent" -> ACIStatus SMDSnd . CISSndSent <$> ((A.space *> strP) <|> pure SSPComplete)
- "snd_rcvd" -> ACIStatus SMDSnd <$> (CISSndRcvd <$> (A.space *> strP) <*> ((A.space *> strP) <|> pure SSPComplete))
- "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth
- "snd_error" -> ACIStatus SMDSnd . CISSndError . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString)
- "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew
- "rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead
- _ -> fail "bad status"
+ (statusP <* A.endOfInput) -- endOfInput to make it fail on partial correct parse, e.g. "snd_rcvd ok complete"
+ <|> (ACIStatus SMDSnd . CISInvalid . safeDecodeUtf8 <$> A.takeByteString)
+ where
+ statusP =
+ A.takeTill (== ' ') >>= \case
+ "snd_new" -> pure $ ACIStatus SMDSnd CISSndNew
+ "snd_sent" -> ACIStatus SMDSnd . CISSndSent <$> ((A.space *> strP) <|> pure SSPComplete)
+ "snd_rcvd" -> ACIStatus SMDSnd <$> (CISSndRcvd <$> (A.space *> strP) <*> ((A.space *> strP) <|> pure SSPComplete))
+ "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth
+ "snd_error" -> ACIStatus SMDSnd . CISSndError . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString)
+ "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew
+ "rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead
+ _ -> fail "bad status"
data JSONCIStatus
= JCISSndNew
@@ -681,6 +696,7 @@ data JSONCIStatus
| JCISSndError {agentError :: String}
| JCISRcvNew
| JCISRcvRead
+ | JCISInvalid {text :: Text}
deriving (Show, Generic)
instance ToJSON JSONCIStatus where
@@ -696,6 +712,7 @@ jsonCIStatus = \case
CISSndError e -> JCISSndError e
CISRcvNew -> JCISRcvNew
CISRcvRead -> JCISRcvRead
+ CISInvalid text -> JCISInvalid text
ciStatusNew :: forall d. MsgDirectionI d => CIStatus d
ciStatusNew = case msgDirection @d of
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index 03b7956274..d6e443401d 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -1369,6 +1369,7 @@ viewFileTransferStatusXFTP (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId
CIFSRcvComplete -> ["receiving " <> fstr <> " complete" <> maybe "" (\fp -> ", path: " <> plain fp) filePath]
CIFSRcvCancelled -> ["receiving " <> fstr <> " cancelled"]
CIFSRcvError -> ["receiving " <> fstr <> " error"]
+ CIFSInvalid text -> [fstr <> " invalid status: " <> plain text]
where
fstr = fileTransferStr fileId fileName
viewFileTransferStatusXFTP _ = ["no file status"]
diff --git a/website/.eleventy.js b/website/.eleventy.js
index 1592f5ab95..6c629af1b8 100644
--- a/website/.eleventy.js
+++ b/website/.eleventy.js
@@ -238,6 +238,7 @@ module.exports = function (ty) {
ty.addPassthroughCopy("src/video")
ty.addPassthroughCopy("src/css")
ty.addPassthroughCopy("src/js")
+ ty.addPassthroughCopy("src/lottie_file")
ty.addPassthroughCopy("src/contact/*.js")
ty.addPassthroughCopy("src/call")
ty.addPassthroughCopy("src/hero-phone")
diff --git a/website/langs/en.json b/website/langs/en.json
index 5db528114d..74ea350e78 100644
--- a/website/langs/en.json
+++ b/website/langs/en.json
@@ -90,7 +90,7 @@
"simplex-unique-4-title": "You own SimpleX network",
"simplex-unique-4-overlay-1-title": "Fully decentralised — users own the SimpleX network",
"hero-overlay-card-1-p-1": "Many users asked: if SimpleX has no user identifiers, how can it know where to deliver messages?",
- "hero-overlay-card-1-p-2": "To deliver mesages, instead of user IDs used by all other platforms, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.",
+ "hero-overlay-card-1-p-2": "To deliver messages, instead of user IDs used by all other platforms, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.",
"hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.",
"hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.",
"hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.",
@@ -233,4 +233,4 @@
"on-this-page": "On this page",
"back-to-top": "Back to top",
"glossary": "Glossary"
-}
\ No newline at end of file
+}
diff --git a/website/package.json b/website/package.json
index 5eddf63031..bbe3a17a68 100644
--- a/website/package.json
+++ b/website/package.json
@@ -32,6 +32,7 @@
"fs": "^0.0.1-security",
"gray-matter": "^4.0.3",
"jsdom": "^22.1.0",
+ "lottie-web": "5.12.2",
"markdown-it": "^13.0.1"
}
}
diff --git a/website/src/_includes/simplex_explained.html b/website/src/_includes/simplex_explained.html
index 15bd8623c5..6ba20fb323 100644
--- a/website/src/_includes/simplex_explained.html
+++ b/website/src/_includes/simplex_explained.html
@@ -34,6 +34,8 @@
{{ "simplex-explained-tab-3-text" | i18n({}, lang ) | safe }}
+
+
@@ -41,7 +43,7 @@
{{ "simplex-explained-tab-1-text" | i18n({}, lang ) | safe }}
-

+
{{ "simplex-explained-tab-1-p-1" | i18n({}, lang ) | safe }}
@@ -54,7 +56,7 @@
{{ "simplex-explained-tab-2-text" | i18n({}, lang ) | safe }}
-

+
{{ "simplex-explained-tab-2-p-1" | i18n({}, lang ) | safe }}
@@ -67,7 +69,7 @@
{{ "simplex-explained-tab-3-text" | i18n({}, lang ) | safe }}
-

+
{{ "simplex-explained-tab-3-p-1" | i18n({}, lang ) | safe }}
@@ -83,17 +85,59 @@
+