mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc4a9a19b | |||
| e34faf56c7 | |||
| 1180edf80e | |||
| 5dad4e22f5 | |||
| 4bd6517d19 | |||
| f1290d7e38 | |||
| 2585f4ecfd | |||
| e22e01acd2 | |||
| 8e4299afb6 | |||
| ecff3c6ee5 | |||
| 85af368371 | |||
| 0d3928bd51 | |||
| ddeaa1c7c3 | |||
| 00ba468898 | |||
| 593c7d247c | |||
| 5907d8bd0c | |||
| 231082860e | |||
| c6366cb269 | |||
| d066c66282 | |||
| fab3827697 | |||
| d8126e0cd0 | |||
| 5baf8789d6 | |||
| dd5454f390 | |||
| fca098f535 | |||
| fba0478e50 | |||
| d951003191 |
@@ -158,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- USDT:
|
||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||
- please ask if you want to donate any other coins.
|
||||
|
||||
Thank you,
|
||||
|
||||
|
||||
@@ -332,12 +332,12 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
withConditionalAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation(itemAnimation()) {
|
||||
withConditionalAnimation(itemAnimation()) {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
@@ -357,7 +357,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
withConditionalAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
} else if let status = status {
|
||||
@@ -512,11 +512,13 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markChatItemRead_(i)
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
// update current chat
|
||||
markChatItemRead_(i)
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +725,7 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts {
|
||||
struct UnreadChatItemCounts: Equatable {
|
||||
var totalBelow: Int
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
@@ -92,18 +92,22 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
return r
|
||||
}
|
||||
|
||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
}
|
||||
let start = Date.now
|
||||
let resp = bgTask
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||
: sendSimpleXCmd(cmd, ctrl)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -309,8 +313,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
throw r
|
||||
}
|
||||
|
||||
let loadItemsPerPage = 50
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
@@ -541,6 +547,11 @@ func reconnectAllServers() async throws {
|
||||
try await sendCommandOkResp(.reconnectAllServers)
|
||||
}
|
||||
|
||||
func reconnectServer(smpServer: String) async throws {
|
||||
let userId = try currentUserId("reconnectServer")
|
||||
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
|
||||
}
|
||||
|
||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
@@ -1334,6 +1345,18 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||
let userId = try currentUserId("getAgentServersSummary")
|
||||
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
|
||||
logger.error("getAgentServersSummary error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func resetAgentServersStats() async throws {
|
||||
try await sendCommandOkResp(.resetAgentServersStats)
|
||||
}
|
||||
|
||||
private func currentUserId(_ funcName: String) throws -> Int64 {
|
||||
if let userId = ChatModel.shared.currentUser?.userId {
|
||||
return userId
|
||||
|
||||
@@ -13,10 +13,9 @@ struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let chatItem: ChatItem
|
||||
let image: String
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
@Binding var imgWidth: CGFloat?
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
var imgWidth: CGFloat?
|
||||
@State private var showFullScreenImage = false
|
||||
|
||||
var body: some View {
|
||||
@@ -25,15 +24,14 @@ struct CIImageView: View {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
imageView(uiImage)
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
} else if let preview {
|
||||
imageView(preview)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
@@ -90,7 +88,6 @@ struct CIImageView: View {
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { imgWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
|
||||
@@ -15,14 +15,12 @@ struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
private let preview: UIImage?
|
||||
@State private var duration: Int
|
||||
@State private var progress: Int = 0
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
@Binding private var videoWidth: CGFloat?
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
private var videoWidth: CGFloat?
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@@ -33,13 +31,12 @@ struct CIVideoView: View {
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
@State private var publisher: AnyCancellable? = nil
|
||||
|
||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
|
||||
self.chatItem = chatItem
|
||||
self.image = image
|
||||
self.preview = preview
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self._videoWidth = videoWidth
|
||||
self.scrollProxy = scrollProxy
|
||||
self.videoWidth = videoWidth
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||
self._urlDecrypted = State(initialValue: decrypted)
|
||||
@@ -49,10 +46,6 @@ struct CIVideoView: View {
|
||||
}
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
self._preview = State(initialValue: uiImage)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -63,9 +56,8 @@ struct CIVideoView: View {
|
||||
videoView(player, decrypted, file, preview, duration)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
} else if let preview {
|
||||
imageView(preview)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
@@ -152,7 +144,6 @@ struct CIVideoView: View {
|
||||
|
||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
@@ -252,7 +243,6 @@ struct CIVideoView: View {
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
|
||||
@@ -18,15 +18,16 @@ private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, o
|
||||
|
||||
struct FramedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var preview: UIImage?
|
||||
@Binding var revealed: Bool
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@State var msgWidth: CGFloat = 0
|
||||
@State var imgWidth: CGFloat? = nil
|
||||
@State var videoWidth: CGFloat? = nil
|
||||
var imgWidth: CGFloat? = nil
|
||||
var videoWidth: CGFloat? = nil
|
||||
@State var metaColor = Color.secondary
|
||||
@State var showFullScreenImage = false
|
||||
@Binding var allowMenu: Bool
|
||||
@@ -58,10 +59,9 @@ struct FramedItemView: View {
|
||||
if let qi = chatItem.quotedItem {
|
||||
ciQuoteView(qi)
|
||||
.onTapGesture {
|
||||
if let proxy = scrollProxy,
|
||||
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
||||
scrollModel.scrollToItem(id: ci.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ struct FramedItemView: View {
|
||||
}
|
||||
}
|
||||
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(18)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||
|
||||
@@ -114,8 +115,8 @@ struct FramedItemView: View {
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
switch (chatItem.content.msgContent) {
|
||||
case let .image(text, image):
|
||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
|
||||
case let .image(text, _):
|
||||
CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
@@ -127,8 +128,8 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
ciMsgContentView(chatItem)
|
||||
}
|
||||
case let .video(text, image, duration):
|
||||
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
|
||||
case let .video(text, _, duration):
|
||||
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
@@ -181,7 +182,6 @@ struct FramedItemView: View {
|
||||
.padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: msgWidth, alignment: .leading)
|
||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||
} else {
|
||||
|
||||
@@ -13,12 +13,12 @@ import AVKit
|
||||
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||
@State var chatItem: ChatItem
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
@State var url: URL? = nil
|
||||
@Binding var showView: Bool
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State private var showNext = false
|
||||
@State private var nextImage: UIImage?
|
||||
@State private var nextPlayer: AVPlayer?
|
||||
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
|
||||
let w = abs(t.width)
|
||||
if t.height > 60 && t.height > w * 2 {
|
||||
showView = false
|
||||
if let proxy = scrollProxy {
|
||||
proxy.scrollTo(chatItem.viewId)
|
||||
}
|
||||
scrollModel.scrollToItem(id: chatItem.id)
|
||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||
let previous = t.width > 0
|
||||
scrolling = true
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ChatItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@@ -24,7 +23,6 @@ struct ChatItemView: View {
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
scrollProxy: ScrollViewProxy? = nil,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false),
|
||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||
@@ -34,7 +32,6 @@ struct ChatItemView: View {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
_audioPlayer = audioPlayer
|
||||
@@ -62,7 +59,37 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
let preview = chatItem.content.msgContent
|
||||
.flatMap {
|
||||
switch $0 {
|
||||
case let .image(_, image): image
|
||||
case let .video(_, image, _): image
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
.map { dropImagePrefix($0) }
|
||||
.flatMap { Data(base64Encoded: $0) }
|
||||
.flatMap { UIImage(data: $0) }
|
||||
let adjustedMaxWidth = {
|
||||
if let preview, preview.size.width <= preview.size.height {
|
||||
maxWidth * 0.75
|
||||
} else {
|
||||
maxWidth
|
||||
}
|
||||
}()
|
||||
return FramedItemView(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
preview: preview,
|
||||
revealed: $revealed,
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
videoWidth: adjustedMaxWidth,
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// ReverseList.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 11/06/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
let items: Array<Item>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (Item) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(representer: self)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
case .bottom:
|
||||
controller.scroll(to: .zero, position: .top)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
private let representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||
private var itemCount: Int = .zero
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
self.representer = representer
|
||||
super.init(style: .plain)
|
||||
|
||||
// 1. Style
|
||||
tableView.separatorStyle = .none
|
||||
tableView.transform = .verticalFlip
|
||||
|
||||
// 2. Register cells
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.register(
|
||||
UITableViewCell.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
} else {
|
||||
tableView.register(
|
||||
HostingCell<Content>.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||
self.representer.loadPage()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||
.margins(.all, .zero)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
cell.set(content: self.representer.content(item), parent: self)
|
||||
} else {
|
||||
fatalError("Unexpected Cell Type for: \(item)")
|
||||
}
|
||||
}
|
||||
cell.transform = .verticalFlip
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
|
||||
// 4. External state changes will require manual layout updates
|
||||
NotificationCenter.default
|
||||
.addObserver(
|
||||
self,
|
||||
selector: #selector(updateLayout),
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
@objc private func updateLayout() {
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.setNeedsLayout()
|
||||
tableView.layoutIfNeeded()
|
||||
} else {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides keyboard, when user begins to scroll.
|
||||
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
UIApplication.shared
|
||||
.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(
|
||||
x: tableView.contentOffset.x,
|
||||
y: tableView.contentOffset.y + tableView.bounds.height
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
/// Scrolls to Item at index path
|
||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||
if let index {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: .zero),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
}
|
||||
|
||||
func update(items: Array<Item>) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
|
||||
)
|
||||
itemCount = items.count
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||
if !contentView.subviews.contains(hostingController.view) {
|
||||
contentView.addSubview(hostingController.view)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingView.leadingAnchor
|
||||
.constraint(equalTo: contentView.leadingAnchor),
|
||||
hostingView.trailingAnchor
|
||||
.constraint(equalTo: contentView.trailingAnchor),
|
||||
hostingView.topAnchor
|
||||
.constraint(equalTo: contentView.topAnchor),
|
||||
hostingView.bottomAnchor
|
||||
.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||
} else {
|
||||
fatalError("Hosting View not loaded \(hostingController)")
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
hostingController.rootView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
|
||||
/// Represents Scroll State of ``ReverseList``
|
||||
enum State: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case nextPage
|
||||
case item(Item.ID)
|
||||
case bottom
|
||||
}
|
||||
|
||||
case scrollingTo(Destination)
|
||||
case atDestination
|
||||
}
|
||||
|
||||
@Published var state: State = .atDestination
|
||||
|
||||
func scrollToNextPage() {
|
||||
state = .scrollingTo(.nextPage)
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
state = .scrollingTo(.bottom)
|
||||
}
|
||||
|
||||
func scrollToItem(id: Item.ID) {
|
||||
state = .scrollingTo(.item(id))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let cellReuseId = "hostingCell"
|
||||
|
||||
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||
|
||||
fileprivate extension CGAffineTransform {
|
||||
/// Transform that vertically flips the view, preserving it's location
|
||||
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||
}
|
||||
|
||||
extension NotificationCenter {
|
||||
static func postReverseListNeedsLayout() {
|
||||
NotificationCenter.default.post(
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable animation on iOS 15
|
||||
func withConditionalAnimation<Result>(
|
||||
_ animation: Animation? = .default,
|
||||
_ body: () throws -> Result
|
||||
) rethrows -> Result {
|
||||
if #available(iOS 16.0, *) {
|
||||
try withAnimation(animation, body)
|
||||
} else {
|
||||
try body()
|
||||
}
|
||||
}
|
||||
@@ -115,9 +115,7 @@ struct ChatListView: View {
|
||||
HStack(spacing: 4) {
|
||||
Text("Chats")
|
||||
.font(.headline)
|
||||
if chatModel.chats.count > 0 {
|
||||
toggleFilterButton()
|
||||
}
|
||||
SubsStatusIndicator()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
@@ -131,15 +129,6 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleFilterButton() -> some View {
|
||||
Button {
|
||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var chatList: some View {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
@@ -220,9 +209,7 @@ struct ChatListView: View {
|
||||
|
||||
@ViewBuilder private func chatView() -> some View {
|
||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||
ChatView(chat: chat).onAppear {
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
ChatView(chat: chat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +261,75 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SubsStatusIndicator: View {
|
||||
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||
@State private var sess: ServerSessions = ServerSessions.newServerSessions
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var timerCounter = 0
|
||||
@State private var showServersSummary = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
|
||||
// Constants for the intervals
|
||||
let initialInterval: TimeInterval = 1.0
|
||||
let regularInterval: TimeInterval = 3.0
|
||||
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showServersSummary = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startInitialTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
}
|
||||
.sheet(isPresented: $showServersSummary) {
|
||||
ServersSummaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private func startInitialTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
timerCounter += 1
|
||||
// Switch to the regular timer after the initial phase
|
||||
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
|
||||
switchToRegularTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchToRegularTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
let summ = try getAgentServersSummary()
|
||||
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListSearchBar: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Binding var searchMode: Bool
|
||||
@@ -282,9 +338,9 @@ struct ChatListSearchBar: View {
|
||||
@Binding var searchShowingSimplexLink: Bool
|
||||
@Binding var searchChatFilteredBySimplexLink: String?
|
||||
@State private var ignoreSearchTextChange = false
|
||||
@State private var showScanCodeSheet = false
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
@@ -301,26 +357,6 @@ struct ChatListSearchBar: View {
|
||||
.onTapGesture {
|
||||
searchText = ""
|
||||
}
|
||||
} else if !searchFocussed {
|
||||
HStack(spacing: 24) {
|
||||
if m.pasteboardHasStrings {
|
||||
Image(systemName: "doc")
|
||||
.onTapGesture {
|
||||
if let str = UIPasteboard.general.string {
|
||||
searchText = str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "qrcode")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.onTapGesture {
|
||||
showScanCodeSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||
@@ -335,14 +371,12 @@ struct ChatListSearchBar: View {
|
||||
searchText = ""
|
||||
searchFocussed = false
|
||||
}
|
||||
} else if m.chats.count > 0 {
|
||||
toggleFilterButton()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
.sheet(isPresented: $showScanCodeSheet) {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
|
||||
}
|
||||
.onChange(of: searchFocussed) { sf in
|
||||
withAnimation { searchMode = sf }
|
||||
}
|
||||
@@ -376,6 +410,21 @@ struct ChatListSearchBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleFilterButton() -> some View {
|
||||
ZStack {
|
||||
Color.clear
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary)
|
||||
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
|
||||
.onTapGesture {
|
||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ link: String) {
|
||||
planAndConnect(
|
||||
link,
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
//
|
||||
// ServersSummaryView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 25.06.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ServersSummaryView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State private var serversSummary: PresentedServersSummary? = nil
|
||||
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
||||
@State private var selectedServerType: PresentedServerType = .smp
|
||||
@State private var selectedSMPServer: String? = nil
|
||||
@State private var selectedXFTPServer: String? = nil
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var alert: SomeAlert?
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
|
||||
enum PresentedUserCategory {
|
||||
case currentUser
|
||||
case allUsers
|
||||
}
|
||||
|
||||
enum PresentedServerType {
|
||||
case smp
|
||||
case xftp
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
viewBody()
|
||||
.navigationTitle("Servers info")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
shareButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 {
|
||||
selectedUserCategory = .currentUser
|
||||
}
|
||||
getServersSummary()
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
}
|
||||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func shareButton() -> some View {
|
||||
Button {
|
||||
if let serversSummary = serversSummary {
|
||||
showShareSheet(items: [encodePrettyPrinted(serversSummary)])
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(serversSummary == nil)
|
||||
}
|
||||
|
||||
public func encodePrettyPrinted<T: Encodable>(_ value: T) -> String {
|
||||
let encoder = jsonEncoder
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
let data = try! encoder.encode(value)
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if let summ = serversSummary {
|
||||
List {
|
||||
Group {
|
||||
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
Picker("User selection", selection: $selectedUserCategory) {
|
||||
Text("All users").tag(PresentedUserCategory.allUsers)
|
||||
Text("Current user").tag(PresentedUserCategory.currentUser)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Picker("Server type", selection: $selectedServerType) {
|
||||
Text("Messages").tag(PresentedServerType.smp)
|
||||
Text("Files").tag(PresentedServerType.xftp)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
switch (selectedUserCategory, selectedServerType) {
|
||||
case (.allUsers, .smp):
|
||||
let smpSumm = summ.allUsersSMP
|
||||
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
|
||||
|
||||
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||
|
||||
smpSubsSection(totals)
|
||||
|
||||
if curr.count > 0 {
|
||||
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||
}
|
||||
if prev.count > 0 {
|
||||
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||
}
|
||||
if prox.count > 0 {
|
||||
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
|
||||
}
|
||||
|
||||
ServerSessionsView(sess: totals.sessions)
|
||||
case (.currentUser, .smp):
|
||||
let smpSumm = summ.currentUserSMP
|
||||
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
|
||||
|
||||
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||
|
||||
smpSubsSection(totals)
|
||||
|
||||
if curr.count > 0 {
|
||||
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||
}
|
||||
if prev.count > 0 {
|
||||
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||
}
|
||||
if prox.count > 0 {
|
||||
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
|
||||
}
|
||||
|
||||
ServerSessionsView(sess: totals.sessions)
|
||||
case (.allUsers, .xftp):
|
||||
let xftpSumm = summ.allUsersXFTP
|
||||
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
|
||||
|
||||
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||
|
||||
if curr.count > 0 {
|
||||
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||
}
|
||||
if prev.count > 0 {
|
||||
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||
}
|
||||
|
||||
ServerSessionsView(sess: totals.sessions)
|
||||
case (.currentUser, .xftp):
|
||||
let xftpSumm = summ.currentUserXFTP
|
||||
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
|
||||
|
||||
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||
|
||||
if curr.count > 0 {
|
||||
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||
}
|
||||
if prev.count > 0 {
|
||||
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||
}
|
||||
|
||||
ServerSessionsView(sess: totals.sessions)
|
||||
}
|
||||
|
||||
Section {
|
||||
reconnectAllButton()
|
||||
resetStatsButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No info, try to reload")
|
||||
}
|
||||
}
|
||||
|
||||
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
||||
Section {
|
||||
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
|
||||
infoRow("Total", numOrDash(totals.subs.total))
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnectAllButton() -> some View {
|
||||
Button {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Reconnect all servers?"),
|
||||
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
Task {
|
||||
do {
|
||||
try await reconnectAllServers()
|
||||
} catch let error {
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Error reconnecting servers",
|
||||
message: "\(responseError(error))"
|
||||
),
|
||||
id: "error reconnecting servers"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "reconnect servers question"
|
||||
)
|
||||
} label: {
|
||||
Text("Reconnect all servers")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func smpServersListView(
|
||||
_ servers: [SMPServerSummary],
|
||||
_ statsStartedAt: Date,
|
||||
_ header: LocalizedStringKey? = nil,
|
||||
_ footer: LocalizedStringKey? = nil
|
||||
) -> some View {
|
||||
let sortedServers = servers.sorted {
|
||||
$0.hasSubs == $1.hasSubs
|
||||
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
|
||||
: $0.hasSubs && !$1.hasSubs
|
||||
}
|
||||
Section {
|
||||
ForEach(sortedServers) { server in
|
||||
smpServerView(server, statsStartedAt)
|
||||
}
|
||||
} header: {
|
||||
if let header = header {
|
||||
Text(header)
|
||||
}
|
||||
} footer: {
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smpServerView(_ srvSumm: SMPServerSummary, _ statsStartedAt: Date) -> some View {
|
||||
NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) {
|
||||
SMPServerSummaryView(
|
||||
summary: srvSumm,
|
||||
statsStartedAt: statsStartedAt
|
||||
)
|
||||
.navigationBarTitle("SMP server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(serverAddress(srvSumm.smpServer))
|
||||
.lineLimit(1)
|
||||
if let subs = srvSumm.subs {
|
||||
Spacer()
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
}
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
} else if let sess = srvSumm.sessions {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(sessIconColor(sess), Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func serverAddress(_ server: String) -> String {
|
||||
parseServerAddress(server)?.hostnames.first ?? server
|
||||
}
|
||||
|
||||
private func sessIconColor(_ sess: ServerSessions) -> Color {
|
||||
let online = m.networkInfo.online
|
||||
return (
|
||||
online && sess.ssConnected > 0
|
||||
? sessionActiveColor
|
||||
: Color(uiColor: .tertiaryLabel)
|
||||
)
|
||||
}
|
||||
|
||||
private var sessionActiveColor: Color {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
return onionHosts == .require ? .indigo : .accentColor
|
||||
}
|
||||
|
||||
@ViewBuilder private func xftpServersListView(
|
||||
_ servers: [XFTPServerSummary],
|
||||
_ statsStartedAt: Date,
|
||||
_ header: LocalizedStringKey? = nil,
|
||||
_ footer: LocalizedStringKey? = nil
|
||||
) -> some View {
|
||||
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
|
||||
Section {
|
||||
ForEach(sortedServers) { server in
|
||||
xftpServerView(server, statsStartedAt)
|
||||
}
|
||||
} header: {
|
||||
if let header = header {
|
||||
Text(header)
|
||||
}
|
||||
} footer: {
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func xftpServerView(_ srvSumm: XFTPServerSummary, _ statsStartedAt: Date) -> some View {
|
||||
NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) {
|
||||
XFTPServerSummaryView(
|
||||
summary: srvSumm,
|
||||
statsStartedAt: statsStartedAt
|
||||
)
|
||||
.navigationBarTitle("XFTP server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(serverAddress(srvSumm.xftpServer))
|
||||
.lineLimit(1)
|
||||
if let inProgressIcon = inProgressIcon(srvSumm) {
|
||||
Spacer()
|
||||
Image(systemName: inProgressIcon)
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(sessionActiveColor, Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inProgressIcon(_ srvSumm: XFTPServerSummary) -> String? {
|
||||
switch (srvSumm.rcvInProgress, srvSumm.sndInProgress, srvSumm.delInProgress) {
|
||||
case (false, false, false): nil
|
||||
case (true, false, false): "arrow.down.circle"
|
||||
case (false, true, false): "arrow.up.circle"
|
||||
case (false, false, true): "trash.circle"
|
||||
default: "arrow.up.arrow.down.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func resetStatsButton() -> some View {
|
||||
Button {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Reset all servers statistics?"),
|
||||
message: Text("Servers statistics will be reset - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Reset")) {
|
||||
Task {
|
||||
do {
|
||||
try await resetAgentServersStats()
|
||||
getServersSummary()
|
||||
} catch let error {
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Error resetting statistics",
|
||||
message: "\(responseError(error))"
|
||||
),
|
||||
id: "error resetting statistics"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "reset statistics question"
|
||||
)
|
||||
} label: {
|
||||
Text("Reset all statistics")
|
||||
}
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
serversSummary = try getAgentServersSummary()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionStatusIndicatorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var sess: ServerSessions
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
if #available(iOS 16.0, *) {
|
||||
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Image(systemName: "dot.radiowaves.up.forward")
|
||||
.foregroundColor(color.opacity(opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionStatusPercentageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var sess: ServerSessions
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
Text("\(Int(floor(statusPercent * 100)))%")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
|
||||
func roundedToQuarter(_ n: Double) -> Double {
|
||||
n >= 1 ? 1
|
||||
: n <= 0 ? 0
|
||||
: (n * 4).rounded() / 4
|
||||
}
|
||||
|
||||
let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
|
||||
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
||||
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
||||
|
||||
return online && subs.total > 0
|
||||
? (
|
||||
subs.ssActive == 0
|
||||
? (
|
||||
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
)
|
||||
: ( // ssActive > 0
|
||||
sess.ssConnected == 0
|
||||
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
||||
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
)
|
||||
)
|
||||
: noConnColorAndPercent
|
||||
}
|
||||
|
||||
struct SMPServerSummaryView: View {
|
||||
var summary: SMPServerSummary
|
||||
var statsStartedAt: Date
|
||||
@State private var alert: SomeAlert?
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Server address") {
|
||||
Text(summary.smpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
SMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
}
|
||||
|
||||
if let subs = summary.subs {
|
||||
smpSubsSection(subs)
|
||||
}
|
||||
|
||||
if let sess = summary.sessions {
|
||||
ServerSessionsView(sess: sess)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
||||
Section {
|
||||
infoRow("Connections subscribed", numOrDash(subs.ssActive))
|
||||
infoRow("Pending", numOrDash(subs.ssPending))
|
||||
infoRow("Total", numOrDash(subs.total))
|
||||
reconnectButton()
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnectButton() -> some View {
|
||||
Button {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Reconnect server?"),
|
||||
message: Text("Reconnect server to force message delivery. It uses additional traffic."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
Task {
|
||||
do {
|
||||
try await reconnectServer(smpServer: summary.smpServer)
|
||||
} catch let error {
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Error reconnecting server",
|
||||
message: "\(responseError(error))"
|
||||
),
|
||||
id: "error reconnecting server"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "reconnect server question"
|
||||
)
|
||||
} label: {
|
||||
Text("Reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSessionsView: View {
|
||||
var sess: ServerSessions
|
||||
|
||||
var body: some View {
|
||||
Section("Transport sessions") {
|
||||
infoRow("Connected", numOrDash(sess.ssConnected))
|
||||
infoRow("Errors", numOrDash(sess.ssErrors))
|
||||
infoRow("Connecting", numOrDash(sess.ssConnecting))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SMPStatsView: View {
|
||||
var stats: AgentSMPServerStatsData
|
||||
var statsStartedAt: Date
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
infoRow("Messages sent", numOrDash(stats._sentDirect + stats._sentViaProxy))
|
||||
infoRow("Messages received", numOrDash(stats._recvMsgs))
|
||||
NavigationLink {
|
||||
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
.navigationTitle("Detailed statistics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Text("Details")
|
||||
}
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func numOrDash(_ n: Int) -> String {
|
||||
n == 0 ? "-" : "\(n)"
|
||||
}
|
||||
|
||||
struct DetailedSMPStatsView: View {
|
||||
var stats: AgentSMPServerStatsData
|
||||
var statsStartedAt: Date
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Sent messages") {
|
||||
infoRow("Sent total", numOrDash(stats._sentDirect + stats._sentViaProxy))
|
||||
infoRowTwoValues("Sent directly", "attempts", stats._sentDirect, stats._sentDirectAttempts)
|
||||
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
|
||||
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
|
||||
Text("Send errors")
|
||||
indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
|
||||
indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
|
||||
indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs))
|
||||
indentedInfoRow("other", numOrDash(stats._sentOtherErrs))
|
||||
}
|
||||
Section("Received messages") {
|
||||
infoRow("Received total", numOrDash(stats._recvMsgs))
|
||||
Text("Receive errors")
|
||||
indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates))
|
||||
indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs))
|
||||
indentedInfoRow("other errors", numOrDash(stats._recvErrs))
|
||||
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
|
||||
Text("Acknowledgement errors")
|
||||
indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
|
||||
indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs))
|
||||
}
|
||||
Section {
|
||||
infoRow("Created", numOrDash(stats._connCreated))
|
||||
infoRow("Secured", numOrDash(stats._connCreated))
|
||||
infoRow("Completed", numOrDash(stats._connCompleted))
|
||||
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
|
||||
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
|
||||
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
||||
infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored))
|
||||
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
||||
} header: {
|
||||
Text("Connections")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
|
||||
HStack {
|
||||
Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2)
|
||||
Spacer()
|
||||
Group {
|
||||
if value == 0 && value2 == 0 {
|
||||
Text("-")
|
||||
} else {
|
||||
Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func indentedInfoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.padding(.leading, 24)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct XFTPServerSummaryView: View {
|
||||
var summary: XFTPServerSummary
|
||||
var statsStartedAt: Date
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Server address") {
|
||||
Text(summary.xftpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
XFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
}
|
||||
|
||||
if let sess = summary.sessions {
|
||||
ServerSessionsView(sess: sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct XFTPStatsView: View {
|
||||
var stats: AgentXFTPServerStatsData
|
||||
var statsStartedAt: Date
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
infoRow("Uploaded", prettySize(stats._uploadsSize))
|
||||
infoRow("Downloaded", prettySize(stats._downloadsSize))
|
||||
NavigationLink {
|
||||
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
.navigationTitle("Detailed statistics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Text("Details")
|
||||
}
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prettySize(_ sizeInKB: Int64) -> String {
|
||||
let kb: Int64 = 1024
|
||||
return sizeInKB == 0 ? "-" : ByteCountFormatter.string(fromByteCount: sizeInKB * kb, countStyle: .binary)
|
||||
}
|
||||
|
||||
struct DetailedXFTPStatsView: View {
|
||||
var stats: AgentXFTPServerStatsData
|
||||
var statsStartedAt: Date
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Uploaded files") {
|
||||
infoRow("Size", prettySize(stats._uploadsSize))
|
||||
infoRowTwoValues("Chunks uploaded", "attempts", stats._uploads, stats._uploadAttempts)
|
||||
infoRow("Upload errors", numOrDash(stats._uploadErrs))
|
||||
infoRowTwoValues("Chunks deleted", "attempts", stats._deletions, stats._deleteAttempts)
|
||||
infoRow("Deletion errors", numOrDash(stats._deleteErrs))
|
||||
}
|
||||
Section {
|
||||
infoRow("Size", prettySize(stats._downloadsSize))
|
||||
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
|
||||
Text("Download errors")
|
||||
indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
|
||||
indentedInfoRow("other", numOrDash(stats._downloadErrs))
|
||||
} header: {
|
||||
Text("Downloaded files")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ServersSummaryView()
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
//
|
||||
// ContextMenu2.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 09/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(hasImageOrVideo: Bool, maxWidth: CGFloat, itemWidth: Binding<CGFloat>, menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
|
||||
Group {
|
||||
if allowMenu.wrappedValue {
|
||||
if hasImageOrVideo {
|
||||
InteractionView(content:
|
||||
self.environmentObject(ChatModel.shared)
|
||||
.overlay(DetermineWidthImageVideoItem())
|
||||
.onPreferenceChange(DetermineWidthImageVideoItem.Key.self) { itemWidth.wrappedValue = $0 == 0 ? maxWidth : $0 }
|
||||
, maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
|
||||
.frame(maxWidth: itemWidth.wrappedValue)
|
||||
} else {
|
||||
InteractionView(content: self.environmentObject(ChatModel.shared), maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HostingViewHolder: UIView {
|
||||
var contentSize: CGSize = CGSizeMake(0, 0)
|
||||
override var intrinsicContentSize: CGSize { get { contentSize } }
|
||||
}
|
||||
|
||||
struct InteractionView<Content: View>: UIViewRepresentable {
|
||||
let content: Content
|
||||
var maxWidth: CGFloat
|
||||
var itemWidth: Binding<CGFloat>
|
||||
@Binding var menu: UIMenu
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = HostingViewHolder()
|
||||
view.backgroundColor = .clear
|
||||
let hostView = UIHostingController(rootView: content)
|
||||
view.contentSize = hostView.view.intrinsicContentSize
|
||||
hostView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let constraints = [
|
||||
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
|
||||
]
|
||||
view.addSubview(hostView.view)
|
||||
view.addConstraints(constraints)
|
||||
view.layer.cornerRadius = 18
|
||||
hostView.view.layer.cornerRadius = 18
|
||||
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
|
||||
view.addInteraction(menuInteraction)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
let was = (uiView as! HostingViewHolder).contentSize
|
||||
(uiView as! HostingViewHolder).contentSize = uiView.subviews[0].sizeThatFits(CGSizeMake(itemWidth.wrappedValue, .infinity))
|
||||
if was != (uiView as! HostingViewHolder).contentSize {
|
||||
uiView.invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
||||
let parent: InteractionView<Content>
|
||||
|
||||
init(_ parent: InteractionView<Content>) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
UIContextMenuConfiguration(
|
||||
identifier: nil,
|
||||
previewProvider: nil,
|
||||
actionProvider: { [weak self] _ in
|
||||
guard let self = self else { return nil }
|
||||
return self.parent.menu
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// func contextMenuInteraction(
|
||||
// _ interaction: UIContextMenuInteraction,
|
||||
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
|
||||
// animator: UIContextMenuInteractionCommitAnimating
|
||||
// ) {
|
||||
// animator.addCompletion {
|
||||
// print("user tapped")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// TranslateSheet.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 03/07/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Translation
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func translateSheet(text: Binding<String?>) -> some View {
|
||||
if #available(iOS 17.4, *) {
|
||||
translationPresentation(
|
||||
isPresented: Binding(
|
||||
get: { text.wrappedValue != nil },
|
||||
set: { if !$0 { text.wrappedValue = nil } }
|
||||
),
|
||||
text: text.wrappedValue ?? String()
|
||||
)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
enum NewChatMenuOption: Identifiable {
|
||||
case newContact
|
||||
case scanPaste
|
||||
case newGroup
|
||||
|
||||
var id: Self { self }
|
||||
@@ -25,6 +26,11 @@ struct NewChatMenuButton: View {
|
||||
} label: {
|
||||
Text("Add contact")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .scanPaste
|
||||
} label: {
|
||||
Text("Scan / Paste link")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .newGroup
|
||||
} label: {
|
||||
@@ -39,6 +45,7 @@ struct NewChatMenuButton: View {
|
||||
.sheet(item: $newChatMenuOption) { opt in
|
||||
switch opt {
|
||||
case .newContact: NewChatView(selection: .invite)
|
||||
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
case .newGroup: AddGroupView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ private enum NetworkAlert: Identifiable {
|
||||
struct NetworkAndServers: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = true
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
@State private var cfgLoaded = false
|
||||
@State private var currentNetCfg = NetCfg.defaults
|
||||
@State private var netCfg = NetCfg.defaults
|
||||
@@ -58,6 +59,8 @@ struct NetworkAndServers: View {
|
||||
Text("XFTP servers")
|
||||
}
|
||||
|
||||
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
|
||||
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ProtocolServerView: View {
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@State var serverEnabled: Bool
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
@@ -110,7 +111,10 @@ struct ProtocolServerView: View {
|
||||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
Toggle("Use for new connections", isOn: $serverEnabled)
|
||||
.onChange(of: serverEnabled) { enabled in
|
||||
serverToEdit.enabled = enabled ? .enabled : .disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +183,8 @@ struct ProtocolServerView_Previews: PreviewProvider {
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
serverToEdit: ServerCfg.sampleData.custom,
|
||||
serverEnabled: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
servers.allSatisfy { !$0.enabled }
|
||||
servers.allSatisfy { $0.enabled != .enabled }
|
||||
}
|
||||
|
||||
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
||||
@@ -168,7 +168,8 @@ struct ProtocolServersView: View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: serverProtocol,
|
||||
server: server,
|
||||
serverToEdit: srv
|
||||
serverToEdit: srv,
|
||||
serverEnabled: srv.enabled == .enabled
|
||||
)
|
||||
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
@@ -181,7 +182,7 @@ struct ProtocolServersView: View {
|
||||
invalidServer()
|
||||
} else if !uniqueAddress(srv, address) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if !srv.enabled {
|
||||
} else if srv.enabled != .enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(.secondary)
|
||||
} else {
|
||||
showTestStatus(server: srv)
|
||||
@@ -194,7 +195,7 @@ struct ProtocolServersView: View {
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
||||
if srv.enabled {
|
||||
if srv.enabled == .enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(.secondary)
|
||||
@@ -235,7 +236,7 @@ struct ProtocolServersView: View {
|
||||
private func addAllPresets() {
|
||||
for srv in presetServers {
|
||||
if !hasPreset(srv) {
|
||||
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: true))
|
||||
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: .enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +261,7 @@ struct ProtocolServersView: View {
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled {
|
||||
if servers[i].enabled == .enabled {
|
||||
servers[i].tested = nil
|
||||
}
|
||||
}
|
||||
@@ -269,7 +270,7 @@ struct ProtocolServersView: View {
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled {
|
||||
if servers[i].enabled == .enabled {
|
||||
if let f = await testServerConnection(server: $servers[i]) {
|
||||
fs[serverHostname(servers[i].server)] = f
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ScanProtocolServer: View {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: true))
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: .enabled))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
|
||||
@@ -61,6 +61,7 @@ let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
|
||||
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
|
||||
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
|
||||
|
||||
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
|
||||
|
||||
@@ -101,6 +102,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
|
||||
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
|
||||
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
|
||||
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
|
||||
]
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; };
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; };
|
||||
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; };
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
|
||||
@@ -178,6 +177,7 @@
|
||||
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 */; };
|
||||
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; };
|
||||
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
|
||||
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
|
||||
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; };
|
||||
@@ -186,17 +186,19 @@
|
||||
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; };
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
CE81DF912C355E3C001C0A02 /* TranslateSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE81DF902C355E3C001C0A02 /* TranslateSheet.swift */; };
|
||||
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
|
||||
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
|
||||
D741547A29AF90B00022400A /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547929AF90B00022400A /* PushKit.framework */; };
|
||||
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
|
||||
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
|
||||
E5D68D3F2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5D68D3A2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a */; };
|
||||
E5D68D402C22D78C00CBA347 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5D68D3B2C22D78C00CBA347 /* libffi.a */; };
|
||||
E5D68D412C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5D68D3C2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a */; };
|
||||
E5D68D422C22D78C00CBA347 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5D68D3D2C22D78C00CBA347 /* libgmp.a */; };
|
||||
E5D68D432C22D78C00CBA347 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5D68D3E2C22D78C00CBA347 /* libgmpxx.a */; };
|
||||
E52FF8DA2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */; };
|
||||
E52FF8DB2C34676700BF81EB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D62C34676600BF81EB /* libgmpxx.a */; };
|
||||
E52FF8DC2C34676700BF81EB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D72C34676700BF81EB /* libffi.a */; };
|
||||
E52FF8DD2C34676700BF81EB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D82C34676700BF81EB /* libgmp.a */; };
|
||||
E52FF8DE2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -267,7 +269,6 @@
|
||||
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
|
||||
5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = "<group>"; };
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
|
||||
@@ -475,6 +476,7 @@
|
||||
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
|
||||
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
|
||||
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
|
||||
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = "<group>"; };
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
|
||||
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
|
||||
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
@@ -483,15 +485,17 @@
|
||||
8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDevicePicker.swift; sourceTree = "<group>"; };
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
CE81DF902C355E3C001C0A02 /* TranslateSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslateSheet.swift; sourceTree = "<group>"; };
|
||||
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
|
||||
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
|
||||
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; };
|
||||
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
E5D68D3A2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a"; sourceTree = "<group>"; };
|
||||
E5D68D3B2C22D78C00CBA347 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E5D68D3C2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E5D68D3D2C22D78C00CBA347 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E5D68D3E2C22D78C00CBA347 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E52FF8D62C34676600BF81EB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E52FF8D72C34676700BF81EB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E52FF8D82C34676700BF81EB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -529,13 +533,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E5D68D412C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a in Frameworks */,
|
||||
E52FF8DE2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a in Frameworks */,
|
||||
E52FF8DB2C34676700BF81EB /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
E5D68D3F2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a in Frameworks */,
|
||||
E5D68D422C22D78C00CBA347 /* libgmp.a in Frameworks */,
|
||||
E5D68D402C22D78C00CBA347 /* libffi.a in Frameworks */,
|
||||
E5D68D432C22D78C00CBA347 /* libgmpxx.a in Frameworks */,
|
||||
E52FF8DC2C34676700BF81EB /* libffi.a in Frameworks */,
|
||||
E52FF8DD2C34676700BF81EB /* libgmp.a in Frameworks */,
|
||||
E52FF8DA2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -591,6 +595,7 @@
|
||||
5CE4407127ADB1D0007B033A /* Emoji.swift */,
|
||||
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
|
||||
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
|
||||
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */,
|
||||
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
|
||||
@@ -601,11 +606,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E5D68D3B2C22D78C00CBA347 /* libffi.a */,
|
||||
E5D68D3D2C22D78C00CBA347 /* libgmp.a */,
|
||||
E5D68D3E2C22D78C00CBA347 /* libgmpxx.a */,
|
||||
E5D68D3C2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c-ghc9.6.3.a */,
|
||||
E5D68D3A2C22D78C00CBA347 /* libHSsimplex-chat-5.8.1.0-GEbUSGuGADZH0bnStuks0c.a */,
|
||||
E52FF8D72C34676700BF81EB /* libffi.a */,
|
||||
E52FF8D82C34676700BF81EB /* libgmp.a */,
|
||||
E52FF8D62C34676600BF81EB /* libgmpxx.a */,
|
||||
E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */,
|
||||
E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -651,7 +656,6 @@
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
|
||||
5C6BA666289BD954009B8ECC /* DismissSheets.swift */,
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */,
|
||||
5CA7DFC229302AF000F7FDDE /* AppSheet.swift */,
|
||||
18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */,
|
||||
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */,
|
||||
@@ -660,6 +664,7 @@
|
||||
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */,
|
||||
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */,
|
||||
8C05382D2B39887E006436DC /* VideoUtils.swift */,
|
||||
CE81DF902C355E3C001C0A02 /* TranslateSheet.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -802,6 +807,7 @@
|
||||
5C13730A28156D2700F43030 /* ContactConnectionView.swift */,
|
||||
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */,
|
||||
18415835CBD939A9ABDC108A /* UserPicker.swift */,
|
||||
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */,
|
||||
);
|
||||
path = ChatList;
|
||||
sourceTree = "<group>";
|
||||
@@ -1134,9 +1140,11 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */,
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
|
||||
CE81DF912C355E3C001C0A02 /* TranslateSheet.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
|
||||
@@ -1176,7 +1184,6 @@
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
|
||||
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */,
|
||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
|
||||
5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */,
|
||||
@@ -1256,6 +1263,7 @@
|
||||
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
|
||||
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
|
||||
8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */,
|
||||
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */,
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
|
||||
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
|
||||
@@ -1552,7 +1560,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1577,7 +1585,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES_THIN;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1601,7 +1609,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1626,7 +1634,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1687,7 +1695,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
@@ -1702,7 +1710,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1724,7 +1732,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
@@ -1739,7 +1747,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1761,7 +1769,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1787,7 +1795,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1812,7 +1820,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 225;
|
||||
CURRENT_PROJECT_VERSION = 226;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1838,7 +1846,7 @@
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 5.8.1;
|
||||
MARKETING_VERSION = 5.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public let jsonDecoder = getJSONDecoder()
|
||||
let jsonEncoder = getJSONEncoder()
|
||||
public let jsonEncoder = getJSONEncoder()
|
||||
|
||||
public enum ChatCommand {
|
||||
case showActiveUser
|
||||
@@ -78,6 +78,7 @@ public enum ChatCommand {
|
||||
case apiGetNetworkConfig
|
||||
case apiSetNetworkInfo(networkInfo: UserNetworkInfo)
|
||||
case reconnectAllServers
|
||||
case reconnectServer(userId: Int64, smpServer: String)
|
||||
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
|
||||
case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
|
||||
case apiContactInfo(contactId: Int64)
|
||||
@@ -122,6 +123,7 @@ public enum ChatCommand {
|
||||
case apiEndCall(contact: Contact)
|
||||
case apiGetCallInvitations
|
||||
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
|
||||
// WebRTC calls /
|
||||
case apiGetNetworkStatuses
|
||||
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
|
||||
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
|
||||
@@ -142,6 +144,8 @@ public enum ChatCommand {
|
||||
case apiStandaloneFileInfo(url: String)
|
||||
// misc
|
||||
case showVersion
|
||||
case getAgentServersSummary(userId: Int64)
|
||||
case resetAgentServersStats
|
||||
case string(String)
|
||||
|
||||
public var cmdString: String {
|
||||
@@ -226,6 +230,7 @@ public enum ChatCommand {
|
||||
case .apiGetNetworkConfig: return "/network"
|
||||
case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
|
||||
case .reconnectAllServers: return "/reconnect"
|
||||
case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)"
|
||||
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
|
||||
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
|
||||
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
|
||||
@@ -301,6 +306,8 @@ public enum ChatCommand {
|
||||
case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
|
||||
case let .apiStandaloneFileInfo(link): return "/_download info \(link)"
|
||||
case .showVersion: return "/version"
|
||||
case let .getAgentServersSummary(userId): return "/get servers summary \(userId)"
|
||||
case .resetAgentServersStats: return "/reset servers stats"
|
||||
case let .string(str): return str
|
||||
}
|
||||
}
|
||||
@@ -375,6 +382,7 @@ public enum ChatCommand {
|
||||
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
|
||||
case .apiSetNetworkInfo: return "apiSetNetworkInfo"
|
||||
case .reconnectAllServers: return "reconnectAllServers"
|
||||
case .reconnectServer: return "reconnectServer"
|
||||
case .apiSetChatSettings: return "apiSetChatSettings"
|
||||
case .apiSetMemberSettings: return "apiSetMemberSettings"
|
||||
case .apiContactInfo: return "apiContactInfo"
|
||||
@@ -435,6 +443,8 @@ public enum ChatCommand {
|
||||
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
|
||||
case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
|
||||
case .showVersion: return "showVersion"
|
||||
case .getAgentServersSummary: return "getAgentServersSummary"
|
||||
case .resetAgentServersStats: return "resetAgentServersStats"
|
||||
case .string: return "console command"
|
||||
}
|
||||
}
|
||||
@@ -663,6 +673,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
// misc
|
||||
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
|
||||
case cmdOk(user: UserRef?)
|
||||
case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary)
|
||||
case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs)
|
||||
case chatCmdError(user_: UserRef?, chatError: ChatError)
|
||||
case chatError(user_: UserRef?, chatError: ChatError)
|
||||
case archiveImported(archiveErrors: [ArchiveError])
|
||||
@@ -821,6 +833,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .contactPQEnabled: return "contactPQEnabled"
|
||||
case .versionInfo: return "versionInfo"
|
||||
case .cmdOk: return "cmdOk"
|
||||
case .agentServersSummary: return "agentServersSummary"
|
||||
case .agentSubsSummary: return "agentSubsSummary"
|
||||
case .chatCmdError: return "chatCmdError"
|
||||
case .chatError: return "chatError"
|
||||
case .archiveImported: return "archiveImported"
|
||||
@@ -984,6 +998,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
|
||||
case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))"
|
||||
case .cmdOk: return noDetails
|
||||
case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary))
|
||||
case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary))
|
||||
case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .chatError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
|
||||
@@ -1010,20 +1026,20 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectionPlan: Decodable {
|
||||
public enum ConnectionPlan: Decodable, Hashable {
|
||||
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
|
||||
case contactAddress(contactAddressPlan: ContactAddressPlan)
|
||||
case groupLink(groupLinkPlan: GroupLinkPlan)
|
||||
}
|
||||
|
||||
public enum InvitationLinkPlan: Decodable {
|
||||
public enum InvitationLinkPlan: Decodable, Hashable {
|
||||
case ok
|
||||
case ownLink
|
||||
case connecting(contact_: Contact?)
|
||||
case known(contact: Contact)
|
||||
}
|
||||
|
||||
public enum ContactAddressPlan: Decodable {
|
||||
public enum ContactAddressPlan: Decodable, Hashable {
|
||||
case ok
|
||||
case ownLink
|
||||
case connectingConfirmReconnect
|
||||
@@ -1032,7 +1048,7 @@ public enum ContactAddressPlan: Decodable {
|
||||
case contactViaAddress(contact: Contact)
|
||||
}
|
||||
|
||||
public enum GroupLinkPlan: Decodable {
|
||||
public enum GroupLinkPlan: Decodable, Hashable {
|
||||
case ok
|
||||
case ownLink(groupInfo: GroupInfo)
|
||||
case connectingConfirmReconnect
|
||||
@@ -1040,13 +1056,13 @@ public enum GroupLinkPlan: Decodable {
|
||||
case known(groupInfo: GroupInfo)
|
||||
}
|
||||
|
||||
struct NewUser: Encodable {
|
||||
struct NewUser: Encodable, Hashable {
|
||||
var profile: Profile?
|
||||
var sameServers: Bool
|
||||
var pastTimestamp: Bool
|
||||
}
|
||||
|
||||
public enum ChatPagination {
|
||||
public enum ChatPagination: Hashable {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
@@ -1109,13 +1125,13 @@ public struct ServerCfg: Identifiable, Equatable, Codable {
|
||||
public var server: String
|
||||
public var preset: Bool
|
||||
public var tested: Bool?
|
||||
public var enabled: Bool
|
||||
public var enabled: ServerEnabled
|
||||
var createdAt = Date()
|
||||
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
|
||||
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||
// In any case, "trusted/known" servers are out of scope of this change
|
||||
|
||||
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
|
||||
public init(server: String, preset: Bool, tested: Bool?, enabled: ServerEnabled) {
|
||||
self.server = server
|
||||
self.preset = preset
|
||||
self.tested = tested
|
||||
@@ -1128,7 +1144,7 @@ public struct ServerCfg: Identifiable, Equatable, Codable {
|
||||
|
||||
public var id: String { "\(server) \(createdAt)" }
|
||||
|
||||
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: true)
|
||||
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: .enabled)
|
||||
|
||||
public var isEmpty: Bool {
|
||||
server.trimmingCharacters(in: .whitespaces) == ""
|
||||
@@ -1145,19 +1161,19 @@ public struct ServerCfg: Identifiable, Equatable, Codable {
|
||||
server: "smp://abcd@smp8.simplex.im",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true
|
||||
enabled: .enabled
|
||||
),
|
||||
custom: ServerCfg(
|
||||
server: "smp://abcd@smp9.simplex.im",
|
||||
preset: false,
|
||||
tested: false,
|
||||
enabled: false
|
||||
enabled: .disabled
|
||||
),
|
||||
untested: ServerCfg(
|
||||
server: "smp://abcd@smp10.simplex.im",
|
||||
preset: false,
|
||||
tested: nil,
|
||||
enabled: true
|
||||
enabled: .enabled
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1169,6 +1185,12 @@ public struct ServerCfg: Identifiable, Equatable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ServerEnabled: String, Codable {
|
||||
case disabled
|
||||
case enabled
|
||||
case known
|
||||
}
|
||||
|
||||
public enum ProtocolTestStep: String, Decodable, Equatable {
|
||||
case connect
|
||||
case disconnect
|
||||
@@ -1262,7 +1284,7 @@ public struct ServerAddress: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct NetCfg: Codable, Equatable {
|
||||
public struct NetCfg: Codable, Equatable, Hashable {
|
||||
public var socksProxy: String? = nil
|
||||
var socksMode: SocksMode = .always
|
||||
public var hostMode: HostMode = .publicHost
|
||||
@@ -1308,18 +1330,18 @@ public struct NetCfg: Codable, Equatable {
|
||||
public var enableKeepAlive: Bool { tcpKeepAlive != nil }
|
||||
}
|
||||
|
||||
public enum HostMode: String, Codable {
|
||||
public enum HostMode: String, Codable, Hashable {
|
||||
case onionViaSocks
|
||||
case onionHost = "onion"
|
||||
case publicHost = "public"
|
||||
}
|
||||
|
||||
public enum SocksMode: String, Codable {
|
||||
public enum SocksMode: String, Codable, Hashable {
|
||||
case always = "always"
|
||||
case onion = "onion"
|
||||
}
|
||||
|
||||
public enum SMPProxyMode: String, Codable {
|
||||
public enum SMPProxyMode: String, Codable, Hashable {
|
||||
case always = "always"
|
||||
case unknown = "unknown"
|
||||
case unprotected = "unprotected"
|
||||
@@ -1339,7 +1361,7 @@ public enum SMPProxyMode: String, Codable {
|
||||
public static let values: [SMPProxyMode] = [.always, .unknown, .unprotected, .never]
|
||||
}
|
||||
|
||||
public enum SMPProxyFallback: String, Codable {
|
||||
public enum SMPProxyFallback: String, Codable, Hashable {
|
||||
case allow = "allow"
|
||||
case allowProtected = "allowProtected"
|
||||
case prohibit = "prohibit"
|
||||
@@ -1357,7 +1379,7 @@ public enum SMPProxyFallback: String, Codable {
|
||||
public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit]
|
||||
}
|
||||
|
||||
public enum OnionHosts: String, Identifiable {
|
||||
public enum OnionHosts: String, Identifiable, Hashable {
|
||||
case no
|
||||
case prefer
|
||||
case require
|
||||
@@ -1391,7 +1413,7 @@ public enum OnionHosts: String, Identifiable {
|
||||
public static let values: [OnionHosts] = [.no, .prefer, .require]
|
||||
}
|
||||
|
||||
public enum TransportSessionMode: String, Codable, Identifiable {
|
||||
public enum TransportSessionMode: String, Codable, Identifiable, Hashable {
|
||||
case user
|
||||
case entity
|
||||
|
||||
@@ -1407,7 +1429,7 @@ public enum TransportSessionMode: String, Codable, Identifiable {
|
||||
public static let values: [TransportSessionMode] = [.user, .entity]
|
||||
}
|
||||
|
||||
public struct KeepAliveOpts: Codable, Equatable {
|
||||
public struct KeepAliveOpts: Codable, Equatable, Hashable {
|
||||
public var keepIdle: Int // seconds
|
||||
public var keepIntvl: Int // seconds
|
||||
public var keepCnt: Int // times
|
||||
@@ -1415,7 +1437,7 @@ public struct KeepAliveOpts: Codable, Equatable {
|
||||
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
|
||||
}
|
||||
|
||||
public enum NetworkStatus: Decodable, Equatable {
|
||||
public enum NetworkStatus: Decodable, Equatable, Hashable {
|
||||
case unknown
|
||||
case connected
|
||||
case disconnected
|
||||
@@ -1453,12 +1475,12 @@ public enum NetworkStatus: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnNetworkStatus: Decodable {
|
||||
public struct ConnNetworkStatus: Decodable, Hashable {
|
||||
public var agentConnId: String
|
||||
public var networkStatus: NetworkStatus
|
||||
}
|
||||
|
||||
public struct ChatSettings: Codable {
|
||||
public struct ChatSettings: Codable, Hashable {
|
||||
public var enableNtfs: MsgFilter
|
||||
public var sendRcpts: Bool?
|
||||
public var favorite: Bool
|
||||
@@ -1472,13 +1494,13 @@ public struct ChatSettings: Codable {
|
||||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
|
||||
}
|
||||
|
||||
public enum MsgFilter: String, Codable {
|
||||
public enum MsgFilter: String, Codable, Hashable {
|
||||
case none
|
||||
case all
|
||||
case mentions
|
||||
}
|
||||
|
||||
public struct UserMsgReceiptSettings: Codable {
|
||||
public struct UserMsgReceiptSettings: Codable, Hashable {
|
||||
public var enable: Bool
|
||||
public var clearOverrides: Bool
|
||||
|
||||
@@ -1488,7 +1510,7 @@ public struct UserMsgReceiptSettings: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionStats: Decodable {
|
||||
public struct ConnectionStats: Decodable, Hashable {
|
||||
public var connAgentVersion: Int
|
||||
public var rcvQueuesInfo: [RcvQueueInfo]
|
||||
public var sndQueuesInfo: [SndQueueInfo]
|
||||
@@ -1504,30 +1526,30 @@ public struct ConnectionStats: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RcvQueueInfo: Codable {
|
||||
public struct RcvQueueInfo: Codable, Hashable {
|
||||
public var rcvServer: String
|
||||
public var rcvSwitchStatus: RcvSwitchStatus?
|
||||
public var canAbortSwitch: Bool
|
||||
}
|
||||
|
||||
public enum RcvSwitchStatus: String, Codable {
|
||||
public enum RcvSwitchStatus: String, Codable, Hashable {
|
||||
case switchStarted = "switch_started"
|
||||
case sendingQADD = "sending_qadd"
|
||||
case sendingQUSE = "sending_quse"
|
||||
case receivedMessage = "received_message"
|
||||
}
|
||||
|
||||
public struct SndQueueInfo: Codable {
|
||||
public struct SndQueueInfo: Codable, Hashable {
|
||||
public var sndServer: String
|
||||
public var sndSwitchStatus: SndSwitchStatus?
|
||||
}
|
||||
|
||||
public enum SndSwitchStatus: String, Codable {
|
||||
public enum SndSwitchStatus: String, Codable, Hashable {
|
||||
case sendingQKEY = "sending_qkey"
|
||||
case sendingQTEST = "sending_qtest"
|
||||
}
|
||||
|
||||
public enum QueueDirection: String, Decodable {
|
||||
public enum QueueDirection: String, Decodable, Hashable {
|
||||
case rcv
|
||||
case snd
|
||||
}
|
||||
@@ -1551,7 +1573,7 @@ public enum RatchetSyncState: String, Decodable {
|
||||
case agreed
|
||||
}
|
||||
|
||||
public struct UserContactLink: Decodable {
|
||||
public struct UserContactLink: Decodable, Hashable {
|
||||
public var connReqContact: String
|
||||
public var autoAccept: AutoAccept?
|
||||
|
||||
@@ -1565,7 +1587,7 @@ public struct UserContactLink: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AutoAccept: Codable {
|
||||
public struct AutoAccept: Codable, Hashable {
|
||||
public var acceptIncognito: Bool
|
||||
public var autoReply: MsgContent?
|
||||
|
||||
@@ -1587,7 +1609,7 @@ public protocol SelectableItem: Hashable, Identifiable {
|
||||
static var values: [Self] { get }
|
||||
}
|
||||
|
||||
public struct DeviceToken: Decodable {
|
||||
public struct DeviceToken: Decodable, Hashable {
|
||||
var pushProvider: PushProvider
|
||||
var token: String
|
||||
|
||||
@@ -1601,12 +1623,12 @@ public struct DeviceToken: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum PushEnvironment: String {
|
||||
public enum PushEnvironment: String, Hashable {
|
||||
case development
|
||||
case production
|
||||
}
|
||||
|
||||
public enum PushProvider: String, Decodable {
|
||||
public enum PushProvider: String, Decodable, Hashable {
|
||||
case apns_dev
|
||||
case apns_prod
|
||||
|
||||
@@ -1620,7 +1642,7 @@ public enum PushProvider: String, Decodable {
|
||||
|
||||
// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable,
|
||||
// and .local for periodic background checks
|
||||
public enum NotificationsMode: String, Decodable, SelectableItem {
|
||||
public enum NotificationsMode: String, Decodable, SelectableItem, Hashable {
|
||||
case off = "OFF"
|
||||
case periodic = "PERIODIC"
|
||||
case instant = "INSTANT"
|
||||
@@ -1638,7 +1660,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
|
||||
public static var values: [NotificationsMode] = [.instant, .periodic, .off]
|
||||
}
|
||||
|
||||
public enum NotificationPreviewMode: String, SelectableItem, Codable {
|
||||
public enum NotificationPreviewMode: String, SelectableItem, Codable, Hashable {
|
||||
case hidden
|
||||
case contact
|
||||
case message
|
||||
@@ -1656,7 +1678,7 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable {
|
||||
public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden]
|
||||
}
|
||||
|
||||
public struct RemoteCtrlInfo: Decodable {
|
||||
public struct RemoteCtrlInfo: Decodable, Hashable {
|
||||
public var remoteCtrlId: Int64
|
||||
public var ctrlDeviceName: String
|
||||
public var sessionState: RemoteCtrlSessionState?
|
||||
@@ -1666,7 +1688,7 @@ public struct RemoteCtrlInfo: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum RemoteCtrlSessionState: Decodable {
|
||||
public enum RemoteCtrlSessionState: Decodable, Hashable {
|
||||
case starting
|
||||
case searching
|
||||
case connecting
|
||||
@@ -1681,17 +1703,17 @@ public enum RemoteCtrlStopReason: Decodable {
|
||||
case disconnected
|
||||
}
|
||||
|
||||
public struct CtrlAppInfo: Decodable {
|
||||
public struct CtrlAppInfo: Decodable, Hashable {
|
||||
public var appVersionRange: AppVersionRange
|
||||
public var deviceName: String
|
||||
}
|
||||
|
||||
public struct AppVersionRange: Decodable {
|
||||
public struct AppVersionRange: Decodable, Hashable {
|
||||
public var minVersion: String
|
||||
public var maxVersion: String
|
||||
}
|
||||
|
||||
public struct CoreVersionInfo: Decodable {
|
||||
public struct CoreVersionInfo: Decodable, Hashable {
|
||||
public var version: String
|
||||
public var simplexmqVersion: String
|
||||
public var simplexmqCommit: String
|
||||
@@ -1713,7 +1735,7 @@ private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
|
||||
encodeJSON(value).cString(using: .utf8)!
|
||||
}
|
||||
|
||||
public enum ChatError: Decodable {
|
||||
public enum ChatError: Decodable, Hashable {
|
||||
case error(errorType: ChatErrorType)
|
||||
case errorAgent(agentError: AgentErrorType)
|
||||
case errorStore(storeError: StoreError)
|
||||
@@ -1722,7 +1744,7 @@ public enum ChatError: Decodable {
|
||||
case invalidJSON(json: String)
|
||||
}
|
||||
|
||||
public enum ChatErrorType: Decodable {
|
||||
public enum ChatErrorType: Decodable, Hashable {
|
||||
case noActiveUser
|
||||
case noConnectionUser(agentConnId: String)
|
||||
case noSndFileUser(agentSndFileId: String)
|
||||
@@ -1801,7 +1823,7 @@ public enum ChatErrorType: Decodable {
|
||||
case exception(message: String)
|
||||
}
|
||||
|
||||
public enum StoreError: Decodable {
|
||||
public enum StoreError: Decodable, Hashable {
|
||||
case duplicateName
|
||||
case userNotFound(userId: Int64)
|
||||
case userNotFoundByName(contactName: ContactName)
|
||||
@@ -1861,7 +1883,7 @@ public enum StoreError: Decodable {
|
||||
case noGroupSndStatus(itemId: Int64, groupMemberId: Int64)
|
||||
}
|
||||
|
||||
public enum DatabaseError: Decodable {
|
||||
public enum DatabaseError: Decodable, Hashable {
|
||||
case errorEncrypted
|
||||
case errorPlaintext
|
||||
case errorNoFile(dbFile: String)
|
||||
@@ -1869,12 +1891,12 @@ public enum DatabaseError: Decodable {
|
||||
case errorOpen(sqliteError: SQLiteError)
|
||||
}
|
||||
|
||||
public enum SQLiteError: Decodable {
|
||||
public enum SQLiteError: Decodable, Hashable {
|
||||
case errorNotADatabase
|
||||
case error(String)
|
||||
}
|
||||
|
||||
public enum AgentErrorType: Decodable {
|
||||
public enum AgentErrorType: Decodable, Hashable {
|
||||
case CMD(cmdErr: CommandErrorType)
|
||||
case CONN(connErr: ConnectionErrorType)
|
||||
case SMP(smpErr: ProtocolErrorType)
|
||||
@@ -1888,7 +1910,7 @@ public enum AgentErrorType: Decodable {
|
||||
case INACTIVE
|
||||
}
|
||||
|
||||
public enum CommandErrorType: Decodable {
|
||||
public enum CommandErrorType: Decodable, Hashable {
|
||||
case PROHIBITED
|
||||
case SYNTAX
|
||||
case NO_CONN
|
||||
@@ -1896,7 +1918,7 @@ public enum CommandErrorType: Decodable {
|
||||
case LARGE
|
||||
}
|
||||
|
||||
public enum ConnectionErrorType: Decodable {
|
||||
public enum ConnectionErrorType: Decodable, Hashable {
|
||||
case NOT_FOUND
|
||||
case DUPLICATE
|
||||
case SIMPLEX
|
||||
@@ -1904,7 +1926,7 @@ public enum ConnectionErrorType: Decodable {
|
||||
case NOT_AVAILABLE
|
||||
}
|
||||
|
||||
public enum BrokerErrorType: Decodable {
|
||||
public enum BrokerErrorType: Decodable, Hashable {
|
||||
case RESPONSE(smpErr: String)
|
||||
case UNEXPECTED
|
||||
case NETWORK
|
||||
@@ -1913,7 +1935,7 @@ public enum BrokerErrorType: Decodable {
|
||||
case TIMEOUT
|
||||
}
|
||||
|
||||
public enum ProtocolErrorType: Decodable {
|
||||
public enum ProtocolErrorType: Decodable, Hashable {
|
||||
case BLOCK
|
||||
case SESSION
|
||||
case CMD(cmdErr: ProtocolCommandError)
|
||||
@@ -1924,7 +1946,7 @@ public enum ProtocolErrorType: Decodable {
|
||||
case INTERNAL
|
||||
}
|
||||
|
||||
public enum XFTPErrorType: Decodable {
|
||||
public enum XFTPErrorType: Decodable, Hashable {
|
||||
case BLOCK
|
||||
case SESSION
|
||||
case CMD(cmdErr: ProtocolCommandError)
|
||||
@@ -1941,7 +1963,7 @@ public enum XFTPErrorType: Decodable {
|
||||
case INTERNAL
|
||||
}
|
||||
|
||||
public enum RCErrorType: Decodable {
|
||||
public enum RCErrorType: Decodable, Hashable {
|
||||
case `internal`(internalErr: String)
|
||||
case identity
|
||||
case noLocalAddress
|
||||
@@ -1959,7 +1981,7 @@ public enum RCErrorType: Decodable {
|
||||
case syntax(syntaxErr: String)
|
||||
}
|
||||
|
||||
public enum ProtocolCommandError: Decodable {
|
||||
public enum ProtocolCommandError: Decodable, Hashable {
|
||||
case UNKNOWN
|
||||
case SYNTAX
|
||||
case PROHIBITED
|
||||
@@ -1968,7 +1990,7 @@ public enum ProtocolCommandError: Decodable {
|
||||
case NO_ENTITY
|
||||
}
|
||||
|
||||
public enum ProtocolTransportError: Decodable {
|
||||
public enum ProtocolTransportError: Decodable, Hashable {
|
||||
case badBlock
|
||||
case largeMsg
|
||||
case badSession
|
||||
@@ -1976,14 +1998,14 @@ public enum ProtocolTransportError: Decodable {
|
||||
case handshake(handshakeErr: SMPHandshakeError)
|
||||
}
|
||||
|
||||
public enum SMPHandshakeError: Decodable {
|
||||
public enum SMPHandshakeError: Decodable, Hashable {
|
||||
case PARSE
|
||||
case VERSION
|
||||
case IDENTITY
|
||||
case BAD_AUTH
|
||||
}
|
||||
|
||||
public enum SMPAgentError: Decodable {
|
||||
public enum SMPAgentError: Decodable, Hashable {
|
||||
case A_MESSAGE
|
||||
case A_PROHIBITED
|
||||
case A_VERSION
|
||||
@@ -1992,12 +2014,12 @@ public enum SMPAgentError: Decodable {
|
||||
case A_QUEUE(queueErr: String)
|
||||
}
|
||||
|
||||
public enum ArchiveError: Decodable {
|
||||
public enum ArchiveError: Decodable, Hashable {
|
||||
case `import`(chatError: ChatError)
|
||||
case importFile(file: String, chatError: ChatError)
|
||||
}
|
||||
|
||||
public enum RemoteCtrlError: Decodable {
|
||||
public enum RemoteCtrlError: Decodable, Hashable {
|
||||
case inactive
|
||||
case badState
|
||||
case busy
|
||||
@@ -2011,14 +2033,14 @@ public enum RemoteCtrlError: Decodable {
|
||||
case protocolError
|
||||
}
|
||||
|
||||
public struct MigrationFileLinkData: Codable {
|
||||
public struct MigrationFileLinkData: Codable, Hashable {
|
||||
let networkConfig: NetworkConfig?
|
||||
|
||||
public init(networkConfig: NetworkConfig) {
|
||||
self.networkConfig = networkConfig
|
||||
}
|
||||
|
||||
public struct NetworkConfig: Codable {
|
||||
public struct NetworkConfig: Codable, Hashable {
|
||||
let socksProxy: String?
|
||||
let hostMode: HostMode?
|
||||
let requiredHostMode: Bool?
|
||||
@@ -2050,7 +2072,7 @@ public struct MigrationFileLinkData: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppSettings: Codable, Equatable {
|
||||
public struct AppSettings: Codable, Equatable, Hashable {
|
||||
public var networkConfig: NetCfg? = nil
|
||||
public var privacyEncryptLocalFiles: Bool? = nil
|
||||
public var privacyAskToApproveRelays: Bool? = nil
|
||||
@@ -2124,7 +2146,7 @@ public struct AppSettings: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppSettingsNotificationMode: String, Codable {
|
||||
public enum AppSettingsNotificationMode: String, Codable, Hashable {
|
||||
case off
|
||||
case periodic
|
||||
case instant
|
||||
@@ -2152,13 +2174,13 @@ public enum AppSettingsNotificationMode: String, Codable {
|
||||
// case message
|
||||
//}
|
||||
|
||||
public enum AppSettingsLockScreenCalls: String, Codable {
|
||||
public enum AppSettingsLockScreenCalls: String, Codable, Hashable {
|
||||
case disable
|
||||
case show
|
||||
case accept
|
||||
}
|
||||
|
||||
public struct UserNetworkInfo: Codable, Equatable {
|
||||
public struct UserNetworkInfo: Codable, Equatable, Hashable {
|
||||
public let networkType: UserNetworkType
|
||||
public let online: Bool
|
||||
|
||||
@@ -2168,7 +2190,7 @@ public struct UserNetworkInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum UserNetworkType: String, Codable {
|
||||
public enum UserNetworkType: String, Codable, Hashable {
|
||||
case none
|
||||
case cellular
|
||||
case wifi
|
||||
@@ -2186,7 +2208,7 @@ public enum UserNetworkType: String, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RcvMsgInfo: Codable {
|
||||
public struct RcvMsgInfo: Codable, Hashable {
|
||||
var msgId: Int64
|
||||
var msgDeliveryId: Int64
|
||||
var msgDeliveryStatus: String
|
||||
@@ -2194,7 +2216,7 @@ public struct RcvMsgInfo: Codable {
|
||||
var agentMsgMeta: String
|
||||
}
|
||||
|
||||
public struct QueueInfo: Codable {
|
||||
public struct QueueInfo: Codable, Hashable {
|
||||
var qiSnd: Bool
|
||||
var qiNtf: Bool
|
||||
var qiSub: QSub?
|
||||
@@ -2202,25 +2224,165 @@ public struct QueueInfo: Codable {
|
||||
var qiMsg: MsgInfo?
|
||||
}
|
||||
|
||||
public struct QSub: Codable {
|
||||
public struct QSub: Codable, Hashable {
|
||||
var qSubThread: QSubThread
|
||||
var qDelivered: String?
|
||||
}
|
||||
|
||||
public enum QSubThread: String, Codable {
|
||||
public enum QSubThread: String, Codable, Hashable {
|
||||
case noSub
|
||||
case subPending
|
||||
case subThread
|
||||
case prohibitSub
|
||||
}
|
||||
|
||||
public struct MsgInfo: Codable {
|
||||
public struct MsgInfo: Codable, Hashable {
|
||||
var msgId: String
|
||||
var msgTs: Date
|
||||
var msgType: MsgType
|
||||
}
|
||||
|
||||
public enum MsgType: String, Codable {
|
||||
public enum MsgType: String, Codable, Hashable {
|
||||
case message
|
||||
case quota
|
||||
}
|
||||
|
||||
public struct PresentedServersSummary: Codable {
|
||||
public var statsStartedAt: Date
|
||||
public var allUsersSMP: SMPServersSummary
|
||||
public var allUsersXFTP: XFTPServersSummary
|
||||
public var currentUserSMP: SMPServersSummary
|
||||
public var currentUserXFTP: XFTPServersSummary
|
||||
}
|
||||
|
||||
public struct SMPServersSummary: Codable {
|
||||
public var smpTotals: SMPTotals
|
||||
public var currentlyUsedSMPServers: [SMPServerSummary]
|
||||
public var previouslyUsedSMPServers: [SMPServerSummary]
|
||||
public var onlyProxiedSMPServers: [SMPServerSummary]
|
||||
}
|
||||
|
||||
public struct SMPTotals: Codable {
|
||||
public var sessions: ServerSessions
|
||||
public var subs: SMPServerSubs
|
||||
public var stats: AgentSMPServerStatsData
|
||||
}
|
||||
|
||||
public struct SMPServerSummary: Codable, Identifiable {
|
||||
public var smpServer: String
|
||||
public var known: Bool?
|
||||
public var sessions: ServerSessions?
|
||||
public var subs: SMPServerSubs?
|
||||
public var stats: AgentSMPServerStatsData?
|
||||
|
||||
public var id: String { smpServer }
|
||||
|
||||
public var hasSubs: Bool { subs != nil }
|
||||
|
||||
public var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions }
|
||||
|
||||
public var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs }
|
||||
}
|
||||
|
||||
public struct ServerSessions: Codable {
|
||||
public var ssConnected: Int
|
||||
public var ssErrors: Int
|
||||
public var ssConnecting: Int
|
||||
|
||||
static public var newServerSessions = ServerSessions(
|
||||
ssConnected: 0,
|
||||
ssErrors: 0,
|
||||
ssConnecting: 0
|
||||
)
|
||||
}
|
||||
|
||||
public struct SMPServerSubs: Codable {
|
||||
public var ssActive: Int
|
||||
public var ssPending: Int
|
||||
|
||||
public init(ssActive: Int, ssPending: Int) {
|
||||
self.ssActive = ssActive
|
||||
self.ssPending = ssPending
|
||||
}
|
||||
|
||||
static public var newSMPServerSubs = SMPServerSubs(
|
||||
ssActive: 0,
|
||||
ssPending: 0
|
||||
)
|
||||
|
||||
public var total: Int { ssActive + ssPending }
|
||||
|
||||
public var shareOfActive: Double {
|
||||
guard total != 0 else { return 0.0 }
|
||||
return Double(ssActive) / Double(total)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentSMPServerStatsData: Codable {
|
||||
public var _sentDirect: Int
|
||||
public var _sentViaProxy: Int
|
||||
public var _sentProxied: Int
|
||||
public var _sentDirectAttempts: Int
|
||||
public var _sentViaProxyAttempts: Int
|
||||
public var _sentProxiedAttempts: Int
|
||||
public var _sentAuthErrs: Int
|
||||
public var _sentQuotaErrs: Int
|
||||
public var _sentExpiredErrs: Int
|
||||
public var _sentOtherErrs: Int
|
||||
public var _recvMsgs: Int
|
||||
public var _recvDuplicates: Int
|
||||
public var _recvCryptoErrs: Int
|
||||
public var _recvErrs: Int
|
||||
public var _ackMsgs: Int
|
||||
public var _ackAttempts: Int
|
||||
public var _ackNoMsgErrs: Int
|
||||
public var _ackOtherErrs: Int
|
||||
public var _connCreated: Int
|
||||
public var _connSecured: Int
|
||||
public var _connCompleted: Int
|
||||
public var _connDeleted: Int
|
||||
public var _connDelAttempts: Int
|
||||
public var _connDelErrs: Int
|
||||
public var _connSubscribed: Int
|
||||
public var _connSubAttempts: Int
|
||||
public var _connSubIgnored: Int
|
||||
public var _connSubErrs: Int
|
||||
}
|
||||
|
||||
public struct XFTPServersSummary: Codable {
|
||||
public var xftpTotals: XFTPTotals
|
||||
public var currentlyUsedXFTPServers: [XFTPServerSummary]
|
||||
public var previouslyUsedXFTPServers: [XFTPServerSummary]
|
||||
}
|
||||
|
||||
public struct XFTPTotals: Codable {
|
||||
public var sessions: ServerSessions
|
||||
public var stats: AgentXFTPServerStatsData
|
||||
}
|
||||
|
||||
public struct XFTPServerSummary: Codable, Identifiable {
|
||||
public var xftpServer: String
|
||||
public var known: Bool?
|
||||
public var sessions: ServerSessions?
|
||||
public var stats: AgentXFTPServerStatsData?
|
||||
public var rcvInProgress: Bool
|
||||
public var sndInProgress: Bool
|
||||
public var delInProgress: Bool
|
||||
|
||||
public var id: String { xftpServer }
|
||||
}
|
||||
|
||||
public struct AgentXFTPServerStatsData: Codable {
|
||||
public var _uploads: Int
|
||||
public var _uploadsSize: Int64
|
||||
public var _uploadAttempts: Int
|
||||
public var _uploadErrs: Int
|
||||
public var _downloads: Int
|
||||
public var _downloadsSize: Int64
|
||||
public var _downloadAttempts: Int
|
||||
public var _downloadAuthErrs: Int
|
||||
public var _downloadErrs: Int
|
||||
public var _deletions: Int
|
||||
public var _deleteAttempts: Int
|
||||
public var _deleteErrs: Int
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct User: Identifiable, Decodable, UserLike, NamedChat {
|
||||
public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
|
||||
public var userId: Int64
|
||||
public var agentUserId: String
|
||||
var userContactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
public var profile: LocalProfile
|
||||
@@ -41,6 +42,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat {
|
||||
|
||||
public static let sampleData = User(
|
||||
userId: 1,
|
||||
agentUserId: "abc",
|
||||
userContactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: LocalProfile.sampleData,
|
||||
@@ -52,7 +54,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct UserRef: Identifiable, Decodable, UserLike {
|
||||
public struct UserRef: Identifiable, Decodable, UserLike, Hashable {
|
||||
public var userId: Int64
|
||||
public var localDisplayName: ContactName
|
||||
|
||||
@@ -63,12 +65,12 @@ public protocol UserLike: Identifiable {
|
||||
var userId: Int64 { get }
|
||||
}
|
||||
|
||||
public struct UserPwdHash: Decodable {
|
||||
public struct UserPwdHash: Decodable, Hashable {
|
||||
public var hash: String
|
||||
public var salt: String
|
||||
}
|
||||
|
||||
public struct UserInfo: Decodable, Identifiable {
|
||||
public struct UserInfo: Decodable, Identifiable, Hashable {
|
||||
public var user: User
|
||||
public var unreadCount: Int
|
||||
|
||||
@@ -89,7 +91,7 @@ public typealias ContactName = String
|
||||
|
||||
public typealias GroupName = String
|
||||
|
||||
public struct Profile: Codable, NamedChat {
|
||||
public struct Profile: Codable, NamedChat, Hashable {
|
||||
public init(
|
||||
displayName: String,
|
||||
fullName: String,
|
||||
@@ -121,7 +123,7 @@ public struct Profile: Codable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct LocalProfile: Codable, NamedChat {
|
||||
public struct LocalProfile: Codable, NamedChat, Hashable {
|
||||
public init(
|
||||
profileId: Int64,
|
||||
displayName: String,
|
||||
@@ -171,13 +173,13 @@ public func fromLocalProfile (_ profile: LocalProfile) -> Profile {
|
||||
Profile(displayName: profile.displayName, fullName: profile.fullName, image: profile.image, contactLink: profile.contactLink, preferences: profile.preferences)
|
||||
}
|
||||
|
||||
public struct UserProfileUpdateSummary: Decodable {
|
||||
public struct UserProfileUpdateSummary: Decodable, Hashable {
|
||||
public var updateSuccesses: Int
|
||||
public var updateFailures: Int
|
||||
public var changedContacts: [Contact]
|
||||
}
|
||||
|
||||
public enum ChatType: String {
|
||||
public enum ChatType: String, Hashable {
|
||||
case direct = "@"
|
||||
case group = "#"
|
||||
case local = "*"
|
||||
@@ -202,7 +204,7 @@ extension NamedChat {
|
||||
|
||||
public typealias ChatId = String
|
||||
|
||||
public struct FullPreferences: Decodable, Equatable {
|
||||
public struct FullPreferences: Decodable, Equatable, Hashable {
|
||||
public var timedMessages: TimedMessagesPreference
|
||||
public var fullDelete: SimplePreference
|
||||
public var reactions: SimplePreference
|
||||
@@ -232,7 +234,7 @@ public struct FullPreferences: Decodable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct Preferences: Codable {
|
||||
public struct Preferences: Codable, Hashable {
|
||||
public var timedMessages: TimedMessagesPreference?
|
||||
public var fullDelete: SimplePreference?
|
||||
public var reactions: SimplePreference?
|
||||
@@ -308,11 +310,11 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac
|
||||
)
|
||||
}
|
||||
|
||||
public protocol Preference: Codable, Equatable {
|
||||
public protocol Preference: Codable, Equatable, Hashable {
|
||||
var allow: FeatureAllowed { get set }
|
||||
}
|
||||
|
||||
public struct SimplePreference: Preference {
|
||||
public struct SimplePreference: Preference, Hashable {
|
||||
public var allow: FeatureAllowed
|
||||
|
||||
public init(allow: FeatureAllowed) {
|
||||
@@ -320,7 +322,7 @@ public struct SimplePreference: Preference {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TimedMessagesPreference: Preference {
|
||||
public struct TimedMessagesPreference: Preference, Hashable {
|
||||
public var allow: FeatureAllowed
|
||||
public var ttl: Int?
|
||||
|
||||
@@ -334,7 +336,7 @@ public struct TimedMessagesPreference: Preference {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CustomTimeUnit {
|
||||
public enum CustomTimeUnit: Hashable {
|
||||
case second
|
||||
case minute
|
||||
case hour
|
||||
@@ -433,7 +435,7 @@ public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey {
|
||||
return CustomTimeUnit.toShortText(seconds: seconds)
|
||||
}
|
||||
|
||||
public struct ContactUserPreferences: Decodable {
|
||||
public struct ContactUserPreferences: Decodable, Hashable {
|
||||
public var timedMessages: ContactUserPreference<TimedMessagesPreference>
|
||||
public var fullDelete: ContactUserPreference<SimplePreference>
|
||||
public var reactions: ContactUserPreference<SimplePreference>
|
||||
@@ -483,7 +485,7 @@ public struct ContactUserPreferences: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct ContactUserPreference<P: Preference>: Decodable {
|
||||
public struct ContactUserPreference<P: Preference>: Decodable, Hashable {
|
||||
public var enabled: FeatureEnabled
|
||||
public var userPreference: ContactUserPref<P>
|
||||
public var contactPreference: P
|
||||
@@ -495,7 +497,7 @@ public struct ContactUserPreference<P: Preference>: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct FeatureEnabled: Decodable {
|
||||
public struct FeatureEnabled: Decodable, Hashable {
|
||||
public var forUser: Bool
|
||||
public var forContact: Bool
|
||||
|
||||
@@ -526,7 +528,7 @@ public struct FeatureEnabled: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ContactUserPref<P: Preference>: Decodable {
|
||||
public enum ContactUserPref<P: Preference>: Decodable, Hashable {
|
||||
case contact(preference: P) // contact override is set
|
||||
case user(preference: P) // global user default is used
|
||||
|
||||
@@ -547,7 +549,7 @@ public protocol Feature {
|
||||
var text: String { get }
|
||||
}
|
||||
|
||||
public enum ChatFeature: String, Decodable, Feature {
|
||||
public enum ChatFeature: String, Decodable, Feature, Hashable {
|
||||
case timedMessages
|
||||
case fullDelete
|
||||
case reactions
|
||||
@@ -690,7 +692,7 @@ public enum ChatFeature: String, Decodable, Feature {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GroupFeature: String, Decodable, Feature {
|
||||
public enum GroupFeature: String, Decodable, Feature, Hashable {
|
||||
case timedMessages
|
||||
case directMessages
|
||||
case fullDelete
|
||||
@@ -890,7 +892,7 @@ public enum ContactFeatureAllowed: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ContactFeaturesAllowed: Equatable {
|
||||
public struct ContactFeaturesAllowed: Equatable, Hashable {
|
||||
public var timedMessagesAllowed: Bool
|
||||
public var timedMessagesTTL: Int?
|
||||
public var fullDelete: ContactFeatureAllowed
|
||||
@@ -968,7 +970,7 @@ public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureA
|
||||
}
|
||||
}
|
||||
|
||||
public enum FeatureAllowed: String, Codable, Identifiable {
|
||||
public enum FeatureAllowed: String, Codable, Identifiable, Hashable {
|
||||
case always
|
||||
case yes
|
||||
case no
|
||||
@@ -986,7 +988,7 @@ public enum FeatureAllowed: String, Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct FullGroupPreferences: Decodable, Equatable {
|
||||
public struct FullGroupPreferences: Decodable, Equatable, Hashable {
|
||||
public var timedMessages: TimedMessagesGroupPreference
|
||||
public var directMessages: RoleGroupPreference
|
||||
public var fullDelete: GroupPreference
|
||||
@@ -1028,7 +1030,7 @@ public struct FullGroupPreferences: Decodable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupPreferences: Codable {
|
||||
public struct GroupPreferences: Codable, Hashable {
|
||||
public var timedMessages: TimedMessagesGroupPreference?
|
||||
public var directMessages: RoleGroupPreference?
|
||||
public var fullDelete: GroupPreference?
|
||||
@@ -1083,7 +1085,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupPreference: Codable, Equatable {
|
||||
public struct GroupPreference: Codable, Equatable, Hashable {
|
||||
public var enable: GroupFeatureEnabled
|
||||
|
||||
public var on: Bool {
|
||||
@@ -1107,7 +1109,7 @@ public struct GroupPreference: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RoleGroupPreference: Codable, Equatable {
|
||||
public struct RoleGroupPreference: Codable, Equatable, Hashable {
|
||||
public var enable: GroupFeatureEnabled
|
||||
public var role: GroupMemberRole?
|
||||
|
||||
@@ -1121,7 +1123,7 @@ public struct RoleGroupPreference: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TimedMessagesGroupPreference: Codable, Equatable {
|
||||
public struct TimedMessagesGroupPreference: Codable, Equatable, Hashable {
|
||||
public var enable: GroupFeatureEnabled
|
||||
public var ttl: Int?
|
||||
|
||||
@@ -1135,7 +1137,7 @@ public struct TimedMessagesGroupPreference: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GroupFeatureEnabled: String, Codable, Identifiable {
|
||||
public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable {
|
||||
case on
|
||||
case off
|
||||
|
||||
@@ -1158,7 +1160,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
case direct(contact: Contact)
|
||||
case group(groupInfo: GroupInfo)
|
||||
case local(noteFolder: NoteFolder)
|
||||
@@ -1370,7 +1372,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShowEnableVoiceMessagesAlert {
|
||||
public enum ShowEnableVoiceMessagesAlert: Hashable {
|
||||
case userEnable
|
||||
case askContact
|
||||
case groupOwnerCan
|
||||
@@ -1443,7 +1445,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SampleData {
|
||||
public struct SampleData: Hashable {
|
||||
public var direct: ChatInfo
|
||||
public var group: ChatInfo
|
||||
public var local: ChatInfo
|
||||
@@ -1460,7 +1462,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct ChatData: Decodable, Identifiable {
|
||||
public struct ChatData: Decodable, Identifiable, Hashable {
|
||||
public var chatInfo: ChatInfo
|
||||
public var chatItems: [ChatItem]
|
||||
public var chatStats: ChatStats
|
||||
@@ -1476,7 +1478,7 @@ public struct ChatData: Decodable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatStats: Decodable {
|
||||
public struct ChatStats: Decodable, Hashable {
|
||||
public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
|
||||
self.unreadCount = unreadCount
|
||||
self.minUnreadItemId = minUnreadItemId
|
||||
@@ -1488,7 +1490,7 @@ public struct ChatStats: Decodable {
|
||||
public var unreadChat: Bool = false
|
||||
}
|
||||
|
||||
public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
public var contactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
public var profile: LocalProfile
|
||||
@@ -1574,12 +1576,12 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public enum ContactStatus: String, Decodable {
|
||||
public enum ContactStatus: String, Decodable, Hashable {
|
||||
case active = "active"
|
||||
case deleted = "deleted"
|
||||
}
|
||||
|
||||
public struct ContactRef: Decodable, Equatable {
|
||||
public struct ContactRef: Decodable, Equatable, Hashable {
|
||||
var contactId: Int64
|
||||
public var agentConnId: String
|
||||
var connId: Int64
|
||||
@@ -1588,12 +1590,12 @@ public struct ContactRef: Decodable, Equatable {
|
||||
public var id: ChatId { get { "@\(contactId)" } }
|
||||
}
|
||||
|
||||
public struct ContactSubStatus: Decodable {
|
||||
public struct ContactSubStatus: Decodable, Hashable {
|
||||
public var contact: Contact
|
||||
public var contactError: ChatError?
|
||||
}
|
||||
|
||||
public struct Connection: Decodable {
|
||||
public struct Connection: Decodable, Hashable {
|
||||
public var connId: Int64
|
||||
public var agentConnId: String
|
||||
public var peerChatVRange: VersionRange
|
||||
@@ -1637,7 +1639,7 @@ public struct Connection: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct VersionRange: Decodable {
|
||||
public struct VersionRange: Decodable, Hashable {
|
||||
public init(minVersion: Int, maxVersion: Int) {
|
||||
self.minVersion = minVersion
|
||||
self.maxVersion = maxVersion
|
||||
@@ -1651,7 +1653,7 @@ public struct VersionRange: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecurityCode: Decodable, Equatable {
|
||||
public struct SecurityCode: Decodable, Equatable, Hashable {
|
||||
public init(securityCode: String, verifiedAt: Date) {
|
||||
self.securityCode = securityCode
|
||||
self.verifiedAt = verifiedAt
|
||||
@@ -1661,7 +1663,7 @@ public struct SecurityCode: Decodable, Equatable {
|
||||
public var verifiedAt: Date
|
||||
}
|
||||
|
||||
public struct UserContact: Decodable {
|
||||
public struct UserContact: Decodable, Hashable {
|
||||
public var userContactLinkId: Int64
|
||||
// public var connReqContact: String
|
||||
public var groupId: Int64?
|
||||
@@ -1679,7 +1681,7 @@ public struct UserContact: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserContactRequest: Decodable, NamedChat {
|
||||
public struct UserContactRequest: Decodable, NamedChat, Hashable {
|
||||
var contactRequestId: Int64
|
||||
public var userContactLinkId: Int64
|
||||
public var cReqChatVRange: VersionRange
|
||||
@@ -1708,7 +1710,7 @@ public struct UserContactRequest: Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct PendingContactConnection: Decodable, NamedChat {
|
||||
public struct PendingContactConnection: Decodable, NamedChat, Hashable {
|
||||
public var pccConnId: Int64
|
||||
var pccAgentConnId: String
|
||||
var pccConnStatus: ConnStatus
|
||||
@@ -1798,7 +1800,7 @@ public struct PendingContactConnection: Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnStatus: String, Decodable {
|
||||
public enum ConnStatus: String, Decodable, Hashable {
|
||||
case new = "new"
|
||||
case joined = "joined"
|
||||
case requested = "requested"
|
||||
@@ -1822,7 +1824,7 @@ public enum ConnStatus: String, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Group: Decodable {
|
||||
public struct Group: Decodable, Hashable {
|
||||
public var groupInfo: GroupInfo
|
||||
public var members: [GroupMember]
|
||||
|
||||
@@ -1832,7 +1834,7 @@ public struct Group: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
public var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
public var groupProfile: GroupProfile
|
||||
@@ -1878,12 +1880,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupRef: Decodable {
|
||||
public struct GroupRef: Decodable, Hashable {
|
||||
public var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
}
|
||||
|
||||
public struct GroupProfile: Codable, NamedChat {
|
||||
public struct GroupProfile: Codable, NamedChat, Hashable {
|
||||
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
|
||||
self.displayName = displayName
|
||||
self.fullName = fullName
|
||||
@@ -1905,7 +1907,7 @@ public struct GroupProfile: Codable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupMember: Identifiable, Decodable {
|
||||
public struct GroupMember: Identifiable, Decodable, Hashable {
|
||||
public var groupMemberId: Int64
|
||||
public var groupId: Int64
|
||||
public var memberId: String
|
||||
@@ -2037,21 +2039,21 @@ public struct GroupMember: Identifiable, Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupMemberSettings: Codable {
|
||||
public struct GroupMemberSettings: Codable, Hashable {
|
||||
public var showMessages: Bool
|
||||
}
|
||||
|
||||
public struct GroupMemberRef: Decodable {
|
||||
public struct GroupMemberRef: Decodable, Hashable {
|
||||
var groupMemberId: Int64
|
||||
var profile: Profile
|
||||
}
|
||||
|
||||
public struct GroupMemberIds: Decodable {
|
||||
public struct GroupMemberIds: Decodable, Hashable {
|
||||
var groupMemberId: Int64
|
||||
var groupId: Int64
|
||||
}
|
||||
|
||||
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable {
|
||||
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable {
|
||||
case observer = "observer"
|
||||
case author = "author"
|
||||
case member = "member"
|
||||
@@ -2085,7 +2087,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod
|
||||
}
|
||||
}
|
||||
|
||||
public enum GroupMemberCategory: String, Decodable {
|
||||
public enum GroupMemberCategory: String, Decodable, Hashable {
|
||||
case userMember = "user"
|
||||
case inviteeMember = "invitee"
|
||||
case hostMember = "host"
|
||||
@@ -2093,7 +2095,7 @@ public enum GroupMemberCategory: String, Decodable {
|
||||
case postMember = "post"
|
||||
}
|
||||
|
||||
public enum GroupMemberStatus: String, Decodable {
|
||||
public enum GroupMemberStatus: String, Decodable, Hashable {
|
||||
case memRemoved = "removed"
|
||||
case memLeft = "left"
|
||||
case memGroupDeleted = "deleted"
|
||||
@@ -2142,7 +2144,7 @@ public enum GroupMemberStatus: String, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NoteFolder: Identifiable, Decodable, NamedChat {
|
||||
public struct NoteFolder: Identifiable, Decodable, NamedChat, Hashable {
|
||||
public var noteFolderId: Int64
|
||||
public var favorite: Bool
|
||||
public var unread: Bool
|
||||
@@ -2175,18 +2177,18 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public enum InvitedBy: Decodable {
|
||||
public enum InvitedBy: Decodable, Hashable {
|
||||
case contact(byContactId: Int64)
|
||||
case user
|
||||
case unknown
|
||||
}
|
||||
|
||||
public struct MemberSubError: Decodable {
|
||||
public struct MemberSubError: Decodable, Hashable {
|
||||
var member: GroupMemberIds
|
||||
var memberError: ChatError
|
||||
}
|
||||
|
||||
public enum ConnectionEntity: Decodable {
|
||||
public enum ConnectionEntity: Decodable, Hashable {
|
||||
case rcvDirectMsgConnection(contact: Contact?)
|
||||
case rcvGroupMsgConnection(groupInfo: GroupInfo, groupMember: GroupMember)
|
||||
case sndFileConnection(sndFileTransfer: SndFileTransfer)
|
||||
@@ -2217,12 +2219,12 @@ public enum ConnectionEntity: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NtfMsgInfo: Decodable {
|
||||
public struct NtfMsgInfo: Decodable, Hashable {
|
||||
public var msgId: String
|
||||
public var msgTs: Date
|
||||
}
|
||||
|
||||
public struct AChatItem: Decodable {
|
||||
public struct AChatItem: Decodable, Hashable {
|
||||
public var chatInfo: ChatInfo
|
||||
public var chatItem: ChatItem
|
||||
|
||||
@@ -2234,19 +2236,19 @@ public struct AChatItem: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ACIReaction: Decodable {
|
||||
public struct ACIReaction: Decodable, Hashable {
|
||||
public var chatInfo: ChatInfo
|
||||
public var chatReaction: CIReaction
|
||||
}
|
||||
|
||||
public struct CIReaction: Decodable {
|
||||
public struct CIReaction: Decodable, Hashable {
|
||||
public var chatDir: CIDirection
|
||||
public var chatItem: ChatItem
|
||||
public var sentAt: Date
|
||||
public var reaction: MsgReaction
|
||||
}
|
||||
|
||||
public struct ChatItem: Identifiable, Decodable {
|
||||
public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
|
||||
self.chatDir = chatDir
|
||||
self.meta = meta
|
||||
@@ -2596,7 +2598,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIMergeCategory {
|
||||
public enum CIMergeCategory: Hashable {
|
||||
case memberConnected
|
||||
case rcvGroupEvent
|
||||
case sndGroupEvent
|
||||
@@ -2605,7 +2607,7 @@ public enum CIMergeCategory {
|
||||
case chatFeature
|
||||
}
|
||||
|
||||
public enum CIDirection: Decodable {
|
||||
public enum CIDirection: Decodable, Hashable {
|
||||
case directSnd
|
||||
case directRcv
|
||||
case groupSnd
|
||||
@@ -2627,7 +2629,7 @@ public enum CIDirection: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIMeta: Decodable {
|
||||
public struct CIMeta: Decodable, Hashable {
|
||||
public var itemId: Int64
|
||||
public var itemTs: Date
|
||||
var itemText: String
|
||||
@@ -2690,7 +2692,7 @@ public struct CIMeta: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CITimed: Decodable {
|
||||
public struct CITimed: Decodable, Hashable {
|
||||
public var ttl: Int
|
||||
public var deleteAt: Date?
|
||||
}
|
||||
@@ -2717,7 +2719,7 @@ private func recent(_ date: Date) -> Bool {
|
||||
return isSameDay || (now < currentDay12 && date >= previousDay18 && date < currentDay00)
|
||||
}
|
||||
|
||||
public enum CIStatus: Decodable {
|
||||
public enum CIStatus: Decodable, Hashable {
|
||||
case sndNew
|
||||
case sndSent(sndProgress: SndCIStatusProgress)
|
||||
case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress)
|
||||
@@ -2787,7 +2789,7 @@ public enum CIStatus: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SndError: Decodable {
|
||||
public enum SndError: Decodable, Hashable {
|
||||
case auth
|
||||
case quota
|
||||
case expired
|
||||
@@ -2809,7 +2811,7 @@ public enum SndError: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SrvError: Decodable, Equatable {
|
||||
public enum SrvError: Decodable, Hashable {
|
||||
case host
|
||||
case version
|
||||
case other(srvError: String)
|
||||
@@ -2831,17 +2833,17 @@ public enum SrvError: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgReceiptStatus: String, Decodable {
|
||||
public enum MsgReceiptStatus: String, Decodable, Hashable {
|
||||
case ok
|
||||
case badMsgHash
|
||||
}
|
||||
|
||||
public enum SndCIStatusProgress: String, Decodable {
|
||||
public enum SndCIStatusProgress: String, Decodable, Hashable {
|
||||
case partial
|
||||
case complete
|
||||
}
|
||||
|
||||
public enum CIDeleted: Decodable {
|
||||
public enum CIDeleted: Decodable, Hashable {
|
||||
case deleted(deletedTs: Date?)
|
||||
case blocked(deletedTs: Date?)
|
||||
case blockedByAdmin(deletedTs: Date?)
|
||||
@@ -2857,12 +2859,12 @@ public enum CIDeleted: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgDirection: String, Decodable {
|
||||
public enum MsgDirection: String, Decodable, Hashable {
|
||||
case rcv = "rcv"
|
||||
case snd = "snd"
|
||||
}
|
||||
|
||||
public enum CIForwardedFrom: Decodable {
|
||||
public enum CIForwardedFrom: Decodable, Hashable {
|
||||
case unknown
|
||||
case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?)
|
||||
case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?)
|
||||
@@ -2882,7 +2884,7 @@ public enum CIForwardedFrom: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIDeleteMode: String, Decodable {
|
||||
public enum CIDeleteMode: String, Decodable, Hashable {
|
||||
case cidmBroadcast = "broadcast"
|
||||
case cidmInternal = "internal"
|
||||
}
|
||||
@@ -2891,7 +2893,7 @@ protocol ItemContent {
|
||||
var text: String { get }
|
||||
}
|
||||
|
||||
public enum CIContent: Decodable, ItemContent {
|
||||
public enum CIContent: Decodable, ItemContent, Hashable {
|
||||
case sndMsgContent(msgContent: MsgContent)
|
||||
case rcvMsgContent(msgContent: MsgContent)
|
||||
case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
|
||||
@@ -3027,7 +3029,7 @@ public enum CIContent: Decodable, ItemContent {
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgDecryptError: String, Decodable {
|
||||
public enum MsgDecryptError: String, Decodable, Hashable {
|
||||
case ratchetHeader
|
||||
case tooManySkipped
|
||||
case ratchetEarlier
|
||||
@@ -3045,7 +3047,7 @@ public enum MsgDecryptError: String, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIQuote: Decodable, ItemContent {
|
||||
public struct CIQuote: Decodable, ItemContent, Hashable {
|
||||
public var chatDir: CIDirection?
|
||||
public var itemId: Int64?
|
||||
var sharedMsgId: String? = nil
|
||||
@@ -3083,13 +3085,13 @@ public struct CIQuote: Decodable, ItemContent {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIReactionCount: Decodable {
|
||||
public struct CIReactionCount: Decodable, Hashable {
|
||||
public var reaction: MsgReaction
|
||||
public var userReacted: Bool
|
||||
public var totalReacted: Int
|
||||
}
|
||||
|
||||
public enum MsgReaction: Hashable {
|
||||
public enum MsgReaction: Hashable, Identifiable {
|
||||
case emoji(emoji: MREmojiChar)
|
||||
case unknown(type: String)
|
||||
|
||||
@@ -3110,9 +3112,16 @@ public enum MsgReaction: Hashable {
|
||||
case type
|
||||
case emoji
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case let .emoji(emoji): emoji.rawValue
|
||||
case let .unknown(unknown): unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MREmojiChar: String, Codable, CaseIterable {
|
||||
public enum MREmojiChar: String, Codable, CaseIterable, Hashable {
|
||||
case thumbsup = "👍"
|
||||
case thumbsdown = "👎"
|
||||
case smile = "😀"
|
||||
@@ -3153,7 +3162,7 @@ extension MsgReaction: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIFile: Decodable {
|
||||
public struct CIFile: Decodable, Hashable {
|
||||
public var fileId: Int64
|
||||
public var fileName: String
|
||||
public var fileSize: Int64
|
||||
@@ -3221,7 +3230,7 @@ public struct CIFile: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CryptoFile: Codable {
|
||||
public struct CryptoFile: Codable, Hashable {
|
||||
public var filePath: String // the name of the file, not a full path
|
||||
public var cryptoArgs: CryptoFileArgs?
|
||||
|
||||
@@ -3268,22 +3277,28 @@ public struct CryptoFile: Codable {
|
||||
static var decryptedUrls = Dictionary<String, URL>()
|
||||
}
|
||||
|
||||
public struct CryptoFileArgs: Codable {
|
||||
public struct CryptoFileArgs: Codable, Hashable {
|
||||
public var fileKey: String
|
||||
public var fileNonce: String
|
||||
}
|
||||
|
||||
public struct CancelAction {
|
||||
public struct CancelAction: Hashable {
|
||||
public var uiAction: String
|
||||
public var alert: AlertInfo
|
||||
}
|
||||
|
||||
public struct AlertInfo {
|
||||
public struct AlertInfo: Hashable {
|
||||
public var title: LocalizedStringKey
|
||||
public var message: LocalizedStringKey
|
||||
public var confirm: LocalizedStringKey
|
||||
}
|
||||
|
||||
extension LocalizedStringKey: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("\(self)")
|
||||
}
|
||||
}
|
||||
|
||||
private var sndCancelAction = CancelAction(
|
||||
uiAction: NSLocalizedString("Stop file", comment: "cancel file action"),
|
||||
alert: AlertInfo(
|
||||
@@ -3311,13 +3326,13 @@ private var rcvCancelAction = CancelAction(
|
||||
)
|
||||
)
|
||||
|
||||
public enum FileProtocol: String, Decodable {
|
||||
public enum FileProtocol: String, Decodable, Hashable {
|
||||
case smp = "smp"
|
||||
case xftp = "xftp"
|
||||
case local = "local"
|
||||
}
|
||||
|
||||
public enum CIFileStatus: Decodable, Equatable {
|
||||
public enum CIFileStatus: Decodable, Equatable, Hashable {
|
||||
case sndStored
|
||||
case sndTransfer(sndProgress: Int64, sndTotal: Int64)
|
||||
case sndComplete
|
||||
@@ -3355,7 +3370,7 @@ public enum CIFileStatus: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum FileError: Decodable, Equatable {
|
||||
public enum FileError: Decodable, Equatable, Hashable {
|
||||
case auth
|
||||
case noFile
|
||||
case relay(srvError: SrvError)
|
||||
@@ -3380,7 +3395,7 @@ public enum FileError: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgContent: Equatable {
|
||||
public enum MsgContent: Equatable, Hashable {
|
||||
case text(String)
|
||||
case link(text: String, preview: LinkPreview)
|
||||
case image(text: String, image: String)
|
||||
@@ -3547,7 +3562,7 @@ extension MsgContent: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct FormattedText: Decodable {
|
||||
public struct FormattedText: Decodable, Hashable {
|
||||
public var text: String
|
||||
public var format: Format?
|
||||
|
||||
@@ -3556,7 +3571,7 @@ public struct FormattedText: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum Format: Decodable, Equatable {
|
||||
public enum Format: Decodable, Equatable, Hashable {
|
||||
case bold
|
||||
case italic
|
||||
case strikeThrough
|
||||
@@ -3578,7 +3593,7 @@ public enum Format: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SimplexLinkType: String, Decodable {
|
||||
public enum SimplexLinkType: String, Decodable, Hashable {
|
||||
case contact
|
||||
case invitation
|
||||
case group
|
||||
@@ -3592,7 +3607,7 @@ public enum SimplexLinkType: String, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum FormatColor: String, Decodable {
|
||||
public enum FormatColor: String, Decodable, Hashable {
|
||||
case red = "red"
|
||||
case green = "green"
|
||||
case blue = "blue"
|
||||
@@ -3619,7 +3634,7 @@ public enum FormatColor: String, Decodable {
|
||||
}
|
||||
|
||||
// Struct to use with simplex API
|
||||
public struct LinkPreview: Codable, Equatable {
|
||||
public struct LinkPreview: Codable, Equatable, Hashable {
|
||||
public init(uri: URL, title: String, description: String = "", image: String) {
|
||||
self.uri = uri
|
||||
self.title = title
|
||||
@@ -3634,7 +3649,7 @@ public struct LinkPreview: Codable, Equatable {
|
||||
public var image: String
|
||||
}
|
||||
|
||||
public enum NtfTknStatus: String, Decodable {
|
||||
public enum NtfTknStatus: String, Decodable, Hashable {
|
||||
case new = "NEW"
|
||||
case registered = "REGISTERED"
|
||||
case invalid = "INVALID"
|
||||
@@ -3643,22 +3658,22 @@ public enum NtfTknStatus: String, Decodable {
|
||||
case expired = "EXPIRED"
|
||||
}
|
||||
|
||||
public struct SndFileTransfer: Decodable {
|
||||
public struct SndFileTransfer: Decodable, Hashable {
|
||||
|
||||
}
|
||||
|
||||
public struct RcvFileTransfer: Decodable {
|
||||
public struct RcvFileTransfer: Decodable, Hashable {
|
||||
public let fileId: Int64
|
||||
}
|
||||
|
||||
public struct FileTransferMeta: Decodable {
|
||||
public struct FileTransferMeta: Decodable, Hashable {
|
||||
public let fileId: Int64
|
||||
public let fileName: String
|
||||
public let filePath: String
|
||||
public let fileSize: Int64
|
||||
}
|
||||
|
||||
public enum CICallStatus: String, Decodable {
|
||||
public enum CICallStatus: String, Decodable, Hashable {
|
||||
case pending
|
||||
case missed
|
||||
case rejected
|
||||
@@ -3690,7 +3705,7 @@ public func durationText(_ sec: Int) -> String {
|
||||
: String(format: "%02d:%02d:%02d", m / 60, m % 60, s)
|
||||
}
|
||||
|
||||
public enum MsgErrorType: Decodable {
|
||||
public enum MsgErrorType: Decodable, Hashable {
|
||||
case msgSkipped(fromMsgId: Int64, toMsgId: Int64)
|
||||
case msgBadId(msgId: Int64)
|
||||
case msgBadHash
|
||||
@@ -3707,7 +3722,7 @@ public enum MsgErrorType: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIGroupInvitation: Decodable {
|
||||
public struct CIGroupInvitation: Decodable, Hashable {
|
||||
public var groupId: Int64
|
||||
public var groupMemberId: Int64
|
||||
public var localDisplayName: GroupName
|
||||
@@ -3723,18 +3738,18 @@ public struct CIGroupInvitation: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIGroupInvitationStatus: String, Decodable {
|
||||
public enum CIGroupInvitationStatus: String, Decodable, Hashable {
|
||||
case pending
|
||||
case accepted
|
||||
case rejected
|
||||
case expired
|
||||
}
|
||||
|
||||
public struct E2EEInfo: Decodable {
|
||||
public struct E2EEInfo: Decodable, Hashable {
|
||||
public var pqEnabled: Bool
|
||||
}
|
||||
|
||||
public enum RcvDirectEvent: Decodable {
|
||||
public enum RcvDirectEvent: Decodable, Hashable {
|
||||
case contactDeleted
|
||||
case profileUpdated(fromProfile: Profile, toProfile: Profile)
|
||||
|
||||
@@ -3763,7 +3778,7 @@ public enum RcvDirectEvent: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum RcvGroupEvent: Decodable {
|
||||
public enum RcvGroupEvent: Decodable, Hashable {
|
||||
case memberAdded(groupMemberId: Int64, profile: Profile)
|
||||
case memberConnected
|
||||
case memberLeft
|
||||
@@ -3819,7 +3834,7 @@ public enum RcvGroupEvent: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SndGroupEvent: Decodable {
|
||||
public enum SndGroupEvent: Decodable, Hashable {
|
||||
case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole)
|
||||
case userRole(role: GroupMemberRole)
|
||||
case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool)
|
||||
@@ -3847,7 +3862,7 @@ public enum SndGroupEvent: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum RcvConnEvent: Decodable {
|
||||
public enum RcvConnEvent: Decodable, Hashable {
|
||||
case switchQueue(phase: SwitchPhase)
|
||||
case ratchetSync(syncStatus: RatchetSyncState)
|
||||
case verificationCodeReset
|
||||
@@ -3884,7 +3899,7 @@ func ratchetSyncStatusToText(_ ratchetSyncStatus: RatchetSyncState) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SndConnEvent: Decodable {
|
||||
public enum SndConnEvent: Decodable, Hashable {
|
||||
case switchQueue(phase: SwitchPhase, member: GroupMemberRef?)
|
||||
case ratchetSync(syncStatus: RatchetSyncState, member: GroupMemberRef?)
|
||||
case pqEnabled(enabled: Bool)
|
||||
@@ -3921,14 +3936,14 @@ public enum SndConnEvent: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SwitchPhase: String, Decodable {
|
||||
public enum SwitchPhase: String, Decodable, Hashable {
|
||||
case started
|
||||
case confirmed
|
||||
case secured
|
||||
case completed
|
||||
}
|
||||
|
||||
public enum ChatItemTTL: Hashable, Identifiable, Comparable {
|
||||
public enum ChatItemTTL: Identifiable, Comparable, Hashable {
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
@@ -3978,13 +3993,13 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatItemInfo: Decodable {
|
||||
public struct ChatItemInfo: Decodable, Hashable {
|
||||
public var itemVersions: [ChatItemVersion]
|
||||
public var memberDeliveryStatuses: [MemberDeliveryStatus]?
|
||||
public var forwardedFromChatItem: AChatItem?
|
||||
}
|
||||
|
||||
public struct ChatItemVersion: Decodable {
|
||||
public struct ChatItemVersion: Decodable, Hashable {
|
||||
public var chatItemVersionId: Int64
|
||||
public var msgContent: MsgContent
|
||||
public var formattedText: [FormattedText]?
|
||||
@@ -3992,7 +4007,7 @@ public struct ChatItemVersion: Decodable {
|
||||
public var createdAt: Date
|
||||
}
|
||||
|
||||
public struct MemberDeliveryStatus: Decodable {
|
||||
public struct MemberDeliveryStatus: Decodable, Hashable {
|
||||
public var groupMemberId: Int64
|
||||
public var memberDeliveryStatus: CIStatus
|
||||
public var sentViaProxy: Bool?
|
||||
|
||||
@@ -179,6 +179,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
override fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean = NtfManager.notifyCallInvitation(invitation)
|
||||
override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId)
|
||||
override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId)
|
||||
override fun cancelNotificationsForUser(userId: Long) = NtfManager.cancelNotificationsForUser(userId)
|
||||
override fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first })
|
||||
override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
|
||||
override fun cancelCallNotification() = NtfManager.cancelCallNotification()
|
||||
|
||||
+20
-4
@@ -48,7 +48,8 @@ object NtfManager {
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private var prevNtfTime = mutableMapOf<String, Long>()
|
||||
// (UserId, ChatId) -> Time
|
||||
private var prevNtfTime = mutableMapOf<Pair<Long, ChatId>, Long>()
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
@@ -72,7 +73,8 @@ object NtfManager {
|
||||
}
|
||||
|
||||
fun cancelNotificationsForChat(chatId: String) {
|
||||
prevNtfTime.remove(chatId)
|
||||
val key = prevNtfTime.keys.firstOrNull { it.second == chatId }
|
||||
prevNtfTime.remove(key)
|
||||
manager.cancel(chatId.hashCode())
|
||||
val msgNtfs = manager.activeNotifications.filter { ntf ->
|
||||
ntf.notification.channelId == MessageChannel
|
||||
@@ -83,12 +85,26 @@ object NtfManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelNotificationsForUser(userId: Long) {
|
||||
prevNtfTime.keys.filter { it.first == userId }.forEach {
|
||||
prevNtfTime.remove(it)
|
||||
manager.cancel(it.second.hashCode())
|
||||
}
|
||||
val msgNtfs = manager.activeNotifications.filter { ntf ->
|
||||
ntf.notification.channelId == MessageChannel
|
||||
}
|
||||
if (msgNtfs.size <= 1) {
|
||||
// Have a group notification with no children so cancel it
|
||||
manager.cancel(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
if (!user.showNotifications) return
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[chatId] = now
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(user.userId to chatId, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[user.userId to chatId] = now
|
||||
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
|
||||
|
||||
@@ -52,6 +52,7 @@ object ChatModel {
|
||||
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
|
||||
val ctrlInitInProgress = mutableStateOf(false)
|
||||
val dbMigrationInProgress = mutableStateOf(false)
|
||||
val incompleteInitializedDbRemoved = mutableStateOf(false)
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
// map of connections network statuses, key is agent connection id
|
||||
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
|
||||
|
||||
+25
-5
@@ -176,6 +176,12 @@ class AppPreferences {
|
||||
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
|
||||
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
|
||||
|
||||
// This flag is set when database is first initialized and resets only when the database is removed.
|
||||
// This is needed for recover from incomplete initialization when only one database file is created.
|
||||
// If false - the app will clear database folder on missing file and re-initialize.
|
||||
// Note that this situation can only happen if passphrase for the first database is incorrect because, otherwise, backend will re-create second database automatically
|
||||
val newDatabaseInitialized = mkBoolPreference(SHARED_PREFS_NEW_DATABASE_INITIALIZED, false)
|
||||
|
||||
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM_THEME_NAME)
|
||||
val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.themeName)
|
||||
val currentThemeIds = mkMapPreference(SHARED_PREFS_CURRENT_THEME_IDs, mapOf(), encode = {
|
||||
@@ -361,6 +367,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase"
|
||||
private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
|
||||
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
|
||||
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
|
||||
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
|
||||
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
|
||||
@@ -491,11 +498,15 @@ object ChatController {
|
||||
}
|
||||
|
||||
suspend fun changeActiveUser_(rhId: Long?, toUserId: Long?, viewPwd: String?) {
|
||||
val prevActiveUser = chatModel.currentUser.value
|
||||
val currentUser = changingActiveUserMutex.withLock {
|
||||
(if (toUserId != null) apiSetActiveUser(rhId, toUserId, viewPwd) else apiGetActiveUser(rhId)).also {
|
||||
chatModel.currentUser.value = it
|
||||
}
|
||||
}
|
||||
if (prevActiveUser?.hidden == true) {
|
||||
ntfManager.cancelNotificationsForUser(prevActiveUser.userId)
|
||||
}
|
||||
val users = listUsers(rhId)
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
@@ -2347,6 +2358,8 @@ object ChatController {
|
||||
notify()
|
||||
} else if (chatModel.upsertChatItem(rh, cInfo, cItem)) {
|
||||
notify()
|
||||
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
|
||||
notify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3018,7 +3031,7 @@ data class ServerCfg(
|
||||
val server: String,
|
||||
val preset: Boolean,
|
||||
val tested: Boolean? = null,
|
||||
val enabled: Boolean
|
||||
val enabled: ServerEnabled
|
||||
) {
|
||||
@Transient
|
||||
private val createdAt: Date = Date()
|
||||
@@ -3032,7 +3045,7 @@ data class ServerCfg(
|
||||
get() = server.isBlank()
|
||||
|
||||
companion object {
|
||||
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = true)
|
||||
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = ServerEnabled.Enabled)
|
||||
|
||||
class SampleData(
|
||||
val preset: ServerCfg,
|
||||
@@ -3046,26 +3059,33 @@ data class ServerCfg(
|
||||
server = "smp://abcd@smp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true
|
||||
enabled = ServerEnabled.Enabled
|
||||
),
|
||||
custom = ServerCfg(
|
||||
remoteHostId = null,
|
||||
server = "smp://abcd@smp9.simplex.im",
|
||||
preset = false,
|
||||
tested = false,
|
||||
enabled = false
|
||||
enabled = ServerEnabled.Disabled
|
||||
),
|
||||
untested = ServerCfg(
|
||||
remoteHostId = null,
|
||||
server = "smp://abcd@smp10.simplex.im",
|
||||
preset = false,
|
||||
tested = null,
|
||||
enabled = true
|
||||
enabled = ServerEnabled.Enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ServerEnabled {
|
||||
@SerialName("disabled") Disabled,
|
||||
@SerialName("enabled") Enabled,
|
||||
@SerialName("known") Known;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ProtocolTestStep {
|
||||
@SerialName("connect") Connect,
|
||||
|
||||
@@ -88,8 +88,23 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
chatModel.chatDbStatus.value = res
|
||||
if (res != DBMigrationResult.OK) {
|
||||
Log.d(TAG, "Unable to migrate successfully: $res")
|
||||
if (!appPrefs.newDatabaseInitialized.get() && DatabaseUtils.hasOnlyOneDatabase(dataDir.absolutePath)) {
|
||||
if (chatModel.incompleteInitializedDbRemoved.value) {
|
||||
Log.d(TAG, "Incomplete initialized databases were removed but after repeated migration only one database exists again, not trying to remove again")
|
||||
} else {
|
||||
val dbPath = dbAbsolutePrefixPath
|
||||
File(dbPath + "_chat.db").delete()
|
||||
File(dbPath + "_agent.db").delete()
|
||||
chatModel.incompleteInitializedDbRemoved.value = true
|
||||
Log.d(TAG, "Incomplete initialized databases were removed for the first time, repeating migration")
|
||||
chatModel.ctrlInitInProgress.value = false
|
||||
initChatController(useKey, confirmMigrations, startChat)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
appPrefs.newDatabaseInitialized.set(true)
|
||||
chatModel.incompleteInitializedDbRemoved.value = false
|
||||
platform.androidRestartNetworkObserver()
|
||||
controller.apiSetAppFilePaths(
|
||||
appFilesDir.absolutePath,
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ abstract class NtfManager {
|
||||
abstract fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean
|
||||
abstract fun hasNotificationsForChat(chatId: String): Boolean
|
||||
abstract fun cancelNotificationsForChat(chatId: String)
|
||||
abstract fun cancelNotificationsForUser(userId: Long)
|
||||
abstract fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList())
|
||||
abstract fun cancelCallNotification()
|
||||
abstract fun cancelAllNotifications()
|
||||
|
||||
+36
-7
@@ -22,8 +22,11 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.AppVersionText
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
@@ -106,7 +109,7 @@ fun DatabaseErrorView(
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||
is MigrationError.Upgrade ->
|
||||
is MigrationError.Upgrade -> {
|
||||
DatabaseErrorDetails(MR.strings.database_upgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(MR.strings.upgrade_and_open_chat))
|
||||
@@ -116,7 +119,9 @@ fun DatabaseErrorView(
|
||||
MigrationsText(err.upMigrations.map { it.upName })
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Downgrade ->
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is MigrationError.Downgrade -> {
|
||||
DatabaseErrorDetails(MR.strings.database_downgrade) {
|
||||
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||
Text(generalGetString(MR.strings.downgrade_and_open_chat))
|
||||
@@ -127,29 +132,41 @@ fun DatabaseErrorView(
|
||||
MigrationsText(err.downMigrations)
|
||||
AppVersionText()
|
||||
}
|
||||
is MigrationError.Error ->
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is MigrationError.Error -> {
|
||||
DatabaseErrorDetails(MR.strings.incompatible_database_version) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(MR.strings.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||
}
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
}
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
is DBMigrationResult.ErrorSQL -> {
|
||||
DatabaseErrorDetails(MR.strings.database_error) {
|
||||
FileNameText(status.dbFile)
|
||||
Text(String.format(generalGetString(MR.strings.error_with_info), status.migrationSQLError))
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain ->
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is DBMigrationResult.ErrorKeychain -> {
|
||||
DatabaseErrorDetails(MR.strings.keychain_error) {
|
||||
Text(generalGetString(MR.strings.cannot_access_keychain))
|
||||
}
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is DBMigrationResult.InvalidConfirmation -> {
|
||||
DatabaseErrorDetails(MR.strings.invalid_migration_confirmation) {
|
||||
// this can only happen if incorrect parameter is passed
|
||||
}
|
||||
is DBMigrationResult.Unknown ->
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is DBMigrationResult.Unknown -> {
|
||||
DatabaseErrorDetails(MR.strings.database_error) {
|
||||
Text(String.format(generalGetString(MR.strings.unknown_database_error_with_info), status.json))
|
||||
}
|
||||
OpenDatabaseDirectoryButton()
|
||||
}
|
||||
is DBMigrationResult.OK -> {}
|
||||
null -> {}
|
||||
}
|
||||
@@ -294,6 +311,18 @@ private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpenDatabaseDirectoryButton() {
|
||||
if (appPlatform.isDesktop) {
|
||||
Spacer(Modifier.padding(top = DEFAULT_PADDING))
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_folder_open),
|
||||
stringResource(MR.strings.open_database_folder),
|
||||
::desktopOpenDatabaseDir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
|
||||
|
||||
+6
-1
@@ -18,6 +18,7 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import chat.simplex.common.ui.theme.*
|
||||
@@ -450,7 +451,9 @@ private fun stopChat(m: ChatModel, progressIndicator: MutableState<Boolean>? = n
|
||||
platform.androidChatStopped()
|
||||
// close chat view for desktop
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
onStop?.invoke()
|
||||
} catch (e: Error) {
|
||||
m.chatRunning.value = true
|
||||
@@ -492,6 +495,7 @@ fun deleteChatDatabaseFilesAndState() {
|
||||
wallpapersDir.deleteRecursively()
|
||||
wallpapersDir.mkdirs()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
appPrefs.newDatabaseInitialized.set(false)
|
||||
controller.appPrefs.storeDBPassphrase.set(true)
|
||||
controller.ctrl = null
|
||||
|
||||
@@ -500,6 +504,7 @@ fun deleteChatDatabaseFilesAndState() {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chats.clear()
|
||||
chatModel.users.clear()
|
||||
ntfManager.cancelAllNotifications()
|
||||
}
|
||||
|
||||
private fun exportArchive(
|
||||
|
||||
+7
-4
@@ -39,22 +39,25 @@ object DatabaseUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasDatabase(rootDir: String): Boolean =
|
||||
File(rootDir + File.separator + chatDatabaseFileName).exists() && File(rootDir + File.separator + agentDatabaseFileName).exists()
|
||||
private fun hasAtLeastOneDatabase(rootDir: String): Boolean =
|
||||
File(rootDir + File.separator + chatDatabaseFileName).exists() || File(rootDir + File.separator + agentDatabaseFileName).exists()
|
||||
|
||||
fun hasOnlyOneDatabase(rootDir: String): Boolean =
|
||||
File(rootDir + File.separator + chatDatabaseFileName).exists() != File(rootDir + File.separator + agentDatabaseFileName).exists()
|
||||
|
||||
fun useDatabaseKey(): String {
|
||||
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
||||
var dbKey = ""
|
||||
val useKeychain = appPreferences.storeDBPassphrase.get()
|
||||
if (useKeychain) {
|
||||
if (!hasDatabase(dataDir.absolutePath)) {
|
||||
if (!hasAtLeastOneDatabase(dataDir.absolutePath)) {
|
||||
dbKey = randomDatabasePassword()
|
||||
ksDatabasePassword.set(dbKey)
|
||||
appPreferences.initialRandomDBPassphrase.set(true)
|
||||
} else {
|
||||
dbKey = ksDatabasePassword.get() ?: ""
|
||||
}
|
||||
} else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) {
|
||||
} else if (appPlatform.isDesktop && !hasAtLeastOneDatabase(dataDir.absolutePath)) {
|
||||
// In case of database was deleted by hand
|
||||
dbKey = randomDatabasePassword()
|
||||
ksDatabasePassword.set(dbKey)
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.UserProfileRow
|
||||
import chat.simplex.common.views.database.PassphraseField
|
||||
@@ -36,6 +37,9 @@ fun HiddenProfileView(
|
||||
withBGApi {
|
||||
try {
|
||||
val u = m.controller.apiHideUser(user, hidePassword)
|
||||
if (!u.activeUser) {
|
||||
ntfManager.cancelNotificationsForUser(u.userId)
|
||||
}
|
||||
m.updateUser(u)
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
|
||||
+4
-2
@@ -175,8 +175,10 @@ private fun UseServerSection(
|
||||
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
ShowTestStatus(server)
|
||||
}
|
||||
val enabled = rememberUpdatedState(server.enabled)
|
||||
PreferenceToggle(stringResource(MR.strings.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
|
||||
val enabled = rememberUpdatedState(server.enabled == ServerEnabled.Enabled)
|
||||
PreferenceToggle(stringResource(MR.strings.smp_servers_use_server_for_new_conn), enabled.value) { enable ->
|
||||
onUpdate(server.copy(enabled = if (enable) ServerEnabled.Enabled else ServerEnabled.Disabled))
|
||||
}
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
|
||||
}
|
||||
|
||||
+6
-6
@@ -34,7 +34,7 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
|
||||
val currServers = remember(rhId) { mutableStateOf(servers) }
|
||||
val testing = rememberSaveable(rhId) { mutableStateOf(false) }
|
||||
val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } }
|
||||
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } }
|
||||
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled == ServerEnabled.Enabled } } }
|
||||
val saveDisabled = remember(servers) {
|
||||
derivedStateOf {
|
||||
servers.isEmpty() ||
|
||||
@@ -250,12 +250,12 @@ private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, s
|
||||
val address = parseServerAddress(srv.server)
|
||||
when {
|
||||
address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
|
||||
srv.enabled != ServerEnabled.Enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
|
||||
else -> ShowTestStatus(srv)
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
val text = address?.hostnames?.firstOrNull() ?: srv.server
|
||||
if (srv.enabled) {
|
||||
if (srv.enabled == ServerEnabled.Enabled) {
|
||||
Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1)
|
||||
} else {
|
||||
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
|
||||
@@ -292,7 +292,7 @@ private fun addAllPresets(rhId: Long?, presetServers: List<String>, servers: Lis
|
||||
val toAdd = ArrayList<ServerCfg>()
|
||||
for (srv in presetServers) {
|
||||
if (!hasPreset(srv, servers)) {
|
||||
toAdd.add(ServerCfg(remoteHostId = rhId, srv, preset = true, tested = null, enabled = true))
|
||||
toAdd.add(ServerCfg(remoteHostId = rhId, srv, preset = true, tested = null, enabled = ServerEnabled.Enabled))
|
||||
}
|
||||
}
|
||||
return toAdd
|
||||
@@ -319,7 +319,7 @@ private suspend fun testServers(testing: MutableState<Boolean>, servers: List<Se
|
||||
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
|
||||
val copy = ArrayList(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
if (server.enabled == ServerEnabled.Enabled) {
|
||||
copy.removeAt(index)
|
||||
copy.add(index, server.copy(tested = null))
|
||||
}
|
||||
@@ -331,7 +331,7 @@ private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpd
|
||||
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
|
||||
val updatedServers = ArrayList<ServerCfg>(servers)
|
||||
for ((index, server) in servers.withIndex()) {
|
||||
if (server.enabled) {
|
||||
if (server.enabled == ServerEnabled.Enabled) {
|
||||
interruptIfCancelled()
|
||||
val (updatedServer, f) = testServerConnection(server, m)
|
||||
updatedServers.removeAt(index)
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import chat.simplex.common.model.ServerEnabled
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
@@ -25,7 +26,7 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) {
|
||||
QRCodeScanner { text ->
|
||||
val res = parseServerAddress(text)
|
||||
if (res != null) {
|
||||
onNext(ServerCfg(remoteHostId = rhId, text, false, null, true))
|
||||
onNext(ServerCfg(remoteHostId = rhId, text, false, null, ServerEnabled.Enabled))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
||||
|
||||
+1
@@ -367,6 +367,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, de
|
||||
}
|
||||
}
|
||||
m.removeUser(user)
|
||||
ntfManager.cancelNotificationsForUser(user.userId)
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_deleting_user), e.stackTraceToString())
|
||||
}
|
||||
|
||||
+17
-6
@@ -16,7 +16,7 @@ import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
object NtfManager {
|
||||
private val prevNtfs = arrayListOf<Pair<ChatId, Slice>>()
|
||||
private val prevNtfs = arrayListOf<Pair<Pair<Long, ChatId>, Slice>>()
|
||||
private val prevNtfsMutex: Mutex = Mutex()
|
||||
|
||||
fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean {
|
||||
@@ -45,14 +45,14 @@ object NtfManager {
|
||||
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) {
|
||||
displayNotificationViaLib(invitation.user.userId, contactId, title, text, prepareIconPath(largeIcon), actions) {
|
||||
ntfManager.openChatAction(invitation.user.userId, contactId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun showMessage(title: String, text: String) {
|
||||
displayNotificationViaLib("MESSAGE", title, text, null, emptyList()) {}
|
||||
displayNotificationViaLib(-1, "MESSAGE", title, text, null, emptyList()) {}
|
||||
}
|
||||
|
||||
fun hasNotificationsForChat(chatId: ChatId) = false//prevNtfs.any { it.first == chatId }
|
||||
@@ -60,7 +60,7 @@ object NtfManager {
|
||||
fun cancelNotificationsForChat(chatId: ChatId) {
|
||||
withBGApi {
|
||||
prevNtfsMutex.withLock {
|
||||
val ntf = prevNtfs.firstOrNull { it.first == chatId }
|
||||
val ntf = prevNtfs.firstOrNull { (userChat) -> userChat.second == chatId }
|
||||
if (ntf != null) {
|
||||
prevNtfs.remove(ntf)
|
||||
/*try {
|
||||
@@ -74,6 +74,16 @@ object NtfManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelNotificationsForUser(userId: Long) {
|
||||
withBGApi {
|
||||
prevNtfsMutex.withLock {
|
||||
prevNtfs.filter { (userChat) -> userChat.first == userId }.forEach {
|
||||
prevNtfs.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAllNotifications() {
|
||||
// prevNtfs.forEach { try { it.second.close() } catch (e: Exception) { println("Failed to close notification: ${e.stackTraceToString()}") } }
|
||||
withBGApi {
|
||||
@@ -95,12 +105,13 @@ object NtfManager {
|
||||
else -> base64ToBitmap(image)
|
||||
}
|
||||
|
||||
displayNotificationViaLib(chatId, title, content, prepareIconPath(largeIcon), actions.map { it.first.name to it.second }) {
|
||||
displayNotificationViaLib(user.userId, chatId, title, content, prepareIconPath(largeIcon), actions.map { it.first.name to it.second }) {
|
||||
ntfManager.openChatAction(user.userId, chatId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayNotificationViaLib(
|
||||
userId: Long,
|
||||
chatId: String,
|
||||
title: String,
|
||||
text: String,
|
||||
@@ -123,7 +134,7 @@ object NtfManager {
|
||||
try {
|
||||
withBGApi {
|
||||
prevNtfsMutex.withLock {
|
||||
prevNtfs.add(chatId to builder.toast())
|
||||
prevNtfs.add(Pair(userId, chatId) to builder.toast())
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ fun initApp() {
|
||||
override fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean = 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 cancelNotificationsForUser(userId: Long) = chat.simplex.common.model.NtfManager.cancelNotificationsForUser(userId)
|
||||
override fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = chat.simplex.common.model.NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions)
|
||||
override fun androidCreateNtfChannelsMaybeShowAlert() {}
|
||||
override fun cancelCallNotification() {}
|
||||
|
||||
@@ -26,11 +26,11 @@ android.enableJetifier=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
kotlin.jvm.target=11
|
||||
|
||||
android.version_name=5.8.1
|
||||
android.version_code=221
|
||||
android.version_name=5.8.2
|
||||
android.version_code=223
|
||||
|
||||
desktop.version_name=5.8.1
|
||||
desktop.version_code=54
|
||||
desktop.version_name=5.8.2
|
||||
desktop.version_code=55
|
||||
|
||||
kotlin.version=1.9.23
|
||||
gradle.plugin.version=8.2.0
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 8a3b72458f917e9867f4e3640dda0fa1827ff6cf
|
||||
tag: ae8e1c5e9aa3155907f1bd075e9c69af5fce2bee
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
+25
-30
@@ -15,29 +15,32 @@ We want to add up to 3 people to the team.
|
||||
|
||||
## Who we are looking for
|
||||
|
||||
### Product/UI designer
|
||||
|
||||
You will be designing the user experience and the interface of both the app and the website in collaboration with the team.
|
||||
|
||||
The current focus of the app is privacy and security, but we hope to have the design that would support the feeling of psychological safety, enabling people to achieve the results in the smallest amount of time.
|
||||
|
||||
You are an experienced and innovative product designer with:
|
||||
- 8+ years of user experience and visual design.
|
||||
- Expertise in typography and high sensitivity to colors.
|
||||
- Exceptional precision and attention to details.
|
||||
- Strong opinions (weakly held).
|
||||
- A strong empathy.
|
||||
|
||||
### Application Haskell engineer
|
||||
|
||||
You will work with the Haskell core of the client applications and with the network servers.
|
||||
|
||||
You are an expert in language models, databases and Haskell:
|
||||
- expert knowledge of SQL.
|
||||
- exception handling, concurrency, STM.
|
||||
- type systems - we use ad hoc dependent types a lot.
|
||||
- experience integrating open-source language models.
|
||||
- experience developing community-centric applications.
|
||||
- interested to build the next generation of messaging network.
|
||||
|
||||
You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell.
|
||||
|
||||
### iOS / Mac engineer
|
||||
|
||||
You are an expert in Apple platforms, including:
|
||||
- iOS and Mac platform architecture.
|
||||
- Swift and Objective-C.
|
||||
- SwiftUI and UIKit.
|
||||
- extensions, including notification service extension and sharing extension.
|
||||
- low level inter-process communication primitives for concurrency.
|
||||
- interested about creating the next generation of UX for a communication/social network.
|
||||
|
||||
Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps.
|
||||
- Haskell exception handling, concurrency, STM, type systems.
|
||||
- 8y+ of software engineering experience in complex projects,
|
||||
- deep understanding of the common programming principles:
|
||||
- data structures, bits and bytes, text encoding.
|
||||
- software design and algorithms.
|
||||
- concurrency.
|
||||
- networking.
|
||||
|
||||
## About you
|
||||
|
||||
@@ -53,20 +56,10 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J
|
||||
- focus on solving only today's problems and resist engineering for the future (aka over-engineering) – see [The Duct Tape Programmer](https://www.joelonsoftware.com/2009/09/23/the-duct-tape-programmer/) and [Why I Hate Frameworks](https://medium.com/@johnfliu/why-i-hate-frameworks-6af8cbadba42).
|
||||
- do not suffer from "not invented here" syndrome, at the same time interested to design and implement protocols and systems from the ground up when appropriate.
|
||||
|
||||
- **Love software engineering**:
|
||||
- have 5y+ of software engineering experience in complex projects,
|
||||
- great understanding of the common principles:
|
||||
- data structures, bits and byte manipulation
|
||||
- text encoding and manipulation
|
||||
- software design and algorithms
|
||||
- concurrency
|
||||
- networking
|
||||
|
||||
- **Want to join a very early stage startup**:
|
||||
- high pace and intensity, longer hours.
|
||||
- a substantial part of the compensation is stock options.
|
||||
- full transparency – we believe that too much [autonomy](https://twitter.com/KentBeck/status/851459129830850561) hurts learning and slows down progress.
|
||||
|
||||
- full transparency - we believe that too much [autonomy](https://twitter.com/KentBeck/status/851459129830850561) hurts learning and slows down progress.
|
||||
|
||||
## How to join the team
|
||||
|
||||
@@ -75,3 +68,5 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J
|
||||
2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test.
|
||||
|
||||
3. [Connect to us](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FKBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) via SimpleX Chat to chat about what you want to contribute and about joining the team.
|
||||
|
||||
4. You can also email [jobs@simplex.chat](mailto:jobs@simplex.chat?subject=Join%20SimpleX%20Chat%20team)
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ Manual installation requires some preliminary actions:
|
||||
Group=xftp
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/xftp-server start +RTS -N -RTS
|
||||
ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex-xftp/file-server-store.log" ] && cp "/var/opt/simplex-xftp/file-server-store.log" "/var/opt/simplex-xftp/file-server-store.log.$(date +'%FT%T')"'
|
||||
ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex-xftp/file-server-store.log" ] && cp "/var/opt/simplex-xftp/file-server-store.log" "/var/opt/simplex-xftp/file-server-store.log.$(date +'%%FT%%T')"'
|
||||
LimitNOFILE=65535
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=infinity
|
||||
|
||||
@@ -4,9 +4,16 @@ sequenceDiagram
|
||||
participant B as Bob
|
||||
participant C as Existing<br>contact
|
||||
|
||||
note over A, B: 1. send and accept group invitation
|
||||
A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection)
|
||||
B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection
|
||||
alt invite contact
|
||||
note over A, B: 1a. send and accept group invitation
|
||||
A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection)
|
||||
B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection
|
||||
else join via group link
|
||||
note over A, B: 1b. join via group link and accept request
|
||||
B ->> A: join via group link<br>SimpleX contact address
|
||||
A ->> B: x.grp.link.inv in SMP confirmation<br>accept joining member request,<br>sending group profile, etc.<br>establish group member connection
|
||||
A ->> B: x.grp.link.mem<br>send inviting member profile
|
||||
end
|
||||
|
||||
note over M, B: 2. introduce new member Bob to all existing members
|
||||
A ->> M: x.grp.mem.new<br>"announce" Bob<br>to existing members<br>(via member connections)
|
||||
@@ -20,14 +27,25 @@ sequenceDiagram
|
||||
end
|
||||
A ->> M: x.grp.mem.fwd<br>forward "invitations" and<br>Bob's chat protocol version<br>to all members<br>(via member connections)
|
||||
|
||||
note over M, B: group message forwarding<br>(while connections between members are being established)
|
||||
M -->> B: messages between members and Bob are forwarded by Alice
|
||||
B -->> M:
|
||||
|
||||
note over M, B: 3. establish direct and group member connections
|
||||
M ->> B: establish group member connection
|
||||
|
||||
opt chat protocol compatible version < 2
|
||||
M ->> B: establish direct connection
|
||||
note over M, C: 4. deduplicate new contact
|
||||
note over M, C: 3*. deduplicate new contact
|
||||
B ->> M: x.info.probe<br>"probe" is sent to all new members
|
||||
B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match
|
||||
C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user
|
||||
note over B: merge existing and new contacts if received and sent probe hashes match
|
||||
end
|
||||
|
||||
note over M, B: 4. notify inviting member that connection is established
|
||||
M ->> A: x.grp.mem.con
|
||||
B ->> A: x.grp.mem.con
|
||||
note over A: stops forwarding messages
|
||||
M -->> B: messages are sent via group connection without forwarding
|
||||
B -->> M:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 41 KiB |
@@ -2,7 +2,7 @@
|
||||
title: SimpleX Chat Protocol
|
||||
revision: 08.08.2022
|
||||
---
|
||||
DRAFT Revision 0.1, 2022-08-08
|
||||
Revision 2, 2024-06-24
|
||||
|
||||
Evgeny Poberezkin
|
||||
|
||||
@@ -157,7 +157,7 @@ This message is sent by both sides of the connection during the connection hands
|
||||
|
||||
### Probing for duplicate contacts
|
||||
|
||||
As there are no globally unique user identitifiers, when the contact a user is already connected to is added to the group by some other group member, this contact will be added to user's list of contacts as a new contact. To allow merging such contacts, "a probe" (random base64url-encoded 32 bytes) SHOULD be sent to all new members as part of `x.info.probe` message and, in case there is a contact with the same profile, the hash of the probe MAY be sent to it as part of `x.info.probe.check` message. In case both the new member and the existing contact are the same user (they would receive both the probe and its hash), the contact would send back the original probe as part of `x.info.probe.ok` message via the previously existing contact connection – proving to the sender that this new member and the existing contact are the same user, in which case the sender SHOULD merge these two contacts.
|
||||
As there are no globally unique user identifiers, when the contact a user is already connected to is added to the group by some other group member, this contact will be added to user's list of contacts as a new contact. To allow merging such contacts, "a probe" (random base64url-encoded 32 bytes) SHOULD be sent to all new members as part of `x.info.probe` message and, in case there is a contact with the same profile, the hash of the probe MAY be sent to it as part of `x.info.probe.check` message. In case both the new member and the existing contact are the same user (they would receive both the probe and its hash), the contact would send back the original probe as part of `x.info.probe.ok` message via the previously existing contact connection – proving to the sender that this new member and the existing contact are the same user, in which case the sender SHOULD merge these two contacts.
|
||||
|
||||
Sending clients MAY disable this functionality, and receiving clients MAY ignore probe messages.
|
||||
|
||||
@@ -210,23 +210,23 @@ File attachment can optionally include connection address to receive the file -
|
||||
|
||||
### Decentralized design for chat groups
|
||||
|
||||
SimpleX Chat groups are fully decentralized and do not have any globally unique group identifiers - they are only defined on client devices as a group profile and a set of bi-directional SimpleX connections with other group members. When a new member accepts group invitation, the inviting member introduces a new member to all existing members and forwards the connection addresses so that they can establish direct and group member connections.
|
||||
SimpleX Chat groups are fully decentralized and do not have any globally unique group identifiers - they are only defined on client devices as a group profile and a set of bi-directional SimpleX connections with other group members. When a new member accepts group invitation or joins via group link, the inviting member introduces a new member to all existing members and forwards the connection addresses so that they can establish direct and group member connections.
|
||||
|
||||
There is a possibility of the attack here: as the introducing member forwards the addresses, they can substitute them with other addresses, performing MITM attack on the communication between existing and introduced members - this is similar to the communication operator being able to perform MITM on any connection between the users. To mitigate this attack this group sub-protocol will be extended to allow validating security of the connection by sending connection verification out-of-band.
|
||||
|
||||
Clients are RECOMMENDED to indicate in the UI whether the connection to a group member or contact was made directly or via annother user.
|
||||
Clients are RECOMMENDED to indicate in the UI whether the connection to a group member or contact was made directly or via another user.
|
||||
|
||||
Each member in the group is identified by a group-wide unique identifier used by all members in the group. This is to allow referencing members in the messages and to allow group message integrity validation.
|
||||
|
||||
The diagram below shows the sequence of messages sent between the users' clients to add the new member to the group.
|
||||
|
||||

|
||||
|
||||
While introduced members establish connection inside group, inviting member forwards messages between them by sending `x.grp.msg.forward` messages. When introduced members finalize connection, they notify inviting member to stop forwarding via `x.grp.mem.con` message.
|
||||
|
||||

|
||||
|
||||
### Member roles
|
||||
|
||||
Currently members can have one of three roles - `owner`, `admin` and `member`. The user that created the group is self-assigned owner role, the new members are assigned role by the member who adds them - only `owner` and `admin` members can add new members; only `owner` members can add members with `owner` role.
|
||||
Currently members can have one of three roles - `owner`, `admin`, `member` and `observer`. The user that created the group is self-assigned owner role, the new members are assigned role by the member who adds them - only `owner` and `admin` members can add new members; only `owner` members can add members with `owner` role. `Observer` members only receive messages and aren't allowed to send messages.
|
||||
|
||||
### Messages to manage groups and add members
|
||||
|
||||
@@ -279,3 +279,66 @@ These message are used for WebRTC calls:
|
||||
3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message.
|
||||
|
||||
4. `x.call.end` message is sent to notify the other party that the call is terminated.
|
||||
|
||||
## Threat model
|
||||
|
||||
This threat model compliments SMP, XFTP, push notifications and XRCP protocols threat models:
|
||||
|
||||
- [SimpleX Messaging Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#threat-model);
|
||||
- [SimpleX File Transfer Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xftp.md#threat-model);
|
||||
- [Push notifications threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/push-notifications.md#threat-model);
|
||||
- [SimpleX Remote Control Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xrcp.md#threat-model).
|
||||
|
||||
#### A user's contact
|
||||
|
||||
*can:*
|
||||
|
||||
- send messages prohibited by user's preferences or otherwise act non-compliantly with user's preferences (for example, if message with updated preferences was lost or failed to be processed, or with modified client), in which case user client should treat such messages and actions as prohibited.
|
||||
|
||||
- by exchanging special messages with user's client, match user's contact with existing group members and/or contacts that have identical user profile (see [Probing for duplicate contacts](#probing-for-duplicate-contacts)).
|
||||
|
||||
- identify that and when a user is using SimpleX, in case user has delivery receipts enabled, or based on other automated client responses.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- match user's contact with existing group members and/or contacts with different or with incognito profiles.
|
||||
|
||||
- match user's contact without communicating with the user's client.
|
||||
|
||||
#### A group member
|
||||
|
||||
*can:*
|
||||
|
||||
- send messages prohibited by group's preferences and member restrictions or otherwise act non-compliantly with preferences and restrictions (for example, if decentralized group state diverged, or with modified client), in which case user client should treat such messages and actions as prohibited.
|
||||
|
||||
- create a direct contact with a user if group permissions allow it.
|
||||
|
||||
- by exchanging special messages with user's client, match user's group member record with the existing group members and/or contacts that have identical user profile.
|
||||
|
||||
- undetectably send different messages to different group members, or selectively send messages to some members and not send to others.
|
||||
|
||||
- identify that and when a user is using SimpleX, in case user has delivery receipts enabled, or based on other automated client responses.
|
||||
|
||||
- join the same group several times, from the same or from different user profile, and pretend to be different members.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- match user's contact with existing group members and/or contacts with different or with incognito profiles.
|
||||
|
||||
- match user's group member record with existing group members and/or contacts without communication of user's client.
|
||||
|
||||
- determine whether two group members with different or with incognito profiles are the same user.
|
||||
|
||||
#### A group admin
|
||||
|
||||
*can:*
|
||||
|
||||
- carry out MITM attack between user and other group member(s) when forwarding invitations for group connections (user can detect such attack by verifying connection security codes out-of-band).
|
||||
|
||||
- undetectably forward different messages to different group members, selectively adding, modifying, and dropping forwarded messages.
|
||||
|
||||
- disrupt decentralized group state by sending different messages that change group state (such as adding or removing members, member role changes, etc.) to different group members, or sending such messages selectively.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- prove that two group members with incognito profiles is the same user.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "non-empty string without spaces, the first character must not be # or @"
|
||||
"format": "non-empty string, the first character must not be # or @"
|
||||
}
|
||||
},
|
||||
"fullName": {"type": "string"}
|
||||
@@ -19,6 +19,39 @@
|
||||
"metadata": {
|
||||
"format": "data URI format for base64 encoded image"
|
||||
}
|
||||
},
|
||||
"contactLink": {"ref": "connReqUri"},
|
||||
"preferences": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "JSON encoded user preferences"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"groupProfile": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "non-empty string, the first character must not be # or @"
|
||||
}
|
||||
},
|
||||
"fullName": {"type": "string"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "data URI format for base64 encoded image"
|
||||
}
|
||||
},
|
||||
"groupPreferences": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "JSON encoded user preferences"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -29,6 +62,8 @@
|
||||
},
|
||||
"optionalProperties": {
|
||||
"file": {"ref": "fileInvitation"},
|
||||
"ttl": {"type": "integer"},
|
||||
"live": {"type": "boolean"},
|
||||
"quote": {
|
||||
"properties": {
|
||||
"msgRef": {"ref": "msgRef"},
|
||||
@@ -56,17 +91,47 @@
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
|
||||
"image": {"ref": "base64url"}
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
|
||||
"image": {"ref": "base64url"}
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
|
||||
"image": {"ref": "base64url"},
|
||||
"duration": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
|
||||
"duration": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}}
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"comment": "it is RECOMMENDED that the clients support other values in `type` properties showing them as text messages in case `text` property is present"
|
||||
}
|
||||
},
|
||||
"msgReaction" : {
|
||||
"discriminator": "type",
|
||||
"mapping": {
|
||||
"emoji": {
|
||||
"properties": {
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"metadata": {"comment": "emoji character"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"msgRef": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
@@ -91,7 +156,31 @@
|
||||
"fileSize": {"type": "uint32"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"fileConnReq": {"ref": "connReqUri"}
|
||||
"fileDigest": {"ref": "base64url"},
|
||||
"fileConnReq": {"ref": "connReqUri"},
|
||||
"fileDescr": {"ref": "fileDescription"}
|
||||
}
|
||||
},
|
||||
"fileDescription": {
|
||||
"properties": {
|
||||
"fileDescrText": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "XFTP file description part text"
|
||||
}
|
||||
},
|
||||
"fileDescrPartNo": {
|
||||
"type": "integer",
|
||||
"metadata": {
|
||||
"format": "XFTP file description part number"
|
||||
}
|
||||
},
|
||||
"fileDescrComplete": {
|
||||
"type": "boolean",
|
||||
"metadata": {
|
||||
"format": "XFTP file description completion marker"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkPreview": {
|
||||
@@ -100,6 +189,21 @@
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"image": {"ref": "base64url"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"content": {"ref": "linkContent"}
|
||||
}
|
||||
},
|
||||
"linkContent": {
|
||||
"discriminator": "type",
|
||||
"mapping": {
|
||||
"page": {},
|
||||
"image": {},
|
||||
"video": {
|
||||
"optionalProperties": {
|
||||
"duration": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupInvitation": {
|
||||
@@ -107,15 +211,27 @@
|
||||
"fromMember": {"ref": "memberIdRole"},
|
||||
"invitedMember": {"ref": "memberIdRole"},
|
||||
"connRequest": {"ref": "connReqUri"},
|
||||
"groupProfile": {"ref": "profile"}
|
||||
"groupProfile": {"ref": "groupProfile"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"groupLinkId": {"ref": "base64url"},
|
||||
"groupSize": {"type": "integer"},
|
||||
"metadata": {
|
||||
"comment": "used to identify invitation via group link"
|
||||
"comment": "groupLinkId is used to identify invitation via group link"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupLinkInvitation": {
|
||||
"properties": {
|
||||
"fromMember": {"ref": "memberIdRole"},
|
||||
"fromMemberName": {"type": "string"},
|
||||
"invitedMember": {"ref": "memberIdRole"},
|
||||
"groupProfile": {"ref": "groupProfile"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"groupSize": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"memberIdRole": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
@@ -127,16 +243,35 @@
|
||||
"memberId": {"ref": "base64url"},
|
||||
"memberRole": {"ref": "groupMemberRole"},
|
||||
"profile": {"ref": "profile"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"v": {"ref": "chatVersionRange"}
|
||||
}
|
||||
},
|
||||
"memberRestrictions": {
|
||||
"properties": {
|
||||
"restriction": {"ref": "memberRestrictionStatus"}
|
||||
}
|
||||
},
|
||||
"memberRestrictionStatus": {
|
||||
"enum": ["blocked", "unrestricted"]
|
||||
},
|
||||
"chatVersionRange": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "chat version range string encoded as `<min>-<max>`, or as `<number>` if min = max"
|
||||
}
|
||||
},
|
||||
"introInvitation": {
|
||||
"properties": {
|
||||
"groupConnReq": {"ref": "connReqUri"},
|
||||
"groupConnReq": {"ref": "connReqUri"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"directConnReq": {"ref": "connReqUri"}
|
||||
}
|
||||
},
|
||||
"groupMemberRole": {
|
||||
"enum": ["author", "member", "admin", "owner"]
|
||||
"enum": ["observer", "author", "member", "admin", "owner"]
|
||||
},
|
||||
"callInvitation": {
|
||||
"properties": {
|
||||
@@ -257,6 +392,17 @@
|
||||
"params": {"ref": "msgContainer"}
|
||||
}
|
||||
},
|
||||
"x.msg.file.descr": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"fileDescr": {"ref": "fileDescription"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.msg.update": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
@@ -264,6 +410,10 @@
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"content": {"ref": "msgContent"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"ttl": {"type": "integer"},
|
||||
"live": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +424,24 @@
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"memberId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.msg.react": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"reaction": {"ref": "msgReaction"},
|
||||
"add": {"type": "boolean"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"memberId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,8 +462,10 @@
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"fileConnReq": {"ref": "connReqUri"},
|
||||
"fileName": {"type": "string"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"fileConnReq": {"ref": "connReqUri"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +480,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.direct.del": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
@@ -330,6 +508,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.link.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"groupLinkInvitation": {"ref": "groupLinkInvitation"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.link.mem": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"profile": {"ref": "profile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.new": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
@@ -346,6 +544,9 @@
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberInfo": {"ref": "memberInfo"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"memberRestrictions": {"ref": "memberRestrictions"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +595,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.restrict": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"memberRestrictions": {"ref": "memberRestrictions"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.con": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.del": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
@@ -425,7 +647,42 @@
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"groupProfile": {"ref": "profile"}
|
||||
"groupProfile": {"ref": "groupProfile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.direct.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"connReq": {"ref": "connReqUri"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"content": {"ref": "msgContent"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.msg.forward": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"msg": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "JSON encoded chat message"
|
||||
}
|
||||
},
|
||||
"msgTs": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "ISO8601 UTC time of the message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,7 +693,7 @@
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"},
|
||||
"invitation": {}
|
||||
"invitation": {"ref": "callInvitation"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Agent stats persistence
|
||||
|
||||
## Problem
|
||||
|
||||
State/state tracked in agent are lost on app restart, which makes it difficult to debug user bugs.
|
||||
|
||||
## Solution
|
||||
|
||||
Persist stats between sessions.
|
||||
|
||||
App terminal signals may vary per platform / be absent (?) -> persist stats periodically.
|
||||
|
||||
Stats would have `<userId, server>` key, so we don't want to store them in a plaintext file to not leak used servers locally -> persist in encrypted db.
|
||||
|
||||
There's couple of orthogonal design decision to be made:
|
||||
- persist in chat or in agent db
|
||||
- pros for chat:
|
||||
- possibly less contention for db than agent
|
||||
- pros for agent:
|
||||
- no unnecessary back and forth, especially if agent starts accumulating from past sessions and has to be parameterized with past stats (see below)
|
||||
- agent to start accumulating from past sessions stats, or keep past separately and only accumulate for current session from zeros
|
||||
- pros for accumulating from past sessions:
|
||||
- easier to maintain stats - e.g. user deletion has to remove keys, which is more convoluted if past stats are not stored in memory
|
||||
- simpler UI - overall stats, no differentiation for past/current session (or less logic in backend preparing presentation data)
|
||||
- pros for accumulating from zeros:
|
||||
- simpler start logic - no need to restore stats from agent db / pass initial stats from chat db
|
||||
- can differentiate between past sessions and current session stats in UI
|
||||
|
||||
### Option 1 - Persist in chat db, agent to track only current session
|
||||
|
||||
- Chat stores stats in such table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_stats(
|
||||
agent_stats_id INTEGER PRIMARY KEY, -- dummy id, there will only be one record
|
||||
past_stats TEXT, -- accumulated from previous sessions
|
||||
session_stats TEXT, -- current session
|
||||
past_started_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of tracking stats, reset on stats reset
|
||||
session_started_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of current session
|
||||
session_updated_at TEXT NOT NULL DEFAULT(datetime('now')) -- last update of current session stats (periodic, frequent updates)
|
||||
);
|
||||
```
|
||||
|
||||
- Chat periodically calls getAgentServersStats api and updates `session_stats`.
|
||||
- interval? should be short to not lose too much data, 5-30 seconds?
|
||||
- On start `session_stats` are accumulated into `past_stats` and set to null.
|
||||
- On user deletion, agent updates current session stats in memory (removes keys), chat has to do same for both stats fields in db.
|
||||
- other cases where stats have to be manipulated in similar way?
|
||||
|
||||
### Option 2 - Persist in chat db, agent to accumulate stats from past sessions
|
||||
|
||||
- Table is only used for persistence of overall stats:
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_stats(
|
||||
agent_stats_id INTEGER PRIMARY KEY, -- dummy id, there will only be one record
|
||||
agent_stats TEXT, -- overall stats - past and session
|
||||
started_tracking_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of tracking stats, reset on stats reset
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
- Chat to parameterize creation of agent client with initial stats.
|
||||
|
||||
### Option 3 - Persist in agent db, agent to differentiate past stats and session stats
|
||||
|
||||
- Table in agent db similar to option 1.
|
||||
- Agent is responsible for periodic updates in session, as well as accumulating into "past" and resetting session stats on start.
|
||||
- Agent only communicates stats to chat on request.
|
||||
- On user deletion agent is fully responsible for maintaining both in-memory session stats, and updating db records.
|
||||
|
||||
### Option 4 - Persist in agent db, agent to accumulate stats from past sessions
|
||||
|
||||
- Table in agent db similar to option 2.
|
||||
- On start agent restores initial stats into memory by itself.
|
||||
- Since all stats are in memory, on user deletion it's enough to update in memory without updating db.
|
||||
- there is a race possible where agent crashes after updating stats (removing user keys) in memory before database stats have been overwritten by a periodic update, so it may be better to immediately overwrite and not wait for periodic update.
|
||||
- still at least there's at least no additional logic to update past stats.
|
||||
|
||||
### Other considerations
|
||||
|
||||
Why is it important to timely remove user keys from past stats?
|
||||
- stats not being saved for past users:
|
||||
- important both privacy-wise and to not cause confusion when showing "All" stats (e.g. user summing up across users stats would have smaller total than total stats).
|
||||
- to avoid accidentally mixing up with newer users.
|
||||
- though we do have an AUTOINCREMENT user_id in agent so probably it wouldn't be a problem.
|
||||
- on the other hand maybe we don't want to "forget" stats on user deletion so that stats would reflect networking more accurately?
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 5.8.1.0
|
||||
version: 5.8.2.0
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Name=SimpleX Chat
|
||||
Comment=A private & encrypted open-source messenger without any user IDs (not even random ones)!
|
||||
Keywords=chat;message;private;secure;simplex;
|
||||
Categories=Utility;Chat;InstantMessaging;
|
||||
Exec=simplex %U
|
||||
Icon=chat.simplex.simplex
|
||||
StartupWMClass=simplex-chat
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>chat.simplex.simplex</id>
|
||||
|
||||
<name>SimpleX Chat</name>
|
||||
<summary>A private & encrypted open-source messenger without any user IDs (not even random)!</summary>
|
||||
|
||||
<developer id="chat.simplex">
|
||||
<name>SimpleX Chat</name>
|
||||
</developer>
|
||||
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>AGPL-3.0-or-later</project_license>
|
||||
|
||||
<description>
|
||||
<p>SimpleX - the first messaging platform that has no user identifiers, not even random numbers!</p>
|
||||
<p>Security assessment was done by Trail of Bits in November 2022.</p>
|
||||
<p>SimpleX Chat features:</p>
|
||||
<ol>
|
||||
<li>end-to-end encrypted messages, with editing, replies and deletion of messages.</li>
|
||||
<li>sending end-to-end encrypted images and files.</li>
|
||||
<li>single-use and long-term user addresses.</li>
|
||||
<li>secret chat groups - only group members know it exists and who is the member.</li>
|
||||
<li>end-to-end encrypted audio and video calls.</li>
|
||||
<li>private instant notifications.</li>
|
||||
<li>portable chat profile - you can transfer your chat contacts and history to another device (terminal or mobile).</li>
|
||||
</ol>
|
||||
<p>SimpleX Chat advantages:</p>
|
||||
<ol>
|
||||
<li><em>Full privacy of your identity, profile, contacts and metadata:</em> unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</li>
|
||||
<li><em>Complete protection against spam and abuse:</em> as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address.</li>
|
||||
<li><em>Full ownership, control and security of your data:</em> SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</li>
|
||||
<li><em>Decentralized network:</em> you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</li>
|
||||
</ol>
|
||||
<p>You can connect to anybody you know via link or scan QR code (in the video call or in person) and start sending messages instantly - no emails, phone numbers or passwords needed.</p>
|
||||
<p>Your profile and contacts are only stored in the app on your device - our servers do not have access to this information.</p>
|
||||
<p>All messages are end-to-end encrypted using open-source double-ratchet protocol; the messages are routed via our servers using open-source SimpleX Messaging Protocol.</p>
|
||||
</description>
|
||||
|
||||
<releases>
|
||||
<release version="5.8.1" date="2024-06-19">
|
||||
<description>
|
||||
<p>General:</p>
|
||||
<ol>
|
||||
<li>fixes in sending/receiving files.</li>
|
||||
<li>better error reporting when connecting to desktop app.</li>
|
||||
<li>prevent forwarding to conversations where conversation preferences do not allow message.</li>
|
||||
</ol>
|
||||
<p>Android and desktop apps:</p>
|
||||
<ol>
|
||||
<li>support transparent theme colors for chat message bubbles.</li>
|
||||
<li>do not reset changed network settings when switching SOCKS proxy on/off</li>
|
||||
<li>fix swipe to reply when animation is disabled.</li>
|
||||
<li>fix bug when duplicate group shown in the UI.</li>
|
||||
</ol>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<url type="homepage">https://simplex.chat/</url>
|
||||
<url type="bugtracker">https://github.com/simplex-chat/simplex-chat/issues</url>
|
||||
<url type="donation">https://opencollective.com/simplex-chat</url>
|
||||
<url type="translate">https://simplex.chat/docs/translations</url>
|
||||
<url type="faq">https://simplex.chat/faq</url>
|
||||
<url type="vcs-browser">https://github.com/simplex-chat/simplex-chat</url>
|
||||
|
||||
<launchable type="desktop-id">chat.simplex.simplex.desktop</launchable>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a5f0ff</color>
|
||||
<color type="primary" scheme_preference="dark">#110e26</color>
|
||||
</branding>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://simplex.chat/blog/images/simplex-desktop-light.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
</component>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."8a3b72458f917e9867f4e3640dda0fa1827ff6cf" = "1mmxdaj563kjmlkacxdnq62n6mzw9khampzaqghnk6iiwzdig0qy";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."ae8e1c5e9aa3155907f1bd075e9c69af5fce2bee" = "1k6phsn0xslqwd30g6l5bsg3ilghwjh2csav2g4bk6hb5a5ga2yk";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 5.8.1.0
|
||||
version: 5.8.2.0
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -160,6 +160,7 @@ library
|
||||
Simplex.Chat.Remote.RevHTTP
|
||||
Simplex.Chat.Remote.Transport
|
||||
Simplex.Chat.Remote.Types
|
||||
Simplex.Chat.Stats
|
||||
Simplex.Chat.Store
|
||||
Simplex.Chat.Store.AppSettings
|
||||
Simplex.Chat.Store.Connections
|
||||
|
||||
+29
-25
@@ -47,7 +47,6 @@ import qualified Data.List.NonEmpty as L
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)
|
||||
import Data.Ord (Down (..))
|
||||
import qualified Data.Set as S
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -71,6 +70,7 @@ import Simplex.Chat.ProfileGenerator (generateRandomProfile)
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Remote
|
||||
import Simplex.Chat.Remote.Types
|
||||
import Simplex.Chat.Stats
|
||||
import Simplex.Chat.Store
|
||||
import Simplex.Chat.Store.AppSettings
|
||||
import Simplex.Chat.Store.Connections
|
||||
@@ -84,7 +84,6 @@ import Simplex.Chat.Store.Shared
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle)
|
||||
import qualified Simplex.Chat.Util as U
|
||||
import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard)
|
||||
@@ -95,7 +94,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
|
||||
import qualified Simplex.FileTransfer.Transport as XFTP
|
||||
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
|
||||
import Simplex.Messaging.Agent as Agent
|
||||
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getNetworkConfig', ipAddressProtected, withLockMap)
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getNetworkConfig', ipAddressProtected, withLockMap)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
|
||||
import Simplex.Messaging.Agent.Lock (withLock)
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
@@ -113,7 +112,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (base64P)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import qualified Simplex.Messaging.TMap as TM
|
||||
@@ -176,12 +175,11 @@ defaultChatConfig =
|
||||
_defaultSMPServers :: NonEmpty SMPServerWithAuth
|
||||
_defaultSMPServers =
|
||||
L.fromList
|
||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
|
||||
"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]
|
||||
@@ -369,7 +367,7 @@ activeAgentServers ChatConfig {defaultServers} p =
|
||||
fromMaybe (cfgServers p defaultServers)
|
||||
. nonEmpty
|
||||
. map (\ServerCfg {server} -> server)
|
||||
. filter (\ServerCfg {enabled} -> enabled)
|
||||
. filter (\ServerCfg {enabled} -> enabled == SEEnabled)
|
||||
|
||||
cfgServers :: UserProtocol p => SProtocolType p -> (DefaultAgentServers -> NonEmpty (ProtoServerWithAuth p))
|
||||
cfgServers p DefaultAgentServers {smp, xftp} = case p of
|
||||
@@ -1315,7 +1313,7 @@ processChatCommand' vr = \case
|
||||
servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers
|
||||
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers
|
||||
where
|
||||
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
|
||||
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = SEEnabled}
|
||||
GetUserProtoServers aProtocol -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APIGetUserProtoServers userId aProtocol
|
||||
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ do
|
||||
@@ -2253,17 +2251,24 @@ processChatCommand' vr = \case
|
||||
CLUserContact ucId -> "UserContact " <> show ucId
|
||||
CLFile fId -> "File " <> show fId
|
||||
DebugEvent event -> toView event >> ok_
|
||||
GetAgentServersSummary userId -> withUserId userId $ \user -> do
|
||||
agentServersSummary <- lift $ withAgent' getAgentServersSummary
|
||||
users <- withStore' getUsers
|
||||
smpServers <- getUserServers user SPSMP
|
||||
xftpServers <- getUserServers user SPXFTP
|
||||
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers
|
||||
pure $ CRAgentServersSummary user presentedServersSummary
|
||||
where
|
||||
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => User -> SProtocolType p -> CM [ProtocolServer p]
|
||||
getUserServers users protocol = do
|
||||
ChatConfig {defaultServers} <- asks config
|
||||
let defServers = cfgServers protocol defaultServers
|
||||
servers <- map (\ServerCfg {server} -> server) <$> withStore' (`getProtocolServers` users)
|
||||
let srvs = if null servers then L.toList defServers else servers
|
||||
pure $ map protoServer srvs
|
||||
ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_
|
||||
GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary
|
||||
GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails
|
||||
GetAgentStats -> lift $ CRAgentStats . map stat <$> withAgent' getAgentStats
|
||||
where
|
||||
stat (AgentStatsKey {host, clientTs, cmd, res}, count) =
|
||||
map B.unpack [host, clientTs, cmd, res, bshow count]
|
||||
ResetAgentStats -> lift (withAgent' resetAgentStats) >> ok_
|
||||
GetAgentMsgCounts -> lift $ do
|
||||
counts <- map (first decodeLatin1) <$> withAgent' getMsgCounts
|
||||
let allMsgs = foldl' (\(ts, ds) (_, (t, d)) -> (ts + t, ds + d)) (0, 0) counts
|
||||
pure CRAgentMsgCounts {msgCounts = ("all", allMsgs) : sortOn (Down . snd) (filter (\(_, (_, d)) -> d /= 0) counts)}
|
||||
GetAgentSubs -> lift $ summary <$> withAgent' getAgentSubscriptions
|
||||
where
|
||||
summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} =
|
||||
@@ -7611,13 +7616,12 @@ chatCommandP =
|
||||
("/version" <|> "/v") $> ShowVersion,
|
||||
"/debug locks" $> DebugLocks,
|
||||
"/debug event " *> (DebugEvent <$> jsonP),
|
||||
"/get stats" $> GetAgentStats,
|
||||
"/reset stats" $> ResetAgentStats,
|
||||
"/get servers summary " *> (GetAgentServersSummary <$> A.decimal),
|
||||
"/reset servers stats" $> ResetAgentServersStats,
|
||||
"/get subs" $> GetAgentSubs,
|
||||
"/get subs details" $> GetAgentSubsDetails,
|
||||
"/get workers" $> GetAgentWorkers,
|
||||
"/get workers details" $> GetAgentWorkersDetails,
|
||||
"/get msgs" $> GetAgentMsgCounts,
|
||||
"/get queues" $> GetAgentQueuesInfo,
|
||||
"//" *> (CustomChatCommand <$> A.takeByteString)
|
||||
]
|
||||
@@ -7755,7 +7759,7 @@ chatCommandP =
|
||||
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
|
||||
(pure Nothing)
|
||||
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
|
||||
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
|
||||
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = SEEnabled}
|
||||
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
|
||||
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
|
||||
char_ = optional . A.char
|
||||
|
||||
@@ -21,10 +21,10 @@ import Data.Time.Clock (UTCTime)
|
||||
import Database.SQLite.Simple.FromField (FromField (..))
|
||||
import Database.SQLite.Simple.ToField (ToField (..))
|
||||
import Simplex.Chat.Types (Contact, ContactId, User)
|
||||
import Simplex.Chat.Types.Util (decodeJSON, encodeJSON)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON)
|
||||
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
|
||||
|
||||
data Call = Call
|
||||
{ contactId :: ContactId,
|
||||
|
||||
@@ -60,6 +60,7 @@ import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Remote.AppVersion
|
||||
import Simplex.Chat.Remote.Types
|
||||
import Simplex.Chat.Stats (PresentedServersSummary)
|
||||
import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, UserMsgReceiptSettings)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
@@ -75,7 +76,7 @@ import Simplex.Messaging.Agent.Protocol
|
||||
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction)
|
||||
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
|
||||
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||
import Simplex.Messaging.Client (SMPProxyMode (..), SMPProxyFallback (..))
|
||||
import Simplex.Messaging.Client (SMPProxyFallback (..), SMPProxyMode (..))
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
@@ -505,13 +506,12 @@ data ChatCommand
|
||||
| ShowVersion
|
||||
| DebugLocks
|
||||
| DebugEvent ChatResponse
|
||||
| GetAgentStats
|
||||
| ResetAgentStats
|
||||
| GetAgentServersSummary UserId
|
||||
| ResetAgentServersStats
|
||||
| GetAgentSubs
|
||||
| GetAgentSubsDetails
|
||||
| GetAgentWorkers
|
||||
| GetAgentWorkersDetails
|
||||
| GetAgentMsgCounts
|
||||
| GetAgentQueuesInfo
|
||||
| -- The parser will return this command for strings that start from "//".
|
||||
-- This command should be processed in preCmdHook
|
||||
@@ -756,12 +756,11 @@ data ChatResponse
|
||||
| CRSQLResult {rows :: [Text]}
|
||||
| CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]}
|
||||
| CRDebugLocks {chatLockName :: Maybe String, chatEntityLocks :: Map String String, agentLocks :: AgentLocks}
|
||||
| CRAgentStats {agentStats :: [[String]]}
|
||||
| CRAgentServersSummary {user :: User, serversSummary :: PresentedServersSummary}
|
||||
| CRAgentWorkersDetails {agentWorkersDetails :: AgentWorkersDetails}
|
||||
| CRAgentWorkersSummary {agentWorkersSummary :: AgentWorkersSummary}
|
||||
| CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]}
|
||||
| CRAgentSubsDetails {agentSubs :: SubscriptionsInfo}
|
||||
| CRAgentMsgCounts {msgCounts :: [(Text, (Int, Int))]}
|
||||
| CRAgentQueuesInfo {agentQueuesInfo :: AgentQueuesInfo}
|
||||
| CRContactDisabled {user :: User, contact :: Contact}
|
||||
| CRConnectionDisabled {connectionEntity :: ConnectionEntity}
|
||||
|
||||
@@ -29,13 +29,12 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8)
|
||||
import System.Console.ANSI.Types
|
||||
import qualified Text.Email.Validate as Email
|
||||
|
||||
@@ -146,7 +145,7 @@ parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInpu
|
||||
|
||||
isSimplexLink :: Format -> Bool
|
||||
isSimplexLink = \case
|
||||
SimplexLink {} -> True;
|
||||
SimplexLink {} -> True
|
||||
_ -> False
|
||||
|
||||
markdownP :: Parser Markdown
|
||||
@@ -223,11 +222,15 @@ markdownP = mconcat <$> A.many' fragmentP
|
||||
wordMD s
|
||||
| T.null s = unmarked s
|
||||
| isUri s =
|
||||
let t = T.takeWhileEnd isPunctuation s
|
||||
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
|
||||
let t = T.takeWhileEnd isPunctuation' s
|
||||
uri = uriMarkdown $ T.dropWhileEnd isPunctuation' s
|
||||
in if T.null t then uri else uri :|: unmarked t
|
||||
| isEmail s = markdown Email s
|
||||
| otherwise = unmarked s
|
||||
isPunctuation' = \case
|
||||
'/' -> False
|
||||
')' -> False
|
||||
c -> isPunctuation c
|
||||
uriMarkdown s = case strDecode $ encodeUtf8 s of
|
||||
Right cReq -> markdown (simplexUriFormat cReq) s
|
||||
_ -> markdown Uri s
|
||||
|
||||
@@ -29,12 +29,11 @@ import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..))
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff)
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern PQEncOn)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON)
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>))
|
||||
import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>))
|
||||
|
||||
data MsgDirection = MDRcv | MDSnd
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -46,14 +46,13 @@ import Database.SQLite.Simple.ToField (ToField (..))
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
|
||||
import Simplex.Messaging.Compression (Compressed, compress1, decompress1)
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
|
||||
import Simplex.Messaging.Protocol (MsgBody)
|
||||
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
|
||||
import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>))
|
||||
import Simplex.Messaging.Version hiding (version)
|
||||
|
||||
-- Chat version history:
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
||||
|
||||
module Simplex.Chat.Stats where
|
||||
|
||||
import qualified Data.Aeson.TH as J
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe, isJust)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Client
|
||||
import Simplex.Messaging.Agent.Protocol (UserId)
|
||||
import Simplex.Messaging.Agent.Stats
|
||||
import Simplex.Messaging.Parsers (defaultJSON)
|
||||
import Simplex.Messaging.Protocol
|
||||
|
||||
data PresentedServersSummary = PresentedServersSummary
|
||||
{ statsStartedAt :: UTCTime,
|
||||
allUsersSMP :: SMPServersSummary,
|
||||
allUsersXFTP :: XFTPServersSummary,
|
||||
currentUserSMP :: SMPServersSummary,
|
||||
currentUserXFTP :: XFTPServersSummary
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- Presentation of servers will be split into separate categories,
|
||||
-- so users can differentiate currently used (connected) servers,
|
||||
-- previously connected servers that were in use in previous sessions,
|
||||
-- and servers that are only proxied (not connected directly).
|
||||
data SMPServersSummary = SMPServersSummary
|
||||
{ -- SMP totals are calculated from all accounted SMP server summaries
|
||||
smpTotals :: SMPTotals,
|
||||
-- currently used SMP servers are those with Just in sessions and/or subs in SMPServerSummary;
|
||||
-- all other servers would fall either into previously used or only proxied servers category
|
||||
currentlyUsedSMPServers :: [SMPServerSummary],
|
||||
-- previously used SMP servers are those with Nothing in sessions and subs,
|
||||
-- and have any of sentDirect, sentProxied, recvMsgs, etc. > 0 in server stats (see toPresentedServersSummary);
|
||||
-- remaining servers would fall into only proxied servers category
|
||||
previouslyUsedSMPServers :: [SMPServerSummary],
|
||||
-- only proxied SMP servers are those that aren't (according to current state - sessions and subs)
|
||||
-- and weren't (according to stats) connected directly; they would have Nothing in sessions and subs,
|
||||
-- and have all of sentDirect, sentProxied, recvMsgs, etc. = 0 in server stats
|
||||
onlyProxiedSMPServers :: [SMPServerSummary]
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data SMPTotals = SMPTotals
|
||||
{ sessions :: ServerSessions,
|
||||
subs :: SMPServerSubs,
|
||||
stats :: AgentSMPServerStatsData
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data SMPServerSummary = SMPServerSummary
|
||||
{ smpServer :: SMPServer,
|
||||
-- known:
|
||||
-- for simplicity always Nothing in totalServersSummary - allows us to load configured servers only for current user,
|
||||
-- and also unnecessary unless we want to add navigation to other users servers settings;
|
||||
-- always Just in currentUserServers - True if server is in list of user servers, otherwise False;
|
||||
-- True - allows to navigate to server settings, False - allows to add server to configured as known (SEKnown)
|
||||
known :: Maybe Bool,
|
||||
sessions :: Maybe ServerSessions,
|
||||
subs :: Maybe SMPServerSubs,
|
||||
-- stats:
|
||||
-- even if sessions and subs are Nothing, stats can be Just - server could be used earlier in session,
|
||||
-- or in previous sessions and stats for it were restored; server would fall into a category of
|
||||
-- previously used or only proxied servers - see ServersSummary above
|
||||
stats :: Maybe AgentSMPServerStatsData
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data XFTPServersSummary = XFTPServersSummary
|
||||
{ -- XFTP totals are calculated from all accounted XFTP server summaries
|
||||
xftpTotals :: XFTPTotals,
|
||||
-- currently used XFTP servers are those with Just in sessions in XFTPServerSummary,
|
||||
-- and/or have upload/download/deletion in progress;
|
||||
-- all other servers would fall into previously used servers category
|
||||
currentlyUsedXFTPServers :: [XFTPServerSummary],
|
||||
-- previously used XFTP servers are those with Nothing in sessions and don't have any process in progress
|
||||
previouslyUsedXFTPServers :: [XFTPServerSummary]
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data XFTPTotals = XFTPTotals
|
||||
{ sessions :: ServerSessions,
|
||||
stats :: AgentXFTPServerStatsData
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data XFTPServerSummary = XFTPServerSummary
|
||||
{ xftpServer :: XFTPServer,
|
||||
known :: Maybe Bool, -- same as for SMPServerSummary
|
||||
sessions :: Maybe ServerSessions,
|
||||
stats :: Maybe AgentXFTPServerStatsData,
|
||||
rcvInProgress :: Bool,
|
||||
sndInProgress :: Bool,
|
||||
delInProgress :: Bool
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- Maps AgentServersSummary to PresentedServersSummary:
|
||||
-- - currentUserServers is for currentUser;
|
||||
-- - users are passed to exclude hidden users from totalServersSummary;
|
||||
-- - if currentUser is hidden, it should be accounted in totalServersSummary;
|
||||
-- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs
|
||||
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> PresentedServersSummary
|
||||
toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs = do
|
||||
let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries
|
||||
(userSMPTotals, allSMPTotals) = (accSMPTotals userSMPSrvsSumms, accSMPTotals allSMPSrvsSumms)
|
||||
(userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms
|
||||
(allSMPCurr, allSMPPrev, allSMPProx) = smpSummsIntoCategories allSMPSrvsSumms
|
||||
(userXFTPSrvsSumms, allXFTPSrvsSumms) = accXFTPSrvsSummaries
|
||||
(userXFTPTotals, allXFTPTotals) = (accXFTPTotals userXFTPSrvsSumms, accXFTPTotals allXFTPSrvsSumms)
|
||||
(userXFTPCurr, userXFTPPrev) = xftpSummsIntoCategories userXFTPSrvsSumms
|
||||
(allXFTPCurr, allXFTPPrev) = xftpSummsIntoCategories allXFTPSrvsSumms
|
||||
PresentedServersSummary
|
||||
{ statsStartedAt,
|
||||
allUsersSMP =
|
||||
SMPServersSummary
|
||||
{ smpTotals = allSMPTotals,
|
||||
currentlyUsedSMPServers = allSMPCurr,
|
||||
previouslyUsedSMPServers = allSMPPrev,
|
||||
onlyProxiedSMPServers = allSMPProx
|
||||
},
|
||||
allUsersXFTP =
|
||||
XFTPServersSummary
|
||||
{ xftpTotals = allXFTPTotals,
|
||||
currentlyUsedXFTPServers = allXFTPCurr,
|
||||
previouslyUsedXFTPServers = allXFTPPrev
|
||||
},
|
||||
currentUserSMP =
|
||||
SMPServersSummary
|
||||
{ smpTotals = userSMPTotals,
|
||||
currentlyUsedSMPServers = userSMPCurr,
|
||||
previouslyUsedSMPServers = userSMPPrev,
|
||||
onlyProxiedSMPServers = userSMPProx
|
||||
},
|
||||
currentUserXFTP =
|
||||
XFTPServersSummary
|
||||
{ xftpTotals = userXFTPTotals,
|
||||
currentlyUsedXFTPServers = userXFTPCurr,
|
||||
previouslyUsedXFTPServers = userXFTPPrev
|
||||
}
|
||||
}
|
||||
where
|
||||
AgentServersSummary {statsStartedAt, smpServersSessions, smpServersSubs, smpServersStats, xftpServersSessions, xftpServersStats, xftpRcvInProgress, xftpSndInProgress, xftpDelInProgress} = agentSummary
|
||||
countUserInAll auId = countUserInAllStats (AgentUserId auId) currentUser users
|
||||
accSMPTotals :: Map SMPServer SMPServerSummary -> SMPTotals
|
||||
accSMPTotals = M.foldr addTotals initialTotals
|
||||
where
|
||||
initialTotals = SMPTotals {sessions = ServerSessions 0 0 0, subs = SMPServerSubs 0 0, stats = newAgentSMPServerStatsData}
|
||||
addTotals SMPServerSummary {sessions, subs, stats} SMPTotals {sessions = accSess, subs = accSubs, stats = accStats} =
|
||||
SMPTotals
|
||||
{ sessions = maybe accSess (accSess `addServerSessions`) sessions,
|
||||
subs = maybe accSubs (accSubs `addSMPSubs`) subs,
|
||||
stats = maybe accStats (accStats `addSMPStatsData`) stats
|
||||
}
|
||||
accXFTPTotals :: Map XFTPServer XFTPServerSummary -> XFTPTotals
|
||||
accXFTPTotals = M.foldr addTotals initialTotals
|
||||
where
|
||||
initialTotals = XFTPTotals {sessions = ServerSessions 0 0 0, stats = newAgentXFTPServerStatsData}
|
||||
addTotals XFTPServerSummary {sessions, stats} XFTPTotals {sessions = accSess, stats = accStats} =
|
||||
XFTPTotals
|
||||
{ sessions = maybe accSess (accSess `addServerSessions`) sessions,
|
||||
stats = maybe accStats (accStats `addXFTPStatsData`) stats
|
||||
}
|
||||
smpSummsIntoCategories :: Map SMPServer SMPServerSummary -> ([SMPServerSummary], [SMPServerSummary], [SMPServerSummary])
|
||||
smpSummsIntoCategories = foldr partitionSummary ([], [], [])
|
||||
where
|
||||
partitionSummary srvSumm (curr, prev, prox)
|
||||
| isCurrentlyUsed srvSumm = (srvSumm : curr, prev, prox)
|
||||
| isPreviouslyUsed srvSumm = (curr, srvSumm : prev, prox)
|
||||
| otherwise = (curr, prev, srvSumm : prox)
|
||||
isCurrentlyUsed SMPServerSummary {sessions, subs} = isJust sessions || isJust subs
|
||||
isPreviouslyUsed SMPServerSummary {stats} = case stats of
|
||||
Nothing -> False
|
||||
-- add connCompleted, connDeleted?
|
||||
-- check: should connCompleted be counted for proxy? is it?
|
||||
Just AgentSMPServerStatsData {_sentDirect, _sentProxied, _sentDirectAttempts, _sentProxiedAttempts, _recvMsgs, _connCreated, _connSecured, _connSubscribed, _connSubAttempts} ->
|
||||
_sentDirect > 0 || _sentProxied > 0 || _sentDirectAttempts > 0 || _sentProxiedAttempts > 0 || _recvMsgs > 0 || _connCreated > 0 || _connSecured > 0 || _connSubscribed > 0 || _connSubAttempts > 0
|
||||
xftpSummsIntoCategories :: Map XFTPServer XFTPServerSummary -> ([XFTPServerSummary], [XFTPServerSummary])
|
||||
xftpSummsIntoCategories = foldr partitionSummary ([], [])
|
||||
where
|
||||
partitionSummary srvSumm (curr, prev)
|
||||
| isCurrentlyUsed srvSumm = (srvSumm : curr, prev)
|
||||
| otherwise = (curr, srvSumm : prev)
|
||||
isCurrentlyUsed XFTPServerSummary {sessions, rcvInProgress, sndInProgress, delInProgress} =
|
||||
isJust sessions || rcvInProgress || sndInProgress || delInProgress
|
||||
accSMPSrvsSummaries :: (Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary)
|
||||
accSMPSrvsSummaries = M.foldrWithKey' (addServerData addStats) summs2 smpServersStats
|
||||
where
|
||||
summs1 = M.foldrWithKey' (addServerData addSessions) (M.empty, M.empty) smpServersSessions
|
||||
summs2 = M.foldrWithKey' (addServerData addSubs) summs1 smpServersSubs
|
||||
addServerData ::
|
||||
(a -> SMPServerSummary -> SMPServerSummary) ->
|
||||
(UserId, SMPServer) ->
|
||||
a ->
|
||||
(Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary) ->
|
||||
(Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary)
|
||||
addServerData addData (userId, srv) d (userSumms, allUsersSumms) = (userSumms', allUsersSumms')
|
||||
where
|
||||
userSumms'
|
||||
| userId == aUserId currentUser = alterSumms newUserSummary userSumms
|
||||
| otherwise = userSumms
|
||||
allUsersSumms'
|
||||
| countUserInAll userId = alterSumms newSummary allUsersSumms
|
||||
| otherwise = allUsersSumms
|
||||
alterSumms n = M.alter (Just . addData d . fromMaybe n) srv
|
||||
newUserSummary = (newSummary :: SMPServerSummary) {known = Just $ srv `elem` userSMPSrvs}
|
||||
newSummary =
|
||||
SMPServerSummary
|
||||
{ smpServer = srv,
|
||||
known = Nothing,
|
||||
sessions = Nothing,
|
||||
subs = Nothing,
|
||||
stats = Nothing
|
||||
}
|
||||
addSessions :: ServerSessions -> SMPServerSummary -> SMPServerSummary
|
||||
addSessions s summ@SMPServerSummary {sessions} = summ {sessions = Just $ maybe s (s `addServerSessions`) sessions}
|
||||
addSubs :: SMPServerSubs -> SMPServerSummary -> SMPServerSummary
|
||||
addSubs s summ@SMPServerSummary {subs} = summ {subs = Just $ maybe s (s `addSMPSubs`) subs}
|
||||
addStats :: AgentSMPServerStatsData -> SMPServerSummary -> SMPServerSummary
|
||||
addStats s summ@SMPServerSummary {stats} = summ {stats = Just $ maybe s (s `addSMPStatsData`) stats}
|
||||
accXFTPSrvsSummaries :: (Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary)
|
||||
accXFTPSrvsSummaries = M.foldrWithKey' (addServerData addStats) summs1 xftpServersStats
|
||||
where
|
||||
summs1 = M.foldrWithKey' (addServerData addSessions) (M.empty, M.empty) xftpServersSessions
|
||||
addServerData ::
|
||||
(a -> XFTPServerSummary -> XFTPServerSummary) ->
|
||||
(UserId, XFTPServer) ->
|
||||
a ->
|
||||
(Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary) ->
|
||||
(Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary)
|
||||
addServerData addData (userId, srv) d (userSumms, allUsersSumms) = (userSumms', allUsersSumms')
|
||||
where
|
||||
userSumms'
|
||||
| userId == aUserId currentUser = alterSumms newUserSummary userSumms
|
||||
| otherwise = userSumms
|
||||
allUsersSumms'
|
||||
| countUserInAll userId = alterSumms newSummary allUsersSumms
|
||||
| otherwise = allUsersSumms
|
||||
alterSumms n = M.alter (Just . addData d . fromMaybe n) srv
|
||||
newUserSummary = (newSummary :: XFTPServerSummary) {known = Just $ srv `elem` userXFTPSrvs}
|
||||
newSummary =
|
||||
XFTPServerSummary
|
||||
{ xftpServer = srv,
|
||||
known = Nothing,
|
||||
sessions = Nothing,
|
||||
stats = Nothing,
|
||||
rcvInProgress = srv `elem` xftpRcvInProgress,
|
||||
sndInProgress = srv `elem` xftpSndInProgress,
|
||||
delInProgress = srv `elem` xftpDelInProgress
|
||||
}
|
||||
addSessions :: ServerSessions -> XFTPServerSummary -> XFTPServerSummary
|
||||
addSessions s summ@XFTPServerSummary {sessions} = summ {sessions = Just $ maybe s (s `addServerSessions`) sessions}
|
||||
addStats :: AgentXFTPServerStatsData -> XFTPServerSummary -> XFTPServerSummary
|
||||
addStats s summ@XFTPServerSummary {stats} = summ {stats = Just $ maybe s (s `addXFTPStatsData`) stats}
|
||||
addServerSessions :: ServerSessions -> ServerSessions -> ServerSessions
|
||||
addServerSessions ss1 ss2 =
|
||||
ServerSessions
|
||||
{ ssConnected = ssConnected ss1 + ssConnected ss2,
|
||||
ssErrors = ssErrors ss1 + ssErrors ss2,
|
||||
ssConnecting = ssConnecting ss1 + ssConnecting ss2
|
||||
}
|
||||
|
||||
countUserInAllStats :: AgentUserId -> User -> [User] -> Bool
|
||||
countUserInAllStats (AgentUserId auId) currentUser users =
|
||||
auId == aUserId currentUser || auId `notElem` hiddenUserIds
|
||||
where
|
||||
hiddenUserIds = map aUserId $ filter (isJust . viewPwdHash) users
|
||||
|
||||
addSMPSubs :: SMPServerSubs -> SMPServerSubs -> SMPServerSubs
|
||||
addSMPSubs ss1 ss2 =
|
||||
SMPServerSubs
|
||||
{ ssActive = ssActive ss1 + ssActive ss2,
|
||||
ssPending = ssPending ss1 + ssPending ss2
|
||||
}
|
||||
|
||||
$(J.deriveJSON defaultJSON ''SMPTotals)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''SMPServerSummary)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''SMPServersSummary)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''XFTPTotals)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''XFTPServerSummary)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''XFTPServersSummary)
|
||||
|
||||
$(J.deriveJSON defaultJSON ''PresentedServersSummary)
|
||||
@@ -523,9 +523,10 @@ getProtocolServers db User {userId} =
|
||||
(userId, decodeLatin1 $ strEncode protocol)
|
||||
where
|
||||
protocol = protocolTypeI @p
|
||||
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p
|
||||
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
|
||||
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Int) -> ServerCfg p
|
||||
toServerCfg (host, port, keyHash, auth_, preset, tested, enabledInt) =
|
||||
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||
enabled = toServerEnabled enabledInt
|
||||
in ServerCfg {server, preset, tested, enabled}
|
||||
|
||||
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
|
||||
@@ -542,7 +543,7 @@ overwriteProtocolServers db User {userId} servers =
|
||||
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs))
|
||||
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, fromServerEnabled enabled, userId, currentTs, currentTs))
|
||||
pure $ Right ()
|
||||
where
|
||||
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
|
||||
|
||||
@@ -1632,10 +1632,42 @@ data ServerCfg p = ServerCfg
|
||||
{ server :: ProtoServerWithAuth p,
|
||||
preset :: Bool,
|
||||
tested :: Maybe Bool,
|
||||
enabled :: Bool
|
||||
enabled :: ServerEnabled
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data ServerEnabled
|
||||
= SEDisabled
|
||||
| SEEnabled
|
||||
| -- server is marked as known, but it's not in the list of configured servers;
|
||||
-- e.g., it may be added via an unknown server dialogue and user didn't manually configure it,
|
||||
-- meaning server wasn't tested (or at least such option wasn't presented in UI)
|
||||
-- and it may be inoperable for user due to server password
|
||||
SEKnown
|
||||
deriving (Eq, Show)
|
||||
|
||||
pattern DBSEDisabled :: Int
|
||||
pattern DBSEDisabled = 0
|
||||
|
||||
pattern DBSEEnabled :: Int
|
||||
pattern DBSEEnabled = 1
|
||||
|
||||
pattern DBSEKnown :: Int
|
||||
pattern DBSEKnown = 2
|
||||
|
||||
toServerEnabled :: Int -> ServerEnabled
|
||||
toServerEnabled = \case
|
||||
DBSEDisabled -> SEDisabled
|
||||
DBSEEnabled -> SEEnabled
|
||||
DBSEKnown -> SEKnown
|
||||
_ -> SEDisabled
|
||||
|
||||
fromServerEnabled :: ServerEnabled -> Int
|
||||
fromServerEnabled = \case
|
||||
SEDisabled -> DBSEDisabled
|
||||
SEEnabled -> DBSEEnabled
|
||||
SEKnown -> DBSEKnown
|
||||
|
||||
data ChatVersion
|
||||
|
||||
instance VersionScope ChatVersion
|
||||
@@ -1764,6 +1796,8 @@ $(JQ.deriveJSON defaultJSON ''ContactRef)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''NoteFolder)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "SE") ''ServerEnabled)
|
||||
|
||||
instance ProtocolTypeI p => ToJSON (ServerCfg p) where
|
||||
toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerCfg)
|
||||
toJSON = $(JQ.mkToJSON defaultJSON ''ServerCfg)
|
||||
|
||||
@@ -36,7 +36,7 @@ import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON)
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
|
||||
import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>))
|
||||
|
||||
data ChatFeature
|
||||
= CFTimedMessages
|
||||
|
||||
@@ -18,6 +18,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
|
||||
import Simplex.Chat.Types.Util
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_)
|
||||
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
|
||||
|
||||
data UITheme = UITheme
|
||||
{ themeId :: Text,
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
|
||||
module Simplex.Chat.Types.Util where
|
||||
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.Types as JT
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Typeable
|
||||
import Database.SQLite.Simple (ResultError (..), SQLData (..))
|
||||
import Database.SQLite.Simple.FromField (FieldParser, returnError)
|
||||
import Database.SQLite.Simple.Internal (Field (..))
|
||||
import Database.SQLite.Simple.Ok (Ok (Ok))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
|
||||
encodeJSON :: ToJSON a => a -> Text
|
||||
encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode
|
||||
|
||||
decodeJSON :: FromJSON a => Text -> Maybe a
|
||||
decodeJSON = J.decode . LB.fromStrict . encodeUtf8
|
||||
|
||||
textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a
|
||||
textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode
|
||||
|
||||
@@ -54,7 +54,6 @@ import qualified Simplex.FileTransfer.Transport as XFTP
|
||||
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP))
|
||||
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
|
||||
import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..))
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
@@ -366,7 +365,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
"chat entity locks: " <> viewJSON chatEntityLocks,
|
||||
"agent locks: " <> viewJSON agentLocks
|
||||
]
|
||||
CRAgentStats stats -> map (plain . intercalate ",") stats
|
||||
CRAgentServersSummary u serversSummary -> ttyUser u ["agent servers summary: " <> viewJSON serversSummary]
|
||||
CRAgentSubs {activeSubs, pendingSubs, removedSubs} ->
|
||||
[plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", pending = " <> show (sum pendingSubs) <> ", removed = " <> show (sum $ M.map length removedSubs)]
|
||||
<> ("active subscriptions:" : listSubs activeSubs)
|
||||
@@ -384,7 +383,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
[ "agent workers details:",
|
||||
viewJSON agentWorkersDetails -- this would be huge, but copypastable when has its own line
|
||||
]
|
||||
CRAgentMsgCounts {msgCounts} -> ["received messages (total, duplicates):", viewJSON msgCounts]
|
||||
CRAgentQueuesInfo {agentQueuesInfo} ->
|
||||
[ "agent queues info:",
|
||||
plain . LB.unpack $ J.encode agentQueuesInfo
|
||||
|
||||
@@ -17,7 +17,7 @@ import Simplex.Chat.Store.Shared (createContact)
|
||||
import Simplex.Chat.Types (ConnStatus (..), Profile (..))
|
||||
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
|
||||
import Simplex.Chat.Types.UITheme
|
||||
import Simplex.Chat.Types.Util (encodeJSON)
|
||||
import Simplex.Messaging.Util (encodeJSON)
|
||||
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
||||
import System.Directory (copyFile, createDirectoryIfMissing)
|
||||
import Test.Hspec hiding (it)
|
||||
|
||||
@@ -149,6 +149,11 @@ textWithUri = describe "text with Uri" do
|
||||
parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat"
|
||||
parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat"
|
||||
parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site"
|
||||
parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat/" `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/"
|
||||
parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat." `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "."
|
||||
parseMarkdown "https://github.com/simplex-chat/ - SimpleX on GitHub" `shouldBe` uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub"
|
||||
-- parseMarkdown "SimpleX on GitHub (https://github.com/simplex-chat/)" `shouldBe` "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")"
|
||||
parseMarkdown "https://en.m.wikipedia.org/wiki/Servo_(software)" `shouldBe` uri "https://en.m.wikipedia.org/wiki/Servo_(software)"
|
||||
it "ignored as markdown" do
|
||||
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
|
||||
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
|
||||
|
||||
+12
-11
@@ -34,7 +34,8 @@ queue =
|
||||
SMPQueueAddress
|
||||
{ smpServer = srv,
|
||||
senderId = "\223\142z\251",
|
||||
dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o="
|
||||
dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o=",
|
||||
sndSecure = False
|
||||
}
|
||||
|
||||
connReqData :: ConnReqUriData
|
||||
@@ -194,7 +195,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}"
|
||||
#==# XMsgDeleted
|
||||
it "x.file" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing}
|
||||
it "x.file without file invitation" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
@@ -203,7 +204,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
|
||||
#==# XFileAcpt "photo.jpg"
|
||||
it "x.file.acpt.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
|
||||
it "x.file.acpt.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
|
||||
@@ -230,10 +231,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
==# XContact testProfile Nothing
|
||||
it "x.grp.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing, groupSize = Nothing}
|
||||
it "x.grp.inv with group link id" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing}
|
||||
it "x.grp.acpt without incognito profile" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||
@@ -254,16 +255,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked})
|
||||
it "x.grp.mem.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||
it "x.grp.mem.inv w/t directConnReq" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||
it "x.grp.mem.fwd" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-8\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-8\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||
it "x.grp.mem.info" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
@@ -284,10 +285,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}"
|
||||
==# XGrpDel
|
||||
it "x.grp.direct.inv" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
#==# XGrpDirectInv testConnReq (Just $ MCText "hello")
|
||||
it "x.grp.direct.inv without content" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
#==# XGrpDirectInv testConnReq Nothing
|
||||
-- it "x.grp.msg.forward"
|
||||
-- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ae8b5b2e-76c9-4a31-a044-bcbda1cdf264
|
||||
Reference in New Issue
Block a user