diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fc1c18fe1..7f72d114b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -343,7 +343,7 @@ jobs: cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" - - os: macos-13 + - os: macos-15-intel ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg diff --git a/README.md b/README.md index 6ed444c7b5..4aa0860af1 100644 --- a/README.md +++ b/README.md @@ -54,24 +54,10 @@ If you are interested in helping us to integrate open-source language models, an ## Join user groups -You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups. **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. -You also can: -- criticize the app, and make comparisons with other messengers. -- share new messengers you think could be interesting for privacy, as long as you don't spam. -- share some privacy related publications, infrequently. -- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it. - -You must: -- be polite to other users -- avoid spam (too frequent messages, even if they are relevant) -- avoid any personal attacks or hostility. -- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.). - -Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. - You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM) There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform: @@ -81,11 +67,7 @@ There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSp - social apps and services - etc. -There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users: - -[\#SimpleX-DE](https://smp6.simplex.im/g#V6tQ-lJqsdgJJdJiLPtP326oQFKHvwinIbgruZ9K2oU) (German-speaking), [\#SimpleX-ES](https://smp5.simplex.im/g#xJ5kwDLq2305O5FmpUzvgRIXXAcAJ9S5BItCd2Wmloc) (Spanish-speaking), [\#SimpleX-FR](https://smp6.simplex.im/g#cVOpB0CKd6hEf2aWQ6sJ22E2DVgQLtdHoiSdKxXeKqk) (French-speaking), [\#SimpleX-RU](https://smp5.simplex.im/g#vwXRdfG5SgtaG6aVcITiUGd--Ux0rY1IuH4QXYxlq3U) (Russian-speaking), [\#SimpleX-IT](https://smp5.simplex.im/g#BtRcjsl29ULFNBSE2OPhp1UwZfW7PW9gUYFQTKHdjqU) (Italian-speaking). - -You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. +You can join these and other groups by opening these links in the app or by opening them in a desktop browser and scanning the QR code. ## Follow our updates @@ -165,8 +147,7 @@ It is possible to donate via: - BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u - XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg -- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- ETH/USDT (Ethereum, Arbitrum One): 0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a - ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg - ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq - DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 00cc25c1d2..7adf7a0435 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -430,19 +430,27 @@ struct ContentView: View { let m = ChatModel.shared if let url = m.appOpenUrl { m.appOpenUrl = nil - dismissAllSheets() { - var path = url.path - if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { - path.removeFirst() - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - planAndConnect( - link, - theme: theme, - dismiss: false - ) - } else { - AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) - } + connectViaUrl_(url) + } else if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active { + // correcting branch in case .onChange(of: scenePhase) in SimpleXApp doesn't trigger and transfer appOpenUrlLater into appOpenUrl + m.appOpenUrlLater = nil + connectViaUrl_(url) + } + } + + func connectViaUrl_(_ url: URL) { + dismissAllSheets() { + var path = url.path + if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { + path.removeFirst() + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + planAndConnect( + link, + theme: theme, + dismiss: false + ) + } else { + AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } } } diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 35b9bf033e..193b675a57 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -158,7 +158,6 @@ enum ChatCommand: ChatCmdProtocol { case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) // WebRTC calls / - case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -359,7 +358,6 @@ enum ChatCommand: ChatCmdProtocol { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" - case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" @@ -534,7 +532,6 @@ enum ChatCommand: ChatCmdProtocol { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" - case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatItemsRead: return "apiChatItemsRead" case .apiChatUnread: return "apiChatUnread" @@ -793,7 +790,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case userContactLinkDeleted(user: User) case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) case newChatItems(user: UserRef, chatItems: [AChatItem]) case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) @@ -837,7 +833,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: "userContactLinkDeleted" case .acceptingContactRequest: "acceptingContactRequest" case .contactRequestRejected: "contactRequestRejected" - case .networkStatuses: "networkStatuses" case .newChatItems: "newChatItems" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .forwardPlan: "forwardPlan" @@ -870,7 +865,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: return noDetails case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestRejected(u, contactRequest, contact_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\ncontact_: \(String(describing: contact_))") - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) @@ -1059,9 +1053,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest, chat_: ChatData?) case contactUpdated(user: UserRef, toContact: Contact) case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case subscriptionStatus(subscriptionStatus: SubscriptionStatus, connections: [String]) case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo) case newChatItems(user: UserRef, chatItems: [AChatItem]) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) @@ -1138,9 +1130,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .receivedContactRequest: "receivedContactRequest" case .contactUpdated: "contactUpdated" case .groupMemberUpdated: "groupMemberUpdated" - case .contactsMerged: "contactsMerged" - case .networkStatus: "networkStatus" - case .networkStatuses: "networkStatuses" + case .subscriptionStatus: "subscriptionStatus" case .chatInfoUpdated: "chatInfoUpdated" case .newChatItems: "newChatItems" case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" @@ -1212,9 +1202,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .receivedContactRequest(u, contactRequest, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))") case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .subscriptionStatus(status, conns): return "subscriptionStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") @@ -1374,38 +1362,6 @@ enum ChatDeleteMode: Codable { } } -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(connectionError: String) - - var statusString: LocalizedStringKey { - switch self { - case .connected: "connected" - case .error: "error" - default: "connecting" - } - } - - var statusExplanation: LocalizedStringKey { - switch self { - case .connected: "You are connected to the server used to receive messages from this contact." - case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: "Trying to connect to the server used to receive messages from this contact." - } - } - - var imageName: String { - switch self { - case .unknown: "circle.dotted" - case .connected: "circle.fill" - case .disconnected: "ellipsis.circle.fill" - case .error: "exclamationmark.circle.fill" - } - } -} - enum ForwardConfirmation: Decodable, Hashable { case filesNotAccepted(fileIds: [Int64]) case filesInProgress(filesCount: Int) @@ -1413,11 +1369,6 @@ enum ForwardConfirmation: Decodable, Hashable { case filesFailed(filesCount: Int) } -struct ConnNetworkStatus: Decodable { - var agentConnId: String - var networkStatus: NetworkStatus -} - struct UserMsgReceiptSettings: Codable { var enable: Bool var clearOverrides: Bool diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a08f70fd53..5ebab167fd 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -283,29 +283,6 @@ class ChatTagsModel: ObservableObject { } } -class NetworkModel: ObservableObject { - // map of connections network statuses, key is agent connection id - @Published var networkStatuses: Dictionary = [:] - - static let shared = NetworkModel() - - private init() { } - - func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] = status - } - } - - func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] ?? .unknown - } else { - .unknown - } - } -} - /// ChatItemWithMenu can depend on previous or next item for it's appearance /// This dummy model is used to force an update of all chat items, /// when they might have changed appearance. @@ -374,6 +351,8 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var chatAgentConnId: String? + @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @@ -385,6 +364,7 @@ final class ChatModel: ObservableObject { @Published var userAddress: UserContactLink? @Published var chatItemTTL: ChatItemTTL = .none @Published var appOpenUrl: URL? + @Published var appOpenUrlLater: URL? @Published var deviceToken: DeviceToken? @Published var savedToken: DeviceToken? @Published var tokenRegistered = false diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f95e6ac2dd..222d6021d9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,8 +15,6 @@ import SwiftUI private var chatController: chat_ctrl? -private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") - enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) case res(Date, ChatAPIResult) @@ -1319,6 +1317,10 @@ func apiCreateUserAddress() async throws -> CreatedConnLink? { let userId = try currentUserId("apiCreateUserAddress") let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId)) if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1640,7 +1642,6 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: } else { ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat) } - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -1728,12 +1729,6 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } -func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { - let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses) - if case let .networkStatuses(_, statuses) = r { return statuses } - throw r.unexpected -} - func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { @@ -1899,6 +1894,10 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1955,7 +1954,6 @@ func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) asy if let contact = await apiAcceptMemberContact(contactId: contactId) { await MainActor.run { ChatModel.shared.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -2241,7 +2239,6 @@ class ChatReceiver { func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared - let n = NetworkModel.shared logger.debug("processReceivedMsg: \(res.responseType)") switch res { case let .contactDeletedByContact(user, contact): @@ -2258,14 +2255,15 @@ func processReceivedMsg(_ res: ChatEvent) async { m.dismissConnReqView(conn.id) m.removeChat(conn.id) } + if contact.id == m.chatId, let conn = contact.activeConn { + m.chatAgentConnId = conn.agentConnId + m.chatSubStatus = .active + } } } if contact.directOrUsed { NtfManager.shared.notifyContactConnected(user, contact) } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .contactConnecting(user, contact): if active(user) && contact.directOrUsed { await MainActor.run { @@ -2286,9 +2284,6 @@ func processReceivedMsg(_ res: ChatEvent) async { } } } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .receivedContactRequest(user, contactRequest, chat_): if active(user) { await MainActor.run { @@ -2327,39 +2322,10 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, toMember) } } - case let .contactsMerged(user, intoContact, mergedContact): - if active(user) && m.hasChat(mergedContact.id) { + case let .subscriptionStatus(status, connections): + if let chatAgentConnId = m.chatAgentConnId, connections.contains(chatAgentConnId) { await MainActor.run { - if m.chatId == mergedContact.id { - ItemsModel.shared.loadOpenChat(mergedContact.id) - } - m.removeChat(mergedContact.id) - } - } - case let .networkStatus(status, connections): - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for cId in connections { - ns[cId] = status - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns - } - } - case let .networkStatuses(_, statuses): () - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for s in statuses { - ns[s.agentConnId] = s.networkStatus - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns + m.chatSubStatus = status } } case let .chatInfoUpdated(user, chatInfo): @@ -2563,11 +2529,6 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, member) } } - if let contact = memberContact { - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } - } case let .groupUpdated(user, toGroup): if active(user) { await MainActor.run { @@ -2793,13 +2754,10 @@ func processReceivedMsg(_ res: ChatEvent) async { func switchToLocalSession() { let m = ChatModel.shared - let n = NetworkModel.shared m.remoteCtrlSession = nil do { m.users = try listUsers() try getUserChatData() - let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } - n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) } catch let error { logger.debug("error updating chat data: \(responseError(error))") } @@ -2907,3 +2865,26 @@ private struct UserResponse: Decodable { var user: User? var error: String? } + +private func showClientNotice(_ server: String, _ preset: Bool, _ expiresAt: Date?) { + DispatchQueue.main.async { + var message = "Server: \(server).\nConditions of use violation notice received from \(preset ? "preset" : "this") server.\nNo IDs shared, see How it works." + if let expiresAt { + message += "\n\nNew addresses can be created after \(expiresAt.formatted(date: .abbreviated, time: .shortened))." + } + showAlert("Not allowed", message: message) { + let howItWorks = UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + return preset + ? [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("Conditions of use", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(conditionsURL) + }), + howItWorks + ] + : [okAlertAction, howItWorks] + } + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 5fbdafe19f..e1a6bb61e8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -19,7 +19,6 @@ struct SimpleXApp: App { @Environment(\.scenePhase) var scenePhase @State private var enteredBackgroundAuthenticated: TimeInterval? = nil - @State private var appOpenUrlLater: URL? init() { DispatchQueue.global(qos: .background).sync { @@ -46,7 +45,7 @@ struct SimpleXApp: App { if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { - appOpenUrlLater = url + chatModel.appOpenUrlLater = url } } .onAppear() { @@ -98,15 +97,15 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } - if let url = appOpenUrlLater { + if let url = chatModel.appOpenUrlLater { await MainActor.run { - appOpenUrlLater = nil + chatModel.appOpenUrlLater = nil chatModel.appOpenUrl = url } } } - } else if let url = appOpenUrlLater { - appOpenUrlLater = nil + } else if let url = chatModel.appOpenUrlLater { + chatModel.appOpenUrlLater = nil chatModel.appOpenUrl = url } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index b60842a4a0..37f3b982a1 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatInfoToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme + @EnvironmentObject var m: ChatModel @ObservedObject var chat: Chat var imageSize: CGFloat = 32 @@ -56,6 +57,13 @@ struct ChatInfoToolbar: View { .padding(.top, -2) } } + if let contact = chat.chatInfo.contact, + contact.ready && contact.active, + let chatSubStatus = m.chatSubStatus, + chatSubStatus != .active { + SubStatusView(status: chatSubStatus) + .padding(.leading, 4) + } } .foregroundColor(theme.colors.onBackground) .frame(width: 220) @@ -68,6 +76,30 @@ struct ChatInfoToolbar: View { .baselineOffset(1) .kerning(-2) } + + struct SubStatusView: View { + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + switch status { + case .active: EmptyView() + case .pending: ProgressView() + case .removed: subStatusError() + case .noSub: subStatusError() + } + } + + @ViewBuilder private func subStatusError() -> some View { + let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) + .foregroundColor(theme.colors.secondary) + } + } } struct ChatInfoToolbar_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 77c1db341a..ad82af05e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -92,7 +92,6 @@ struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction - @ObservedObject var networkModel = NetworkModel.shared @ObservedObject var chat: Chat @State var contact: Contact @State var localAlias: String @@ -115,7 +114,7 @@ struct ChatInfoView: View { enum ChatInfoViewAlert: Identifiable { case clearChatAlert - case networkStatusAlert + case subStatusAlert(status: SubscriptionStatus) case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert @@ -126,7 +125,7 @@ struct ChatInfoView: View { var id: String { switch self { case .clearChatAlert: return "clearChatAlert" - case .networkStatusAlert: return "networkStatusAlert" + case let .subStatusAlert(status): return "subStatusAlert \(status)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" @@ -236,10 +235,12 @@ struct ChatInfoView: View { if contact.ready && contact.active { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert - } + if let chatSubStatus = chatModel.chatSubStatus { + SubStatusRow(status: chatSubStatus) + .onTapGesture { + alert = .subStatusAlert(status: chatSubStatus) + } + } if let connStats = connectionStats { Button("Change receiving address") { alert = .switchAddressAlert @@ -325,7 +326,7 @@ struct ChatInfoView: View { .alert(item: $alert) { alertItem in switch(alertItem) { case .clearChatAlert: return clearChatAlert() - case .networkStatusAlert: return networkStatusAlert() + case let .subStatusAlert(status): return subStatusAlert(status) case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: @@ -546,26 +547,6 @@ struct ChatInfoView: View { } } - private func networkStatusRow() -> some View { - HStack { - Text("Network status") - Image(systemName: "info.circle") - .foregroundColor(theme.colors.primary) - .font(.system(size: 14)) - Spacer() - Text(networkModel.contactNetworkStatus(contact).statusString) - .foregroundColor(theme.colors.secondary) - serverImage() - } - } - - private func serverImage() -> some View { - let status = networkModel.contactNetworkStatus(contact) - return Image(systemName: status.imageName) - .foregroundColor(status == .connected ? .green : theme.colors.secondary) - .font(.system(size: 12)) - } - private func deleteContactButton() -> some View { Button(role: .destructive) { deleteContactDialog( @@ -605,10 +586,10 @@ struct ChatInfoView: View { ) } - private func networkStatusAlert() -> Alert { + private func subStatusAlert(_ status: SubscriptionStatus) -> Alert { Alert( title: Text("Network status"), - message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) + message: Text(status.statusExplanation) ) } @@ -667,6 +648,30 @@ struct ChatInfoView: View { } } +struct SubStatusRow: View { + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + HStack { + Text("Network status") + Image(systemName: "info.circle") + .foregroundColor(theme.colors.primary) + .font(.system(size: 14)) + Spacer() + Text(status.statusString) + .foregroundColor(theme.colors.secondary) + serverImage(status) + } + } + + private func serverImage(_ status: SubscriptionStatus) -> some View { + return Image(systemName: status.imageName) + .foregroundColor(status == .active ? .green : theme.colors.secondary) + .font(.system(size: 12)) + } +} + struct ChatTTLOption: View { @ObservedObject var chat: Chat @Binding var progressIndicator: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index fa53045391..709758655f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -393,6 +393,8 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil im.reversedChatItems = [] im.chatState.clear() chatModel.groupMembers = [] @@ -657,18 +659,30 @@ struct ChatView: View { private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. - if case .active = scenePhase, - case let .direct(contact) = cInfo { - Task { - do { - let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) - await MainActor.run { - if let s = stats { - chatModel.updateContactConnectionStats(contact, s) + if case .active = scenePhase { + if case let .direct(contact) = cInfo { + Task { + do { + let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) + await MainActor.run { + if let s = stats { + chatModel.updateContactConnectionStats(contact, s) + if let conn = contact.activeConn { + chatModel.chatAgentConnId = conn.agentConnId + chatModel.chatSubStatus = s.subStatus + } + } } + } catch let error { + logger.error("apiContactInfo error: \(responseError(error))") + } + } + } else { + Task { + await MainActor.run { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil } - } catch let error { - logger.error("apiContactInfo error: \(responseError(error))") } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 683dea0f56..3745d0f0b8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -788,7 +788,6 @@ struct ComposeView: View { await MainActor.run { self.chatModel.updateContact(contact) clearState() - NetworkModel.shared.setContactNetworkStatus(contact, .connected) } } else { AlertManager.shared.showAlertMsg(title: "Empty message!") @@ -827,7 +826,6 @@ struct ComposeView: View { if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) clearState() } } else { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 31d4ceecc6..c5fd8e39d0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -71,13 +71,17 @@ struct NativeTextEditor: UIViewRepresentable { if field.selectedRange != selectedRange { field.selectedRange = selectedRange } - if focused && !field.isFocused { - DispatchQueue.main.async { - if !field.isFocused { - field.becomeFirstResponder() - } - } - } +// This block causes delays in closing keyboard when navigating from chat view to chat list. +// It is also a candidate for iOS 26.1 freeze. +// This was added in commit below to open keyboard programmatically via a passed binding but this approach is not reliable. +// https://github.com/simplex-chat/simplex-chat/pull/6003/commits/cb666de51375623451a5e80dcf59449adc7d2a5f +// if focused && !field.isFocused { +// DispatchQueue.main.async { +// if !field.isFocused { +// field.becomeFirstResponder() +// } +// } +// } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 2298af614e..01a3805910 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -160,7 +160,15 @@ struct GroupMemberInfoView: View { if let connStats = connectionStats { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - // TODO network connection status + if let subStatus = connStats.subStatus { + SubStatusRow(status: subStatus) + .onTapGesture { + showAlert( + NSLocalizedString("Network status", comment: "alert title"), + message: subStatus.statusExplanation + ) + } + } Button("Change receiving address") { alert = .switchAddressAlert } @@ -396,7 +404,6 @@ struct GroupMemberInfoView: View { ItemsModel.shared.loadOpenChat(memberContact.id) { dismissAllSheets(animated: true) } - NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) } } catch let error { logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index c56d947a5a..be2c456802 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -460,12 +460,6 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { let size = dynamicSize(userFont).incognitoSize switch chat.chatInfo { - case let .direct(contact): - if contact.active, let status = contact.activeConn?.connStatus, status == .ready || status == .sndReady { - NetworkStatusView(contact: contact, size: size) - } else { - incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) - } case .group: if progressByTimeout { ProgressView() @@ -482,30 +476,6 @@ struct ChatPreviewView: View { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } } - - struct NetworkStatusView: View { - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @EnvironmentObject var theme: AppTheme - @ObservedObject var networkModel = NetworkModel.shared - - let contact: Contact - let size: CGFloat - - var body: some View { - let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize - switch (networkModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) - default: - ProgressView() - } - } - } } @ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View { diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 656cef4a04..33ffa04a50 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -63,8 +63,6 @@ struct OnboardingConditionsView: View { @State private var notificationsModeNavLinkActive = false @State private var justOpened = true - var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } } - var body: some View { GeometryReader { g in let v = ScrollView { @@ -169,9 +167,7 @@ struct OnboardingConditionsView: View { Task { do { let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId - let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let operatorIds = acceptForOperators.map { $0.operatorId } - let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds)) await MainActor.run { ChatModel.shared.conditions = r } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 1e5b4bff16..d40ec116f4 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -196,12 +196,13 @@ struct UserAddressView: View { progressIndicator = true Task { do { - if let connLinkContact = try await apiCreateUserAddress() { - DispatchQueue.main.async { + let connLinkContact = try await apiCreateUserAddress() + DispatchQueue.main.async { + if let connLinkContact { chatModel.userAddress = UserContactLink(connLinkContact) alert = .shareOnCreate - progressIndicator = false } + progressIndicator = false } } catch let error { logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index e3ca0e6a43..a59179ddfa 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Условия за ползване - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3239,6 +3239,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Грешка при създаване на адрес @@ -5134,7 +5138,7 @@ This is your link for group %@! Network status Състояние на мрежата - No comment provided by engineer. + alert title New @@ -6154,11 +6158,6 @@ Enable in *Network & servers* settings. Получено в: %@ copied message info - - Received file event - Събитие за получен файл - notification - Received message Получено съобщение @@ -6929,11 +6928,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Събитие за изпратен файл - notification - Sent message Изпратено съобщение @@ -8025,15 +8019,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8704,16 +8692,19 @@ Repeat join request? Изпрати отново заявката за присъединяване? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Поканени сте в групата No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9729,6 +9720,10 @@ pref value липсва e2e криптиране No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text няма текст diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 244cbdf946..ace3079550 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1730,7 +1730,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3105,6 +3105,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Chyba při vytváření adresy @@ -4939,7 +4943,7 @@ This is your link for group %@! Network status Stav sítě - No comment provided by engineer. + alert title New @@ -5934,11 +5938,6 @@ Enable in *Network & servers* settings. Přijato v: %@ copied message info - - Received file event - Událost přijatého souboru - notification - Received message Přijatá zpráva @@ -6695,11 +6694,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Odeslaná událost souboru - notification - Sent message Poslaná zpráva @@ -7769,15 +7763,9 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8409,16 +8397,19 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Jste pozváni do skupiny No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9416,6 +9407,10 @@ pref value bez šifrování e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text žádný text diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index e6131264af..34bbab2a6a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Nutzungsbedingungen - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2016,7 +2016,7 @@ Das ist Ihr eigener Einmal-Link! Connection is blocked by server operator: %@ - Die Verbindung wurde vom Server-Betreiber blockiert: + Die Verbindung wurde vom Serverbetreiber blockiert: %@ No comment provided by engineer. @@ -3310,6 +3310,11 @@ chat item action Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@ + subscription status explanation + Error creating address Fehler beim Erstellen der Adresse @@ -3644,6 +3649,7 @@ snd error text Error: %@. + Fehler: %@. server test error @@ -3766,7 +3772,7 @@ snd error text File is blocked by server operator: %@. - Datei wurde vom Server-Betreiber blockiert: + Die Datei wurde vom Serverbetreiber blockiert: %@. file error text @@ -3872,19 +3878,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. server test error Fingerprint in server address does not match certificate: %@. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@. No comment provided by engineer. @@ -5326,7 +5335,7 @@ Das ist Ihr Link für die Gruppe %@! Network status Netzwerkstatus - No comment provided by engineer. + alert title New @@ -6436,11 +6445,6 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Empfangen um: %@ copied message info - - Received file event - Datei-Ereignis empfangen - notification - Received message Empfangene Nachricht @@ -7056,7 +7060,7 @@ chat item action Security assessment - Sicherheits-Gutachten + Security-Gutachten No comment provided by engineer. @@ -7274,11 +7278,6 @@ chat item action Direkt gesendet No comment provided by engineer. - - Sent file event - Datei-Ereignis wurde gesendet - notification - Sent message Gesendete Nachricht @@ -7351,12 +7350,12 @@ chat item action Server requires authorization to create queues, check password. - Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error Server requires authorization to upload, check password. - Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error @@ -8477,15 +8476,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Transport-Sitzungen No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation Turkish interface @@ -9196,16 +9190,21 @@ Repeat join request? Verbindungsanfrage wiederholen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation You are invited to group Sie sind zu der Gruppe eingeladen No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt. @@ -9730,7 +9729,7 @@ Verbindungsanfrage wiederholen? blocked %@ - %@ wurde blockiert + hat %@ blockiert rcv group event chat item @@ -10265,6 +10264,11 @@ pref value Keine E2E-Verschlüsselung No comment provided by engineer. + + no subscription + Kein Abonnement + No comment provided by engineer. + no text Kein Text @@ -10509,7 +10513,7 @@ Zuletzt empfangene Nachricht: %2$@ unblocked %@ - %@ wurde freigegeben + hat %@ freigegeben rcv group event chat item @@ -10743,7 +10747,7 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2022 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. All rights reserved. + Copyright © 2025 SimpleX Chat. All rights reserved. Copyright (human-readable) @@ -10939,12 +10943,12 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstellen Sie ein Profil in der SimpleX-App + Bitte erstellen Sie in der SimpleX-App ein Profil No comment provided by engineer. Selected chat preferences prohibit this message. - Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt. + Die gewählten Chat-Einstellungen erlauben diese Nachricht nicht. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 6761ff6fce..2f5a0acbb1 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Error creating address @@ -5330,7 +5335,7 @@ This is your link for group %@! Network status Network status - No comment provided by engineer. + alert title New @@ -6440,11 +6445,6 @@ Enable in *Network & servers* settings. Received at: %@ copied message info - - Received file event - Received file event - notification - Received message Received message @@ -7278,11 +7278,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Sent file event - notification - Sent message Sent message @@ -8481,15 +8476,10 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Trying to connect to the server used to receive messages from this contact (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9200,16 +9190,21 @@ Repeat join request? Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - You are connected to the server used to receive messages from this contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group You are invited to group No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. You are not connected to these servers. Private routing is used to deliver messages to them. @@ -10269,6 +10264,11 @@ pref value no e2e encryption No comment provided by engineer. + + no subscription + no subscription + No comment provided by engineer. + no text no text diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index f11ad704ac..30c69af755 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Condiciones de uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@ + subscription status explanation + Error creating address Error al crear dirección @@ -3357,7 +3362,7 @@ chat item action Error deleting chat - Error al eliminar el chat con el miembro + Error al eliminar el chat alert title @@ -3644,6 +3649,7 @@ snd error text Error: %@. + Error: %@. server test error @@ -3872,19 +3878,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + La huella en la dirección del servidor de destino no coincide con el certificado: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Posiblemente la huella del certificado en la dirección del servidor es incorrecta + La huella en la dirección del servidor no coincide con el certificado. server test error Fingerprint in server address does not match certificate: %@. + La huella en la dirección del servidor no coincide con el certificado: %@. No comment provided by engineer. @@ -5326,7 +5335,7 @@ This is your link for group %@! Network status Estado de la red - No comment provided by engineer. + alert title New @@ -6436,11 +6445,6 @@ Actívalo en ajustes de *Servidores y Redes*. Recibido: %@ copied message info - - Received file event - Evento de archivo recibido - notification - Received message Mensaje entrante @@ -7274,11 +7278,6 @@ chat item action Directamente No comment provided by engineer. - - Sent file event - Evento de archivo enviado - notification - Sent message Mensaje saliente @@ -7351,12 +7350,12 @@ chat item action Server requires authorization to create queues, check password. - El servidor requiere autorización para crear colas, comprueba la contraseña + El servidor requiere autorización para crear colas, comprueba la contraseña. server test error Server requires authorization to upload, check password. - El servidor requiere autorización para subir, comprueba la contraseña + El servidor requiere autorización para subir, comprueba la contraseña. server test error @@ -8477,15 +8476,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.Sesiones de transporte No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Intentando conectar con el servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. + subscription status explanation Turkish interface @@ -9196,16 +9190,21 @@ Repeat join request? ¿Repetir solicitud de admisión? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Estás conectado al servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Estás conectado al servidor usado para recibir mensajes de esta conexión. + subscription status explanation You are invited to group Has sido invitado a un grupo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. @@ -9575,7 +9574,7 @@ Repeat connection request? Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. No comment provided by engineer. @@ -10265,6 +10264,11 @@ pref value sin cifrar No comment provided by engineer. + + no subscription + sin suscripciones + No comment provided by engineer. + no text sin texto diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 6e400a6078..56fa4a1485 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1624,7 +1624,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2994,6 +2994,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Virhe osoitteen luomisessa @@ -4826,7 +4830,7 @@ This is your link for group %@! Network status Verkon tila - No comment provided by engineer. + alert title New @@ -5819,11 +5823,6 @@ Enable in *Network & servers* settings. Vastaanotettu klo: %@ copied message info - - Received file event - Tiedoston vastaanottotapahtuma - notification - Received message Vastaanotettu viesti @@ -6579,11 +6578,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Lähetetty tiedosto tapahtuma - notification - Sent message Lähetetty viesti @@ -7651,15 +7645,9 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8291,16 +8279,19 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Sinut on kutsuttu ryhmään No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9298,6 +9289,10 @@ pref value ei e2e-salausta No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ei tekstiä diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index d008696a14..67485353d2 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1791,7 +1791,7 @@ set passcode view Conditions of use Conditions d'utilisation - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3288,6 +3288,10 @@ chat item action Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Erreur lors de la création de l'adresse @@ -5267,7 +5271,7 @@ Voici votre lien pour le groupe %@ ! Network status État du réseau - No comment provided by engineer. + alert title New @@ -6339,11 +6343,6 @@ Activez-le dans les paramètres *Réseau et serveurs*. Reçu le : %@ copied message info - - Received file event - Événement de fichier reçu - notification - Received message Message reçu @@ -7148,11 +7147,6 @@ chat item action Envoyé directement No comment provided by engineer. - - Sent file event - Événement de fichier envoyé - notification - Sent message Message envoyé @@ -8320,15 +8314,9 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Sessions de transport No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9026,16 +9014,19 @@ Repeat join request? Répéter la demande d'adhésion ? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Vous êtes invité·e au groupe No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Vous n'êtes pas connecté à ces serveurs. Le routage privé est utilisé pour leur délivrer des messages. @@ -10075,6 +10066,10 @@ pref value sans chiffrement de bout en bout No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text aucun texte diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index b542c6eabf..d13492c4e1 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Használati feltételek - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@ + subscription status explanation + Error creating address Hiba történt a cím létrehozásakor @@ -3357,7 +3362,7 @@ chat item action Error deleting chat - Hiba a taggal való csevegés törlésekor + Hiba a csevegés törlésekor alert title @@ -3644,6 +3649,7 @@ snd error text Error: %@. + Hiba: %@. server test error @@ -3872,19 +3878,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. server test error Fingerprint in server address does not match certificate: %@. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. No comment provided by engineer. @@ -5326,7 +5335,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Network status Hálózat állapota - No comment provided by engineer. + alert title New @@ -6436,11 +6445,6 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Fogadva: %@ copied message info - - Received file event - Fogadott fájlesemény - notification - Received message Fogadott üzenetbuborék színe @@ -7274,11 +7278,6 @@ chat item action Közvetlenül küldött No comment provided by engineer. - - Sent file event - Elküldött fájlesemény - notification - Sent message Üzenetbuborék színe @@ -7351,12 +7350,12 @@ chat item action Server requires authorization to create queues, check password. - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. server test error Server requires authorization to upload, check password. - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. server test error @@ -8477,15 +8476,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Munkamenetek átvitele No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. + + Trying to connect to the server used to receive messages from this connection. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + subscription status explanation Turkish interface @@ -9196,16 +9190,21 @@ Repeat join request? Megismétli a csatlakozási kérést? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + subscription status explanation You are invited to group Ön meghívást kapott a csoportba No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. @@ -10265,6 +10264,11 @@ pref value nincs e2e titkosítás No comment provided by engineer. + + no subscription + nincs előfizetés + No comment provided by engineer. + no text nincs szöveg diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 6ddcf7d6d1..5f057cd8bb 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Condizioni d'uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Errore di connessione al server di inoltro %@. Riprova più tardi. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Errore di connessione al server usato per ricevere messaggi da questa connessione: %@ + subscription status explanation + Error creating address Errore nella creazione dell'indirizzo @@ -3644,6 +3649,7 @@ snd error text Error: %@. + Errore: %@. server test error @@ -3872,19 +3878,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata + L'impronta digitale nell'indirizzo del server non corrisponde al certificato. server test error Fingerprint in server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@. No comment provided by engineer. @@ -5326,7 +5335,7 @@ Questo è il tuo link per il gruppo %@! Network status Stato della rete - No comment provided by engineer. + alert title New @@ -6436,11 +6445,6 @@ Attivalo nelle impostazioni *Rete e server*. Ricevuto il: %@ copied message info - - Received file event - Evento file ricevuto - notification - Received message Messaggio ricevuto @@ -7274,11 +7278,6 @@ chat item action Inviato direttamente No comment provided by engineer. - - Sent file event - Evento file inviato - notification - Sent message Messaggio inviato @@ -7351,12 +7350,12 @@ chat item action Server requires authorization to create queues, check password. - Il server richiede l'autorizzazione di creare code, controlla la password + Il server richiede l'autorizzazione di creare code, controlla la password. server test error Server requires authorization to upload, check password. - Il server richiede l'autorizzazione per il caricamento, controllare la password + Il server richiede l'autorizzazione per l'invio, controlla la password. server test error @@ -8477,15 +8476,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Sessioni di trasporto No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. + subscription status explanation Turkish interface @@ -9196,16 +9190,21 @@ Repeat join request? Ripetere la richiesta di ingresso? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sei connesso/a al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + subscription status explanation You are invited to group Sei stato/a invitato/a al gruppo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi. @@ -10265,6 +10264,11 @@ pref value nessuna crittografia e2e No comment provided by engineer. + + no subscription + nessuna iscrizione + No comment provided by engineer. + no text nessun testo diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 0ca54bb3d9..9a42ab3f7e 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1689,7 +1689,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3076,6 +3076,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address アドレス作成にエラー発生 @@ -4910,7 +4914,7 @@ This is your link for group %@! Network status ネットワーク状況 - No comment provided by engineer. + alert title New @@ -5905,11 +5909,6 @@ Enable in *Network & servers* settings. 受信: %@ copied message info - - Received file event - ファイル受信イベント - notification - Received message 受信したメッセージ @@ -6658,11 +6657,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - 送信済みファイルイベント - notification - Sent message 送信 @@ -7730,15 +7724,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - このコンタクトから受信するメッセージのサーバに接続しようとしてます。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8370,16 +8358,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - この連絡先から受信するメッセージのサーバに既に接続してます。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group グループ招待が届きました No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9378,6 +9369,10 @@ pref value エンドツーエンド暗号化がありません No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text テキストなし diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index a13e4cd80d..8e0cdee3ca 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1791,7 +1791,7 @@ set passcode view Conditions of use Gebruiksvoorwaarden - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3290,6 +3290,10 @@ chat item action Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Fout bij aanmaken van adres @@ -5294,7 +5298,7 @@ Dit is jouw link voor groep %@! Network status Netwerk status - No comment provided by engineer. + alert title New @@ -6390,11 +6394,6 @@ Schakel dit in in *Netwerk en servers*-instellingen. Ontvangen op: %@ copied message info - - Received file event - Ontvangen bestandsgebeurtenis - notification - Received message Ontvangen bericht @@ -7219,11 +7218,6 @@ chat item action Direct verzonden No comment provided by engineer. - - Sent file event - Verzonden bestandsgebeurtenis - notification - Sent message Verzonden bericht @@ -8404,15 +8398,9 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Transportsessies No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9115,16 +9103,19 @@ Repeat join request? Deelnameverzoek herhalen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Je bent uitgenodigd voor de groep No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. U bent niet verbonden met deze servers. Privéroutering wordt gebruikt om berichten bij hen af te leveren. @@ -10177,6 +10168,10 @@ pref value geen e2e versleuteling No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text geen tekst diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index d08a7d86e5..fb46bcd1d9 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1775,7 +1775,7 @@ set passcode view Conditions of use Warunki użytkowania - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3237,6 +3237,10 @@ chat item action Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Błąd tworzenia adresu @@ -5183,7 +5187,7 @@ To jest twój link do grupy %@! Network status Status sieci - No comment provided by engineer. + alert title New @@ -6237,11 +6241,6 @@ Włącz w ustawianiach *Sieć i serwery* . Otrzymane o: %@ copied message info - - Received file event - Otrzymano zdarzenie pliku - notification - Received message Otrzymano wiadomość @@ -7045,11 +7044,6 @@ chat item action Wysłano bezpośrednio No comment provided by engineer. - - Sent file event - Wyślij zdarzenie pliku - notification - Sent message Wyślij wiadomość @@ -8191,15 +8185,9 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Sesje transportowe No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8888,16 +8876,19 @@ Repeat join request? Powtórzyć prośbę dołączenia? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Jesteś zaproszony do grupy No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -9933,6 +9924,10 @@ pref value brak szyfrowania e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text brak tekstu diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 568ca53946..d885db1350 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Условия использования - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,10 @@ chat item action Ошибка подключения к пересылающему серверу %@. Попробуйте позже. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Ошибка при создании адреса @@ -3883,7 +3887,7 @@ snd error text Fingerprint in server address does not match certificate. - Возможно, хэш сертификата в адресе сервера неверный + Возможно, хэш сертификата в адресе сервера неверный. server test error @@ -5293,7 +5297,7 @@ This is your link for group %@! Network & servers - Сеть & серверы + Сеть и серверы No comment provided by engineer. @@ -5329,7 +5333,7 @@ This is your link for group %@! Network status Состояние сети - No comment provided by engineer. + alert title New @@ -6439,11 +6443,6 @@ Enable in *Network & servers* settings. Получено: %@ copied message info - - Received file event - Загрузка файла - notification - Received message Полученное сообщение @@ -6823,7 +6822,7 @@ swipe action Review members before admitting ("knocking"). - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. admission stage description @@ -7277,11 +7276,6 @@ chat item action Отправлено напрямую No comment provided by engineer. - - Sent file event - Отправка файла - notification - Sent message Отправленное сообщение @@ -7354,12 +7348,12 @@ chat item action Server requires authorization to create queues, check password. - Сервер требует авторизации для создания очередей, проверьте пароль + Сервер требует авторизации для создания очередей, проверьте пароль. server test error Server requires authorization to upload, check password. - Сервер требует авторизации для загрузки, проверьте пароль + Сервер требует авторизации для загрузки, проверьте пароль. server test error @@ -8003,7 +7997,7 @@ report reason Tap Connect to use bot - Нажмите Соединиться, чтобы использовать бот. + Нажмите Соединиться, чтобы использовать бот No comment provided by engineer. @@ -8480,15 +8474,9 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортные сессии No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9199,16 +9187,19 @@ Repeat join request? Повторить запрос на вступление? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Вы приглашены в группу No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка. @@ -10268,6 +10259,10 @@ pref value нет e2e шифрования No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text нет текста diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index a0ab509388..ecb4d20fbb 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1616,7 +1616,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2980,6 +2980,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address เกิดข้อผิดพลาดในการสร้างที่อยู่ @@ -4808,7 +4812,7 @@ This is your link for group %@! Network status สถานะเครือข่าย - No comment provided by engineer. + alert title New @@ -5797,11 +5801,6 @@ Enable in *Network & servers* settings. ได้รับเมื่อ: %@ copied message info - - Received file event - ได้รับไฟล์ - notification - Received message ได้รับข้อความ @@ -6554,11 +6553,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - เหตุการณ์ไฟล์ที่ส่ง - notification - Sent message ข้อความที่ส่งแล้ว @@ -7623,15 +7617,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@) - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8261,16 +8249,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group คุณได้รับเชิญให้เข้าร่วมกลุ่ม No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9265,6 +9256,10 @@ pref value ไม่มีการ encrypt จากต้นจนจบ No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ไม่มีข้อความ diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 8fb8e4ac51..57151a95b5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Kullanım koşulları - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,10 @@ chat item action Yönlendirme sunucusu %@'ya bağlanırken hata oluştu. Lütfen daha sonra deneyin. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Adres oluşturulurken hata oluştu @@ -5326,7 +5330,7 @@ Bu senin grup için bağlantın %@! Network status Ağ durumu - No comment provided by engineer. + alert title New @@ -6436,11 +6440,6 @@ Enable in *Network & servers* settings. Şuradan alındı: %@ copied message info - - Received file event - Dosya etkinliği alındı - notification - Received message Mesaj alındı @@ -7274,11 +7273,6 @@ chat item action Direkt gönderildi No comment provided by engineer. - - Sent file event - Dosya etkinliği gönderildi - notification - Sent message Mesaj gönderildi @@ -8477,15 +8471,9 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Taşıma oturumları No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9196,16 +9184,19 @@ Repeat join request? Katılma isteği tekrarlansın mı? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Gruba davet edildiniz No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Bu sunuculara bağlı değilsiniz. Mesajları onlara iletmek için özel yönlendirme kullanılır. @@ -10265,6 +10256,10 @@ pref value uçtan uca şifreleme yok No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text metin yok diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index bfb565fd65..7980685349 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1798,7 +1798,7 @@ set passcode view Conditions of use Умови використання - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3304,6 +3304,10 @@ chat item action Помилка підключення до сервера переадресації %@. Спробуйте пізніше. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Помилка створення адреси @@ -3872,7 +3876,7 @@ snd error text Fingerprint in server address does not match certificate. - Можливо, в адресі сервера неправильно вказано відбиток сертифіката + Відбиток в адресі сервера не співпадає з сертифікатом. server test error @@ -5316,7 +5320,7 @@ This is your link for group %@! Network status Стан мережі - No comment provided by engineer. + alert title New @@ -6421,11 +6425,6 @@ Enable in *Network & servers* settings. Отримано за: %@ copied message info - - Received file event - Подія отримання файлу - notification - Received message Отримано повідомлення @@ -7258,11 +7257,6 @@ chat item action Відправлено напряму No comment provided by engineer. - - Sent file event - Подія надісланого файлу - notification - Sent message Надіслано повідомлення @@ -7335,12 +7329,12 @@ chat item action Server requires authorization to create queues, check password. - Сервер вимагає авторизації для створення черг, перевірте пароль + Сервер вимагає авторизації для створення черг, перевірте пароль. server test error Server requires authorization to upload, check password. - Сервер вимагає авторизації для завантаження, перевірте пароль + Сервер вимагає авторизації для завантаження, перевірте пароль. server test error @@ -8457,15 +8451,9 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортні сесії No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9176,16 +9164,19 @@ Repeat join request? Повторити запит на приєднання? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Запрошуємо вас до групи No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Не підключені до цих серверів. Для доставлення повідомлень до них використовується приватна маршрутизація. @@ -10245,6 +10236,10 @@ pref value без шифрування e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text без тексту diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 0a2a252826..e1ce65b5ce 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1785,7 +1785,7 @@ set passcode view Conditions of use 使用条款 - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3280,6 +3280,10 @@ chat item action 连接到转发服务器 %@ 时出错。请稍后尝试。 alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address 创建地址错误 @@ -5281,7 +5285,7 @@ This is your link for group %@! Network status 网络状态 - No comment provided by engineer. + alert title New @@ -6362,11 +6366,6 @@ Enable in *Network & servers* settings. 已收到于:%@ copied message info - - Received file event - 收到文件项目 - notification - Received message 收到的信息 @@ -7169,11 +7168,6 @@ chat item action 直接发送 No comment provided by engineer. - - Sent file event - 已发送文件项目 - notification - Sent message 已发信息 @@ -8313,15 +8307,9 @@ You will be prompted to complete authentication before this feature is enabled.< 传输会话 No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - 正在尝试连接到用于从该联系人接收消息的服务器。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9008,16 +8996,19 @@ Repeat join request? 重复加入请求? new chat sheet title - - You are connected to the server used to receive messages from this contact. - 您已连接到用于接收该联系人消息的服务器。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group 您被邀请加入群组 No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. 您未连接到这些服务器。私有路由用于向他们发送消息。 @@ -10048,6 +10039,10 @@ pref value 无端到端加密 No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text 无文本 diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index efed487739..5d619ac130 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -1196,8 +1196,6 @@ func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { groupInfo.chatSettings.enableNtfs == .all ? .connectionEvent(user, connEntity) : .noNtf - case .sndFileConnection: .noNtf - case .rcvFileConnection: .noNtf case let .userContactConnection(_, userContact): userContact.groupId == nil ? .connectionEvent(user, connEntity) diff --git a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings index 6cc768efe1..2ea2a332d4 100644 --- a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX NSE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. All rights reserved."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index 4c10694986..ed96f44a15 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -77,10 +77,10 @@ "Passphrase" = "Passwort"; /* No comment provided by engineer. */ -"Please create a profile in the SimpleX app" = "Bitte erstellen Sie ein Profil in der SimpleX-App"; +"Please create a profile in the SimpleX app" = "Bitte erstellen Sie in der SimpleX-App ein Profil"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; +"Selected chat preferences prohibit this message." = "Die gewählten Chat-Einstellungen erlauben diese Nachricht nicht."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Das Senden einer Nachricht dauert länger als erwartet."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2c9d662c7d..b3ec79d125 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -555,8 +555,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -718,8 +718,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -805,8 +805,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.5.1-B5MqGjdGsDB2dMXvZ9bdUJ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.2-6qgCrd2M1igHbyytG3wCNC.a */, ); path = Libraries; sourceTree = ""; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2038,7 +2038,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2063,7 +2063,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2088,7 +2088,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2105,11 +2105,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2125,11 +2125,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2150,7 +2150,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2165,7 +2165,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2187,7 +2187,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2202,7 +2202,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2224,7 +2224,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2250,7 +2250,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2275,7 +2275,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2301,7 +2301,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2326,7 +2326,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2341,7 +2341,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2360,7 +2360,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 298; + CURRENT_PROJECT_VERSION = 300; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2375,7 +2375,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.5; + MARKETING_VERSION = 6.4.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 34ce1cf84f..fce0f100f2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -545,6 +545,7 @@ public struct ConnectionStats: Decodable, Hashable { public var sndQueuesInfo: [SndQueueInfo] public var ratchetSyncState: RatchetSyncState public var ratchetSyncSupported: Bool + public var subStatus: SubscriptionStatus? public var ratchetSyncAllowed: Bool { ratchetSyncSupported && [.allowed, .required].contains(ratchetSyncState) @@ -559,25 +560,28 @@ public struct ConnectionStats: Decodable, Hashable { } } -public struct RcvQueueInfo: Codable, Hashable { +public struct RcvQueueInfo: Decodable, Hashable { public var rcvServer: String + public var status: QueueStatus public var rcvSwitchStatus: RcvSwitchStatus? public var canAbortSwitch: Bool + public var subStatus: SubscriptionStatus } -public enum RcvSwitchStatus: String, Codable, Hashable { +public enum RcvSwitchStatus: String, Decodable, Hashable { case switchStarted = "switch_started" case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" } -public struct SndQueueInfo: Codable, Hashable { +public struct SndQueueInfo: Decodable, Hashable { public var sndServer: String + public var status: QueueStatus public var sndSwitchStatus: SndSwitchStatus? } -public enum SndSwitchStatus: String, Codable, Hashable { +public enum SndSwitchStatus: String, Decodable, Hashable { case sendingQKEY = "sending_qkey" case sendingQTEST = "sending_qtest" } @@ -606,6 +610,48 @@ public enum RatchetSyncState: String, Decodable { case agreed } +public enum QueueStatus: String, Decodable, Hashable { + case new + case confirmed + case secured + case active + case disabled +} + +public enum SubscriptionStatus: Decodable, Hashable { + case active + case pending + case removed(subError: String) + case noSub + + public var statusString: LocalizedStringKey { + switch self { + case .active: "connected" + case .pending: "connecting" + case .removed: "error" + case .noSub: "no subscription" + } + } + + public var statusExplanation: String { + switch self { + case .active: NSLocalizedString("You are connected to the server used to receive messages from this connection.", comment: "subscription status explanation") + case .pending: NSLocalizedString("Trying to connect to the server used to receive messages from this connection.", comment: "subscription status explanation") + case let .removed(err): String.localizedStringWithFormat(NSLocalizedString("Error connecting to the server used to receive messages from this connection: %@", comment: "subscription status explanation"), err) + case .noSub: NSLocalizedString("You are not connected to the server used to receive messages from this connection (no subscription).", comment: "subscription status explanation") + } + } + + public var imageName: String { + switch self { + case .active: "circle.fill" + case .pending: "ellipsis.circle.fill" + case .removed: "exclamationmark.circle.fill" + case .noSub: "circle.dotted" + } + } +} + public protocol SelectableItem: Identifiable, Equatable { var label: LocalizedStringKey { get } static var values: [Self] { get } @@ -714,7 +760,6 @@ public enum ChatErrorType: Decodable, Hashable { case fileCancelled(message: String) case fileCancel(fileId: Int64, message: String) case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) case fileWrite(filePath: String, message: String) case fileSend(fileId: Int64, agentError: String) case fileRcvChunk(message: String) @@ -834,6 +879,7 @@ public enum AgentErrorType: Decodable, Hashable { case RCP(rcpErr: RCErrorType) case BROKER(brokerAddress: String, brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) + case NOTICE(server: String, preset: Bool, expiresAt: Date?) case INTERNAL(internalErr: String) case CRITICAL(offerRestart: Bool, criticalErr: String) case INACTIVE diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9695e8e911..1e9fbf15e6 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2086,11 +2086,6 @@ public struct ContactRef: Decodable, Equatable, Hashable { public var id: ChatId { get { "@\(contactId)" } } } -public struct ContactSubStatus: Decodable, Hashable { - public var contact: Contact - public var contactError: ChatError? -} - public struct Connection: Decodable, Hashable { public var connId: Int64 public var agentConnId: String @@ -2866,16 +2861,9 @@ public enum InvitedBy: Decodable, Hashable { case unknown } -public struct MemberSubError: Decodable, Hashable { - var member: GroupMemberIds - var memberError: ChatError -} - public enum ConnectionEntity: Decodable, Hashable { case rcvDirectMsgConnection(entityConnection: Connection, contact: Contact?) case rcvGroupMsgConnection(entityConnection: Connection, groupInfo: GroupInfo, groupMember: GroupMember) - case sndFileConnection(entityConnection: Connection, sndFileTransfer: SndFileTransfer) - case rcvFileConnection(entityConnection: Connection, rcvFileTransfer: RcvFileTransfer) case userContactConnection(entityConnection: Connection, userContact: UserContact) public var id: String? { @@ -2886,8 +2874,6 @@ public enum ConnectionEntity: Decodable, Hashable { groupMember.id case let .userContactConnection(_, userContact): userContact.id - default: - nil } } @@ -2908,8 +2894,6 @@ public enum ConnectionEntity: Decodable, Hashable { switch self { case let .rcvDirectMsgConnection(entityConnection, _): entityConnection case let .rcvGroupMsgConnection(entityConnection, _, _): entityConnection - case let .sndFileConnection(entityConnection, _): entityConnection - case let .rcvFileConnection(entityConnection, _): entityConnection case let .userContactConnection(entityConnection, _): entityConnection } } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 70db4476d5..31b7ef83ff 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -111,10 +111,6 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: hideContent) body = NSLocalizedString("message received", comment: "notification") targetContentIdentifier = groupInfo.id - case .sndFileConnection: - title = NSLocalizedString("Sent file event", comment: "notification") - case .rcvFileConnection: - title = NSLocalizedString("Received file event", comment: "notification") case .userContactConnection: title = NSLocalizedString("New contact request", comment: "notification") } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 1ce7b53767..038546f889 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1167,7 +1167,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Условията вече са приети за тези оператори: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Условия за ползване"; /* No comment provided by engineer. */ @@ -2912,7 +2912,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Мрежови настройки"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Състояние на мрежата"; /* delete after time */ @@ -3361,9 +3361,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено потвърждение…"; -/* notification */ -"Received file event" = "Събитие за получен файл"; - /* message info title */ "Received message" = "Получено съобщение"; @@ -3712,9 +3709,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Изпратено на: %@"; -/* notification */ -"Sent file event" = "Събитие за изпратен файл"; - /* message info title */ "Sent message" = "Изпратено съобщение"; @@ -4130,12 +4124,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Транспортна изолация"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "Turkish interface" = "Турски интерфейс"; @@ -4493,9 +4481,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вече се присъединихте към групата!\nИзпрати отново заявката за присъединяване?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "You are invited to group" = "Поканени сте в групата"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index f8597fa4a5..dd486001c7 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -2307,7 +2307,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Nastavení sítě"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Stav sítě"; /* delete after time */ @@ -2678,9 +2678,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "obdržel potvrzení…"; -/* notification */ -"Received file event" = "Událost přijatého souboru"; - /* message info title */ "Received message" = "Přijatá zpráva"; @@ -2975,9 +2972,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Posláno v: % @"; -/* notification */ -"Sent file event" = "Odeslaná událost souboru"; - /* message info title */ "Sent message" = "Poslaná zpráva"; @@ -3318,12 +3312,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Izolace transportu"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "Turn off" = "Vypnout"; @@ -3552,9 +3540,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Již jste připojeni k %@."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "You are invited to group" = "Jste pozváni do skupiny"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 3008d29608..caf58399de 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -849,7 +849,7 @@ swipe action */ "blocked" = "Blockiert"; /* rcv group event chat item */ -"blocked %@" = "%@ wurde blockiert"; +"blocked %@" = "hat %@ blockiert"; /* blocked chat item marked deleted chat item preview text */ @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Die Nutzungsbedingungen der/des folgenden Betreiber(s) wurden schon akzeptiert: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Nutzungsbedingungen"; /* No comment provided by engineer. */ @@ -1348,7 +1348,7 @@ set passcode view */ "connection established" = "Verbindung hergestellt"; /* No comment provided by engineer. */ -"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Server-Betreiber blockiert:\n%@"; +"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Serverbetreiber blockiert:\n%@"; /* No comment provided by engineer. */ "Connection not ready." = "Verbindung noch nicht bereit."; @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Fehler beim Erstellen der Adresse"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Fehler: %@"; +/* server test error */ +"Error: %@." = "Fehler: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Fehler: Keine Datenbankdatei"; @@ -2511,7 +2517,7 @@ snd error text */ "File errors:\n%@" = "Datei-Fehler:\n%@"; /* file error text */ -"File is blocked by server operator:\n%@." = "Datei wurde vom Server-Betreiber blockiert:\n%@."; +"File is blocked by server operator:\n%@." = "Die Datei wurde vom Serverbetreiber blockiert:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -2573,8 +2579,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Chats schneller finden"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig"; +"Fingerprint in server address does not match certificate." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein."; /* No comment provided by engineer. */ "Fix" = "Reparieren"; @@ -3539,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Netzwerkeinstellungen"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Netzwerkstatus"; /* delete after time */ @@ -3698,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Keine Server für das Versenden von Dateien."; +/* No comment provided by engineer. */ +"no subscription" = "Kein Abonnement"; + /* copied message info in history */ "no text" = "Kein Text"; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "Bestätigung erhalten…"; -/* notification */ -"Received file event" = "Datei-Ereignis empfangen"; - /* message info title */ "Received message" = "Empfangene Nachricht"; @@ -4697,7 +4712,7 @@ chat item action */ "Secured" = "Abgesichert"; /* No comment provided by engineer. */ -"Security assessment" = "Sicherheits-Gutachten"; +"Security assessment" = "Security-Gutachten"; /* No comment provided by engineer. */ "Security code" = "Sicherheitscode"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gesendet"; -/* notification */ -"Sent file event" = "Datei-Ereignis wurde gesendet"; - /* message info title */ "Sent message" = "Gesendete Nachricht"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "Server-Warteschlangen-Information: %1$@\n\nZuletzt empfangene Nachricht: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort"; +"Server requires authorization to create queues, check password." = "Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort."; /* server test error */ -"Server requires authorization to upload, check password." = "Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung"; +"Server requires authorization to upload, check password." = "Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort."; /* No comment provided by engineer. */ "Server test failed!" = "Server Test ist fehlgeschlagen!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transport-Sitzungen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "Turkish interface" = "Türkische Bedienoberfläche"; @@ -5611,7 +5620,7 @@ report reason */ "Unblock member?" = "Mitglied freigeben?"; /* rcv group event chat item */ -"unblocked %@" = "%@ wurde freigegeben"; +"unblocked %@" = "hat %@ freigegeben"; /* No comment provided by engineer. */ "Undelivered messages" = "Nicht ausgelieferte Nachrichten"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Sie sind bereits Mitglied dieser Gruppe!\nVerbindungsanfrage wiederholen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "You are invited to group" = "Sie sind zu der Gruppe eingeladen"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 6c8232fb63..5ce7ab6843 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Las condiciones ya se han aceptado para el/los siguiente(s) operador(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condiciones de uso"; /* No comment provided by engineer. */ @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Error al crear dirección"; @@ -2266,7 +2269,7 @@ chat item action */ "Error decrypting file" = "Error al descifrar el archivo"; /* alert title */ -"Error deleting chat" = "Error al eliminar el chat con el miembro"; +"Error deleting chat" = "Error al eliminar el chat"; /* alert title */ "Error deleting chat database" = "Error al eliminar base de datos"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Error: %@"; +/* server test error */ +"Error: %@." = "Error: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Error: sin archivo de base de datos"; @@ -2573,8 +2579,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Encuentra chats mas rápido"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "La huella en la dirección del servidor de destino no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "La huella en la dirección del servidor de reenvío no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "La huella en la dirección del servidor no coincide con el certificado: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Posiblemente la huella del certificado en la dirección del servidor es incorrecta"; +"Fingerprint in server address does not match certificate." = "La huella en la dirección del servidor no coincide con el certificado."; /* No comment provided by engineer. */ "Fix" = "Reparar"; @@ -3539,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Configuración de red"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Estado de la red"; /* delete after time */ @@ -3698,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Sin servidores para enviar archivos."; +/* No comment provided by engineer. */ +"no subscription" = "sin suscripciones"; + /* copied message info in history */ "no text" = "sin texto"; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confirmación recibida…"; -/* notification */ -"Received file event" = "Evento de archivo recibido"; - /* message info title */ "Received message" = "Mensaje entrante"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Directamente"; -/* notification */ -"Sent file event" = "Evento de archivo enviado"; - /* message info title */ "Sent message" = "Mensaje saliente"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "información cola del servidor: %1$@\n\núltimo mensaje recibido: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "El servidor requiere autorización para crear colas, comprueba la contraseña"; +"Server requires authorization to create queues, check password." = "El servidor requiere autorización para crear colas, comprueba la contraseña."; /* server test error */ -"Server requires authorization to upload, check password." = "El servidor requiere autorización para subir, comprueba la contraseña"; +"Server requires authorization to upload, check password." = "El servidor requiere autorización para subir, comprueba la contraseña."; /* No comment provided by engineer. */ "Server test failed!" = "¡Prueba no superada!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sesiones de transporte"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Intentando conectar con el servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaz en turco"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "¡En proceso de unirte al grupo!\n¿Repetir solicitud de admisión?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Estás conectado al servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Estás conectado al servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "You are invited to group" = "Has sido invitado a un grupo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; @@ -6334,7 +6346,7 @@ report reason */ "Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 76e4c1be0d..884be40cc1 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1983,7 +1983,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Verkkoasetukset"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Verkon tila"; /* delete after time */ @@ -2348,9 +2348,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "vahvistus saatu…"; -/* notification */ -"Received file event" = "Tiedoston vastaanottotapahtuma"; - /* message info title */ "Received message" = "Vastaanotettu viesti"; @@ -2642,9 +2639,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Lähetetty klo: %@"; -/* notification */ -"Sent file event" = "Lähetetty tiedosto tapahtuma"; - /* message info title */ "Sent message" = "Lähetetty viesti"; @@ -2979,12 +2973,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Kuljetuksen eristäminen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta."; - /* No comment provided by engineer. */ "Turn off" = "Sammuta"; @@ -3213,9 +3201,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta."; - /* No comment provided by engineer. */ "You are invited to group" = "Sinut on kutsuttu ryhmään"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 2a210cbfa8..dbfac375d1 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1143,7 +1143,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Conditions d'utilisation"; /* No comment provided by engineer. */ @@ -3323,7 +3323,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "État du réseau"; /* delete after time */ @@ -3925,9 +3925,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confimation reçu…"; -/* notification */ -"Received file event" = "Événement de fichier reçu"; - /* message info title */ "Received message" = "Message reçu"; @@ -4378,9 +4375,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Envoyé directement"; -/* notification */ -"Sent file event" = "Événement de fichier envoyé"; - /* message info title */ "Sent message" = "Message envoyé"; @@ -5030,12 +5024,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sessions de transport"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "Turkish interface" = "Interface en turc"; @@ -5486,9 +5474,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Vous êtes déjà membre de ce groupe !\nRépéter la demande d'adhésion ?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "You are invited to group" = "Vous êtes invité·e au groupe"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index bb4d4b1eca..08ebb0415b 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "A feltételek már el lettek fogadva a következő üzemeltető(k) számára: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Használati feltételek"; /* No comment provided by engineer. */ @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Hiba történt a cím létrehozásakor"; @@ -2266,7 +2269,7 @@ chat item action */ "Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* alert title */ -"Error deleting chat" = "Hiba a taggal való csevegés törlésekor"; +"Error deleting chat" = "Hiba a csevegés törlésekor"; /* alert title */ "Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Hiba: %@"; +/* server test error */ +"Error: %@." = "Hiba: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Hiba: nincs adatbázisfájl"; @@ -2573,8 +2579,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Csevegési üzenetek gyorsabb megtalálása"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen"; +"Fingerprint in server address does not match certificate." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal."; /* No comment provided by engineer. */ "Fix" = "Javítás"; @@ -3539,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Hálózati beállítások"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Hálózat állapota"; /* delete after time */ @@ -3698,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Nincsenek fájlküldő-kiszolgálók."; +/* No comment provided by engineer. */ +"no subscription" = "nincs előfizetés"; + /* copied message info in history */ "no text" = "nincs szöveg"; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "visszaigazolás fogadása…"; -/* notification */ -"Received file event" = "Fogadott fájlesemény"; - /* message info title */ "Received message" = "Fogadott üzenetbuborék színe"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Közvetlenül küldött"; -/* notification */ -"Sent file event" = "Elküldött fájlesemény"; - /* message info title */ "Sent message" = "Üzenetbuborék színe"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló várólista információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát"; +"Server requires authorization to create queues, check password." = "A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát."; /* server test error */ -"Server requires authorization to upload, check password." = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát"; +"Server requires authorization to upload, check password." = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát."; /* No comment provided by engineer. */ "Server test failed!" = "Sikertelen kiszolgáló teszt!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "Turkish interface" = "Török kezelőfelület"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "You are invited to group" = "Ön meghívást kapott a csoportba"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 2eba58ab47..511a6835e5 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Le condizioni sono già state accettate per i seguenti operatori: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condizioni d'uso"; /* No comment provided by engineer. */ @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Errore di connessione al server di inoltro %@. Riprova più tardi."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Errore di connessione al server usato per ricevere messaggi da questa connessione: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Errore nella creazione dell'indirizzo"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Errore: %@"; +/* server test error */ +"Error: %@." = "Errore: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Errore: nessun file di database"; @@ -2573,8 +2579,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Trova le chat più velocemente"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata"; +"Fingerprint in server address does not match certificate." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato."; /* No comment provided by engineer. */ "Fix" = "Correggi"; @@ -3539,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Impostazioni di rete"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Stato della rete"; /* delete after time */ @@ -3698,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Nessun server per inviare file."; +/* No comment provided by engineer. */ +"no subscription" = "nessuna iscrizione"; + /* copied message info in history */ "no text" = "nessun testo"; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "conferma ricevuta…"; -/* notification */ -"Received file event" = "Evento file ricevuto"; - /* message info title */ "Received message" = "Messaggio ricevuto"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Inviato direttamente"; -/* notification */ -"Sent file event" = "Evento file inviato"; - /* message info title */ "Sent message" = "Messaggio inviato"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "info coda server: %1$@\n\nultimo msg ricevuto: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Il server richiede l'autorizzazione di creare code, controlla la password"; +"Server requires authorization to create queues, check password." = "Il server richiede l'autorizzazione di creare code, controlla la password."; /* server test error */ -"Server requires authorization to upload, check password." = "Il server richiede l'autorizzazione per il caricamento, controllare la password"; +"Server requires authorization to upload, check password." = "Il server richiede l'autorizzazione per l'invio, controlla la password."; /* No comment provided by engineer. */ "Server test failed!" = "Test del server fallito!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sessioni di trasporto"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Tentativo di connessione al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaccia in turco"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Stai già entrando nel gruppo!\nRipetere la richiesta di ingresso?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sei connesso/a al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sei connesso/a al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "You are invited to group" = "Sei stato/a invitato/a al gruppo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index de2f23ccce..d4510af72f 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -2223,7 +2223,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "ネットワーク設定"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "ネットワーク状況"; /* delete after time */ @@ -2594,9 +2594,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "確認を受け取りました…"; -/* notification */ -"Received file event" = "ファイル受信イベント"; - /* message info title */ "Received message" = "受信したメッセージ"; @@ -2867,9 +2864,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "送信日時: %@"; -/* notification */ -"Sent file event" = "送信済みファイルイベント"; - /* message info title */ "Sent message" = "送信"; @@ -3204,12 +3198,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "トランスポート隔離"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "このコンタクトから受信するメッセージのサーバに接続しようとしてます。"; - /* No comment provided by engineer. */ "Turn off" = "オフにする"; @@ -3438,9 +3426,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "すでに %@ に接続されています。"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "この連絡先から受信するメッセージのサーバに既に接続してます。"; - /* No comment provided by engineer. */ "You are invited to group" = "グループ招待が届きました"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 1d5fef3fe3..79e3da3b01 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1158,7 +1158,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Voorwaarden zijn reeds geaccepteerd voor de volgende operator(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Gebruiksvoorwaarden"; /* No comment provided by engineer. */ @@ -3437,7 +3437,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Netwerk instellingen"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Netwerk status"; /* delete after time */ @@ -4123,9 +4123,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "bevestiging ontvangen…"; -/* notification */ -"Received file event" = "Ontvangen bestandsgebeurtenis"; - /* message info title */ "Received message" = "Ontvangen bericht"; @@ -4651,9 +4648,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direct verzonden"; -/* notification */ -"Sent file event" = "Verzonden bestandsgebeurtenis"; - /* message info title */ "Sent message" = "Verzonden bericht"; @@ -5343,12 +5337,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transportsessies"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "Turkish interface" = "Turkse interface"; @@ -5817,9 +5805,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Je sluit je al aan bij de groep!\nDeelnameverzoek herhalen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "You are invited to group" = "Je bent uitgenodigd voor de groep"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index cf08c2b40a..34c79eeef4 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1098,7 +1098,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Warunki użytkowania"; /* No comment provided by engineer. */ @@ -3083,7 +3083,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Ustawienia sieci"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Status sieci"; /* delete after time */ @@ -3631,9 +3631,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "otrzymano potwierdzenie…"; -/* notification */ -"Received file event" = "Otrzymano zdarzenie pliku"; - /* message info title */ "Received message" = "Otrzymano wiadomość"; @@ -4078,9 +4075,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Wysłano bezpośrednio"; -/* notification */ -"Sent file event" = "Wyślij zdarzenie pliku"; - /* message info title */ "Sent message" = "Wyślij wiadomość"; @@ -4655,12 +4649,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sesje transportowe"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu."; - /* No comment provided by engineer. */ "Turkish interface" = "Turecki interfejs"; @@ -5084,9 +5072,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu."; - /* No comment provided by engineer. */ "You are invited to group" = "Jesteś zaproszony do grupy"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b2bdf39086..0826bca4a3 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Условия уже приняты для следующих оператора(ов): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Условия использования"; /* No comment provided by engineer. */ @@ -2586,7 +2586,7 @@ snd error text */ "Fingerprint in server address does not match certificate: %@." = "Хэш в адресе сервера не соответствует сертификату: %@."; /* server test error */ -"Fingerprint in server address does not match certificate." = "Возможно, хэш сертификата в адресе сервера неверный"; +"Fingerprint in server address does not match certificate." = "Возможно, хэш сертификата в адресе сервера неверный."; /* No comment provided by engineer. */ "Fix" = "Починить"; @@ -3531,7 +3531,7 @@ snd error text */ "Name" = "Имя"; /* No comment provided by engineer. */ -"Network & servers" = "Сеть & серверы"; +"Network & servers" = "Сеть и серверы"; /* No comment provided by engineer. */ "Network connection" = "Интернет-соединение"; @@ -3551,7 +3551,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Настройки сети"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Состояние сети"; /* delete after time */ @@ -4279,9 +4279,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено подтверждение…"; -/* notification */ -"Received file event" = "Загрузка файла"; - /* message info title */ "Received message" = "Полученное сообщение"; @@ -4552,7 +4549,7 @@ swipe action */ "Review members" = "Одобрять членов"; /* admission stage description */ -"Review members before admitting (\"knocking\")." = "Одобрять членов для вступления в группу."; +"Review members before admitting (\"knocking\")." = "Вручную одобрять членов для вступления в группу."; /* No comment provided by engineer. */ "reviewed by admins" = "одобрен админами"; @@ -4843,9 +4840,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Отправлено напрямую"; -/* notification */ -"Sent file event" = "Отправка файла"; - /* message info title */ "Sent message" = "Отправленное сообщение"; @@ -4892,10 +4886,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "информация сервера об очереди: %1$@\n\nпоследнее полученное сообщение: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Сервер требует авторизации для создания очередей, проверьте пароль"; +"Server requires authorization to create queues, check password." = "Сервер требует авторизации для создания очередей, проверьте пароль."; /* server test error */ -"Server requires authorization to upload, check password." = "Сервер требует авторизации для загрузки, проверьте пароль"; +"Server requires authorization to upload, check password." = "Сервер требует авторизации для загрузки, проверьте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Ошибка теста сервера!"; @@ -5284,7 +5278,7 @@ report reason */ "Tap Connect to send request" = "Нажмите Соединиться, чтобы отправить запрос"; /* No comment provided by engineer. */ -"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот."; +"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот"; /* No comment provided by engineer. */ "Tap Create SimpleX address in the menu to create it later." = "Нажмите Создать адрес SimpleX в меню, чтобы создать его позже."; @@ -5589,12 +5583,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортные сессии"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта."; - /* No comment provided by engineer. */ "Turkish interface" = "Турецкий интерфейс"; @@ -6087,9 +6075,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта."; - /* No comment provided by engineer. */ "You are invited to group" = "Вы приглашены в группу"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 923c1960c7..2700711773 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1926,7 +1926,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "การตั้งค่าเครือข่าย"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "สถานะเครือข่าย"; /* delete after time */ @@ -2285,9 +2285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "ได้รับการยืนยัน…"; -/* notification */ -"Received file event" = "ได้รับไฟล์"; - /* message info title */ "Received message" = "ได้รับข้อความ"; @@ -2570,9 +2567,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "ส่งเมื่อ: %@"; -/* notification */ -"Sent file event" = "เหตุการณ์ไฟล์ที่ส่ง"; - /* message info title */ "Sent message" = "ข้อความที่ส่งแล้ว"; @@ -2895,12 +2889,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "การแยกการขนส่ง"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@)"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "Turn off" = "ปิด"; @@ -3123,9 +3111,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "คุณได้เชื่อมต่อกับ %@ แล้ว"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "You are invited to group" = "คุณได้รับเชิญให้เข้าร่วมกลุ่ม"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index f6d38b0632..9acf2cc425 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Kullanım koşulları"; /* No comment provided by engineer. */ @@ -3539,7 +3539,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Ağ ayarları"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Ağ durumu"; /* delete after time */ @@ -4267,9 +4267,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "onaylama alındı…"; -/* notification */ -"Received file event" = "Dosya etkinliği alındı"; - /* message info title */ "Received message" = "Mesaj alındı"; @@ -4831,9 +4828,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gönderildi"; -/* notification */ -"Sent file event" = "Dosya etkinliği gönderildi"; - /* message info title */ "Sent message" = "Mesaj gönderildi"; @@ -5577,12 +5571,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Taşıma oturumları"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor."; - /* No comment provided by engineer. */ "Turkish interface" = "Türkçe arayüz"; @@ -6075,9 +6063,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Gruba zaten katılıyorsunuz!\nKatılma isteği tekrarlansın mı?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız."; - /* No comment provided by engineer. */ "You are invited to group" = "Gruba davet edildiniz"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 2c3d7b083d..fe8cfe22a0 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1179,7 +1179,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Умови вже прийняті для наступних операторів: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Умови використання"; /* No comment provided by engineer. */ @@ -2550,7 +2550,7 @@ snd error text */ "Find chats faster" = "Швидше знаходьте чати"; /* server test error */ -"Fingerprint in server address does not match certificate." = "Можливо, в адресі сервера неправильно вказано відбиток сертифіката"; +"Fingerprint in server address does not match certificate." = "Відбиток в адресі сервера не співпадає з сертифікатом."; /* No comment provided by engineer. */ "Fix" = "Виправити"; @@ -3509,7 +3509,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Налаштування мережі"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Стан мережі"; /* delete after time */ @@ -4222,9 +4222,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "отримали підтвердження…"; -/* notification */ -"Received file event" = "Подія отримання файлу"; - /* message info title */ "Received message" = "Отримано повідомлення"; @@ -4777,9 +4774,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Відправлено напряму"; -/* notification */ -"Sent file event" = "Подія надісланого файлу"; - /* message info title */ "Sent message" = "Надіслано повідомлення"; @@ -4826,10 +4820,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "інформація про чергу на сервері: %1$@\n\nостаннє отримане повідомлення: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Сервер вимагає авторизації для створення черг, перевірте пароль"; +"Server requires authorization to create queues, check password." = "Сервер вимагає авторизації для створення черг, перевірте пароль."; /* server test error */ -"Server requires authorization to upload, check password." = "Сервер вимагає авторизації для завантаження, перевірте пароль"; +"Server requires authorization to upload, check password." = "Сервер вимагає авторизації для завантаження, перевірте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Тест сервера завершився невдало!"; @@ -5511,12 +5505,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортні сесії"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "Turkish interface" = "Турецький інтерфейс"; @@ -6009,9 +5997,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Ви вже приєдналися до групи!\nПовторити запит на приєднання?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "You are invited to group" = "Запрошуємо вас до групи"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 2803b374f7..24d153afd5 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1122,7 +1122,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "已经接受下列运营方的条款:**%@**。"; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "使用条款"; /* No comment provided by engineer. */ @@ -3365,7 +3365,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "网络设置"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "网络状态"; /* delete after time */ @@ -3997,9 +3997,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "已受到确认……"; -/* notification */ -"Received file event" = "收到文件项目"; - /* message info title */ "Received message" = "收到的信息"; @@ -4444,9 +4441,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "直接发送"; -/* notification */ -"Sent file event" = "已发送文件项目"; - /* message info title */ "Sent message" = "已发信息"; @@ -5012,12 +5006,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "传输会话"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "正在尝试连接到用于从该联系人接收消息的服务器。"; - /* No comment provided by engineer. */ "Turkish interface" = "土耳其语界面"; @@ -5435,9 +5423,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "您已连接到用于接收该联系人消息的服务器。"; - /* No comment provided by engineer. */ "You are invited to group" = "您被邀请加入群组"; diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 8b4dd0e4d7..6b0c0f441e 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -113,7 +113,7 @@ kotlin { } // For jSystemThemeDetector only implementation("net.java.dev.jna:jna-platform:5.14.0") - implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") + implementation("com.sshtools:two-slices:0.9.1") implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") @@ -254,8 +254,11 @@ afterEvaluate { val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) treeList.remove(baseStringsFile) + // removed lv/strings.xml file with 100+ errors + treeList.remove(lvStringsFile) treeList.add(0, baseStringsFile) val baseFormatting = mutableMapOf>() treeList.forEachIndexed { index, file -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index d88a450fd1..70e0067260 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -243,9 +243,9 @@ fun MainScreen() { ModalManager.fullscreen.showOneTimePasscodeInView() AlertManager.privacySensitive.showInView() if (onboarding == OnboardingStage.OnboardingComplete) { - LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) { + LaunchedEffect(chatModel.chatRunning.value, chatModel.currentUser.value, chatModel.appOpenUrl.value) { val (rhId, url) = chatModel.appOpenUrl.value ?: (null to null) - if (url != null) { + if (url != null && chatModel.chatRunning.value == true) { chatModel.appOpenUrl.value = null connectIfOpenedViaUri(rhId, url, chatModel) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 628e768d57..debd0ec328 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -96,11 +96,12 @@ object ChatModel { val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) // map of connections network statuses, key is agent connection id - val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) + val chatAgentConnId = mutableStateOf(null) + val chatSubStatus = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) val secondaryChatsContext = mutableStateOf(null) @@ -133,6 +134,7 @@ object ChatModel { // set when app is opened via contact or invitation URI (rhId, uri) val appOpenUrl = mutableStateOf?>(null) + val appOpenUrlConnecting = mutableStateOf(false) // Needed to check for bottom nav bar and to apply or not navigation bar color on Android val newChatSheetVisible = mutableStateOf(false) @@ -1146,21 +1148,6 @@ object ChatModel { showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true) } - fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { - val conn = contact.activeConn - if (conn != null) { - networkStatuses[conn.agentConnId] = status - } - } - - fun contactNetworkStatus(contact: Contact): NetworkStatus { - val conn = contact.activeConn - return if (conn != null) - networkStatuses[conn.agentConnId] ?: NetworkStatus.Unknown() - else - NetworkStatus.Unknown() - } - fun addTerminalItem(item: TerminalItem) { val maxItems = if (appPreferences.developerTools.get()) 500 else 200 if (terminalsVisible.isNotEmpty()) { @@ -1720,30 +1707,6 @@ sealed class ChatInfo: SomeChat, NamedChat { } } -@Serializable -sealed class NetworkStatus { - val statusString: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.server_connected) - is Error -> generalGetString(MR.strings.server_error) - else -> generalGetString(MR.strings.server_connecting) - } - val statusExplanation: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) - else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) - } - - @Serializable @SerialName("unknown") class Unknown: NetworkStatus() - @Serializable @SerialName("connected") class Connected: NetworkStatus() - @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() -} - -@Serializable -data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) - @Serializable data class Contact( val contactId: Long, @@ -1915,12 +1878,6 @@ class ContactRef( val id: ChatId get() = "@$contactId" } -@Serializable -class ContactSubStatus( - val contact: Contact, - val contactError: ChatError? = null -) - @Serializable data class Connection( val connId: Long, @@ -2520,12 +2477,6 @@ class LinkPreview ( } } -@Serializable -class MemberSubError ( - val member: GroupMemberIds, - val memberError: ChatError -) - @Serializable class NoteFolder( val noteFolderId: Long, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index ea740f238b..5ad334e4bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -27,12 +28,14 @@ import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration @@ -46,12 +49,17 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.* import kotlinx.serialization.builtins.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Date typealias ChatCtrl = Long @@ -632,7 +640,7 @@ object ChatController { val chats = apiGetChats(rhId) chatModel.chatsContext.updateChats(chats, keepingChatId = keepingChatId) } - chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.userTags.value = if (hasUser) apiGetChatTags(rhId) ?: emptyList() else emptyList() chatModel.activeChatTagFilter.value = null chatModel.updateChatTags(rhId) } @@ -1709,6 +1717,11 @@ object ChatController { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.ApiCreateMyAddress(userId)) if (r is API.Result && r.res is CR.UserContactLinkCreated) return r.res.connLinkContact + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) @@ -1842,13 +1855,6 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiGetNetworkStatuses(rh: Long?): List? { - val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) - if (r is API.Result && r.res is CR.NetworkStatuses) return r.res.networkStatuses - Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null)) if (r.result is CR.CmdOk) return true @@ -2175,6 +2181,11 @@ object ChatController { suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? { val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole)) if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) @@ -2546,12 +2557,15 @@ object ChatController { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") chatModel.chatsContext.removeChat(rhId, conn.id) } + if (r.contact.id == chatModel.chatId.value && conn != null) { + chatModel.chatAgentConnId.value = conn.agentConnId + chatModel.chatSubStatus.value = SubscriptionStatus.Active + } } } if (r.contact.directOrUsed) { ntfManager.notifyContactConnected(r.user, r.contact) } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { @@ -2576,7 +2590,6 @@ object ChatController { } } } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest @@ -2618,45 +2631,14 @@ object ChatController { } } } - is CR.ContactsMerged -> { - if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { - if (chatModel.chatId.value == r.mergedContact.id) { - chatModel.chatId.value = r.intoContact.id - } + is CR.SubscriptionStatusEvt -> { + val chatAgentConnId = chatModel.chatAgentConnId.value + if (chatAgentConnId != null && r.connections.contains(chatAgentConnId)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.removeChat(rhId, r.mergedContact.id) + chatModel.chatSubStatus.value = r.subscriptionStatus } } } - // ContactsSubscribed, ContactsDisconnected and ContactSubSummary are only used in CLI, - // They have to be used here for remote desktop to process these status updates. - is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) - is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubSummary -> { - for (sub in r.contactSubscriptions) { - if (active(r.user)) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContact(rhId, sub.contact) - } - } - val err = sub.contactError - if (err == null) { - chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected()) - } else { - processContactSubError(sub.contact, sub.contactError) - } - } - } - is CR.NetworkStatusResp -> { - for (cId in r.connections) { - chatModel.networkStatuses[cId] = r.networkStatus - } - } - is CR.NetworkStatuses -> { - for (s in r.networkStatuses) { - chatModel.networkStatuses[s.agentConnId] = s.networkStatus - } - } is CR.ChatInfoUpdated -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -2941,9 +2923,6 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } - if (r.memberContact != null) { - chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) - } } is CR.GroupUpdated -> if (active(r.user)) { @@ -3256,12 +3235,6 @@ object ChatController { m.users.clear() m.users.addAll(users) getUserChatData(null) - val statuses = apiGetNetworkStatuses(null) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } } private fun activeUser(rhId: Long?, user: UserLike): Boolean = @@ -3374,27 +3347,6 @@ object ChatController { } } - private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) { - for (c in contactRefs) { - chatModel.networkStatuses[c.agentConnId] = status - } - } - - private fun processContactSubError(contact: Contact, chatError: ChatError) { - val e = chatError - val err: String = - if (e is ChatError.ChatErrorAgent) { - val a = e.agentError - when { - a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network" - a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted" - else -> e.string - } - } - else e.string - chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) - } - suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null @@ -3421,12 +3373,6 @@ object ChatController { chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } - val statuses = apiGetNetworkStatuses(rhId) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } getUserChatData(rhId) } @@ -3685,7 +3631,6 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() - class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() @@ -3884,7 +3829,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" - is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" @@ -4059,7 +4003,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" - is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatItemsRead -> "apiChatItemsRead" is ApiChatUnread -> "apiChatUnread" @@ -6199,13 +6142,7 @@ sealed class CR { @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef, val contactRequest: UserContactRequest, val contact_: Contact?): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() - // TODO remove below - @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - // TODO remove above - @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() - @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List): CR() @Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @@ -6240,7 +6177,6 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @@ -6387,11 +6323,7 @@ sealed class CR { is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" is GroupMemberUpdated -> "groupMemberUpdated" - is ContactsSubscribed -> "contactsSubscribed" - is ContactsDisconnected -> "contactsDisconnected" - is ContactSubSummary -> "contactSubSummary" - is NetworkStatusResp -> "networkStatus" - is NetworkStatuses -> "networkStatuses" + is SubscriptionStatusEvt -> "subscriptionStatus" is ChatInfoUpdated -> "chatInfoUpdated" is NewChatItems -> "newChatItems" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" @@ -6425,7 +6357,6 @@ sealed class CR { is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" is GroupDeleted -> "groupDeleted" - is ContactsMerged -> "contactsMerged" is UserJoinedGroup -> "userJoinedGroup" is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" @@ -6565,11 +6496,7 @@ sealed class CR { is ContactRequestRejected -> withUser(user, "contactRequest: ${json.encodeToString(contactRequest)}\ncontact_: ${json.encodeToString(contact_)}") is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") - is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) - is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" - is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) + is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections" is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo)) is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) @@ -6603,7 +6530,6 @@ sealed class CR { is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember\nwithMessages: ${withMessages}") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") @@ -6793,7 +6719,8 @@ class ConnectionStats( val rcvQueuesInfo: List, val sndQueuesInfo: List, val ratchetSyncState: RatchetSyncState, - val ratchetSyncSupported: Boolean + val ratchetSyncSupported: Boolean, + var subStatus: SubscriptionStatus? ) { val ratchetSyncAllowed: Boolean get() = ratchetSyncSupported && listOf(RatchetSyncState.Allowed, RatchetSyncState.Required).contains(ratchetSyncState) @@ -6808,8 +6735,10 @@ class ConnectionStats( @Serializable class RcvQueueInfo( val rcvServer: String, + var status: QueueStatus, val rcvSwitchStatus: RcvSwitchStatus?, - var canAbortSwitch: Boolean + var canAbortSwitch: Boolean, + var subStatus: SubscriptionStatus ) @Serializable @@ -6823,6 +6752,7 @@ enum class RcvSwitchStatus { @Serializable class SndQueueInfo( val sndServer: String, + var status: QueueStatus, val sndSwitchStatus: SndSwitchStatus? ) @@ -6860,6 +6790,39 @@ enum class RatchetSyncState { @SerialName("agreed") Agreed } +@Serializable +enum class QueueStatus { + @SerialName("new") New, + @SerialName("confirmed") Confirmed, + @SerialName("secured") Secured, + @SerialName("active") Active, + @SerialName("disabled") Disabled +} + +@Serializable +sealed class SubscriptionStatus { + @Serializable @SerialName("active") object Active: SubscriptionStatus() + @Serializable @SerialName("pending") object Pending: SubscriptionStatus() + @Serializable @SerialName("removed") class Removed(val subError: String): SubscriptionStatus() + @Serializable @SerialName("noSub") object NoSub: SubscriptionStatus() + + val statusString: String get() = + when (this) { + is Active -> generalGetString(MR.strings.server_connected) + is Pending -> generalGetString(MR.strings.server_connecting) + is Removed -> generalGetString(MR.strings.server_error) + is NoSub -> generalGetString(MR.strings.server_no_sub) + } + + val statusExplanation: String get() = + when (this) { + is Active -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) + is Pending -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) + is Removed -> String.format(generalGetString(MR.strings.error_connecting_to_server_to_receive_messages), subError) + is NoSub -> generalGetString(MR.strings.not_connected_to_server_to_receive_messages_no_sub) + } +} + interface SimplexAddress { val connLinkContact: CreatedConnLink val shortLinkDataSet: Boolean @@ -7046,7 +7009,6 @@ sealed class ChatErrorType { is FileCancelled -> "fileCancelled" is FileCancel -> "fileCancel" is FileAlreadyExists -> "fileAlreadyExists" - is FileRead -> "fileRead" is FileWrite -> "fileWrite $message" is FileSend -> "fileSend" is FileRcvChunk -> "fileRcvChunk" @@ -7127,7 +7089,6 @@ sealed class ChatErrorType { @Serializable @SerialName("fileCancelled") class FileCancelled(val message: String): ChatErrorType() @Serializable @SerialName("fileCancel") class FileCancel(val fileId: Long, val message: String): ChatErrorType() @Serializable @SerialName("fileAlreadyExists") class FileAlreadyExists(val filePath: String): ChatErrorType() - @Serializable @SerialName("fileRead") class FileRead(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileWrite") class FileWrite(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileSend") class FileSend(val fileId: Long, val agentError: String): ChatErrorType() @Serializable @SerialName("fileRcvChunk") class FileRcvChunk(val message: String): ChatErrorType() @@ -7354,6 +7315,7 @@ sealed class AgentErrorType { is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" + is NOTICE -> "NOTICE $server $expiresAt" is INTERNAL -> "INTERNAL $internalErr" is CRITICAL -> "CRITICAL $offerRestart $criticalErr" is INACTIVE -> "INACTIVE" @@ -7367,6 +7329,7 @@ sealed class AgentErrorType { @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() + @Serializable @SerialName("NOTICE") class NOTICE(val server: String, val preset: Boolean, val expiresAt: Instant?): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType() @Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType() @@ -8094,3 +8057,34 @@ enum class MsgType { @SerialName("quota") QUOTA } + +fun showClientNoticeAlert(server: String, preset: Boolean, expiresAt: Instant?) { + var message = "Server: $server.\nConditions of use violation notice received from ${if (preset) "preset" else "this"} server.\nNo ID shared, see How it works." + if (expiresAt != null) { + val tz = TimeZone.currentSystemDefault() + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + message += "\n\nNew addresses can be created after ${expiresAt.toLocalDateTime(tz).toJavaLocalDateTime().format(formatter)}." + } + AlertManager.shared.showAlertDialogButtonsColumn(title = "Not allowed", text = AnnotatedString(message)) { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (preset) { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(defaultConditionsLink) + }) { + Text(generalGetString(MR.strings.operator_conditions_of_use), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index 19e40ab0a2..d7a241d5ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -1,7 +1,15 @@ package chat.simplex.common.platform +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale import boofcv.struct.image.GrayU8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URI @@ -23,3 +31,23 @@ expect fun isImage(uri: URI): Boolean expect fun isAnimImage(uri: URI, drawable: Any?): Boolean expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap + +@Composable +fun Base64AsyncImage( + base64ImageString: String, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier +) { + val imageBitmap by produceState(initialValue = null, base64ImageString) { + value = withContext(Dispatchers.IO) { base64ToBitmap(base64ImageString) } + } + imageBitmap?.let { + Image( + bitmap = it, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index c7b8cb2c81..061ea71016 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -72,9 +72,6 @@ fun ChatInfoView( val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && currentUser != null) { - val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { - mutableStateOf(chatModel.contactNetworkStatus(contact)) - } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } @@ -101,7 +98,6 @@ fun ChatInfoView( setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, connStats = connStats, - contactNetworkStatus.value, customUserProfile, localAlias, connectionCode, @@ -524,7 +520,6 @@ fun ChatInfoLayout( chatItemTTL: MutableState, setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, - contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -643,13 +638,16 @@ fun ChatInfoLayout( if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { - SectionItemView({ - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.network_status), - contactNetworkStatus.statusExplanation - ) - }) { - NetworkStatusRow(contactNetworkStatus) + val chatSubStatus = chatModel.chatSubStatus.value + if (chatSubStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + chatSubStatus.statusExplanation + ) + }) { + SubStatusRow(chatSubStatus) + } } if (cStats != null) { SwitchAddressButton( @@ -1063,7 +1061,7 @@ fun InfoViewActionButton( } @Composable -private fun NetworkStatusRow(networkStatus: NetworkStatus) { +fun SubStatusRow(subStatus: SubscriptionStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1086,25 +1084,24 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) { horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - networkStatus.statusString, + subStatus.statusString, color = MaterialTheme.colors.secondary ) - ServerImage(networkStatus) + ServerImage(subStatus) } } } @Composable -private fun ServerImage(networkStatus: NetworkStatus) { +private fun ServerImage(subStatus: SubscriptionStatus) { Box(Modifier.size(18.dp)) { - when (networkStatus) { - is NetworkStatus.Connected -> + when (subStatus) { + is SubscriptionStatus.Active -> Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = Color.Green) - is NetworkStatus.Disconnected -> + is SubscriptionStatus.Pending -> Icon(painterResource(MR.images.ic_pending_filled), stringResource(MR.strings.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary) - is NetworkStatus.Error -> + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> Icon(painterResource(MR.images.ic_error_filled), stringResource(MR.strings.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary) - else -> Icon(painterResource(MR.images.ic_circle), stringResource(MR.strings.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary) } } } @@ -1455,7 +1452,6 @@ fun PreviewChatInfoLayout() { connectionCode = "123", developerTools = false, connStats = remember { mutableStateOf(null) }, - contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, openPreferences = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index b69c98887d..26daee363f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -102,6 +102,7 @@ fun ChatView( val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } + val chatRh = remoteHostId.value val activeChat = remember { derivedStateOf { var chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } val chatInfo = chat?.chatInfo @@ -120,6 +121,8 @@ fun ChatView( if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null ModalManager.end.closeModals() } } else { @@ -161,10 +164,32 @@ fun ChatView( } if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false } showSearch.value = false searchText.value = "" selectedChatItems.value = null + if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) { + withBGApi { + val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) + if (r != null) { + val contactStats = r.first + if (contactStats != null) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, chat.chatInfo.contact, contactStats) + chatModel.chatAgentConnId.value = chat.chatInfo.contact.activeConn.agentConnId + chatModel.chatSubStatus.value = contactStats.subStatus + } + } + } + } else { + withContext(Dispatchers.Main) { + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null + } + } } } } @@ -181,7 +206,6 @@ fun ChatView( } } val view = LocalMultiplatformView() - val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -353,7 +377,7 @@ fun ChatView( if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { + KeyChangeEffect(chatInfo.id) { contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) preloadedContactInfo = contactInfo code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second @@ -1278,9 +1302,51 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo ) } } + val chatSubStatus = chatModel.chatSubStatus.value + if ( + cInfo is ChatInfo.Direct && + cInfo.contact.ready && + cInfo.contact.active && + chatSubStatus != null && + chatSubStatus != SubscriptionStatus.Active + ) { + Box( + Modifier.padding(start = 10.dp) + ) { + SubStatusView(chatSubStatus) + } + } } } +@Composable +fun SubStatusView(status: SubscriptionStatus) { + when (status) { + SubscriptionStatus.Active -> + Box {} + SubscriptionStatus.Pending -> + SubProgressView() + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.sp.toDp()) + ) + } +} + +@Composable +private fun SubProgressView() { + CircularProgressIndicator( + Modifier + .size(15.sp.toDp()), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) +} + @Composable private fun SupportChatsCountToolbar( chatInfo: ChatInfo, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 5c8ac3bc5d..21799ff820 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -162,7 +162,6 @@ fun acceptMemberContact( chatModel.chatsContext.updateContact(rhId, contact) inProgress?.value = false } - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) close?.invoke(chat) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index b01d07d9b8..af4baad90f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -533,7 +533,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) @@ -554,7 +553,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 1ea3daeab1..46669ead70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -867,7 +867,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member, async = true) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index b29374390e..d7e052e502 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -109,7 +109,6 @@ fun GroupMemberInfoView( } openDirectChat(rhId, memberContact.contactId) closeAll() - chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } progressIndicator = false } @@ -495,6 +494,17 @@ fun GroupMemberInfoLayout( if (cStats != null) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { + val subStatus = cStats.subStatus + if (subStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + subStatus.statusExplanation + ) + }) { + SubStatusRow(subStatus) + } + } SwitchAddressButton( disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || !member.sendMsgEnabled, switchAddress = switchMemberAddress @@ -714,14 +724,16 @@ fun MemberProfileImage( size: Dp, mem: GroupMember, color: Color = MaterialTheme.colors.secondaryVariant, - backgroundColor: Color? = null + backgroundColor: Color? = null, + async: Boolean = false ) { ProfileImage( size = size, image = mem.image, color = color, backgroundColor = backgroundColor, - blurred = mem.blocked + blurred = mem.blocked, + async = async ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 4a8e9e5193..77ab62dbf1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -62,12 +62,11 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { when (chat.chatInfo) { is ChatInfo.Direct -> { - val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -87,7 +86,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) } }, click = defaultClickAction, @@ -107,7 +106,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -744,7 +743,6 @@ fun acceptContactRequest( } inProgress?.value = false } - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) close?.invoke(chat) } else { inProgress?.value = false @@ -1057,7 +1055,6 @@ fun PreviewChatListNavLinkDirect() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, @@ -1103,7 +1100,6 @@ fun PreviewChatListNavLinkGroup() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 7b5be0c323..2109e21bfe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -601,7 +601,8 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { chatModel.appOpenUrl.value = rhId to uri } else { withBGApi { - planAndConnect(rhId, uri, close = null) + chatModel.appOpenUrlConnecting.value = true + planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false }) } } } @@ -670,7 +671,9 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState // if some other text is pasted, enter search mode focusRequester.requestFocus() } else { - connectProgressManager.cancelConnectProgress() + if (!chatModel.appOpenUrlConnecting.value) { + connectProgressManager.cancelConnectProgress() + } if (listState.layoutInfo.totalItemsCount > 0) { listState.scrollToItem(0) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d5d6facafe..4280845867 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,6 @@ fun ChatPreviewView( chatModelDraft: ComposeState?, chatModelDraftChatId: ChatId?, currentUserProfileDisplayName: String?, - contactNetworkStatus: NetworkStatus?, disabled: Boolean, linkMode: SimplexLinkMode, inProgress: Boolean, @@ -349,33 +348,7 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { - if (cInfo is ChatInfo.Direct) { - if ( - cInfo.contact.active && - (cInfo.contact.activeConn?.connStatus == ConnStatus.Ready || cInfo.contact.activeConn?.connStatus == ConnStatus.SndReady) - ) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) - - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.sp.toDp()) - .offset(x = 2.sp.toDp()) - ) - - else -> - progressView() - } - } else { - IncognitoIcon(chat.chatInfo.incognito) - } - } else if (cInfo is ChatInfo.Group) { + if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() } else if (chat.chatStats.reportsCount > 0) { @@ -636,6 +609,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(Chat.sampleData, true, null, null, "", disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 55ac9c8810..ed1c7116e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -64,7 +64,7 @@ enum class SubscriptionColorType { ACTIVE, ACTIVE_SOCKS_PROXY, DISCONNECTED, ACTIVE_DISCONNECTED } -data class SubscriptionStatus( +data class AppSubscriptionStatus( val color: SubscriptionColorType, val variableValue: Float, val statusPercent: Float @@ -75,7 +75,7 @@ fun subscriptionStatusColorAndPercentage( socksProxy: String?, subs: SMPServerSubs, hasSess: Boolean -): SubscriptionStatus { +): AppSubscriptionStatus { fun roundedToQuarter(n: Float): Float = when { n >= 1 -> 1f n <= 0 -> 0f @@ -83,25 +83,25 @@ fun subscriptionStatusColorAndPercentage( } val activeColor: SubscriptionColorType = if (socksProxy != null) SubscriptionColorType.ACTIVE_SOCKS_PROXY else SubscriptionColorType.ACTIVE - val noConnColorAndPercent = SubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) + val noConnColorAndPercent = AppSubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) val activeSubsRounded = roundedToQuarter(subs.shareOfActive) return if (!online) noConnColorAndPercent else if (subs.total == 0 && !hasSess) // On freshly installed app (without chats) and on app start - SubscriptionStatus(activeColor, 0f, 0f) + AppSubscriptionStatus(activeColor, 0f, 0f) else if (subs.ssActive == 0) { if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else noConnColorAndPercent } else { // ssActive > 0 if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else // This would mean implementation error - SubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 72ef8c623d..5f3a73e7ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -12,12 +12,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.unit.* -import chat.simplex.common.model.BusinessChatType import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatInfo @@ -57,7 +54,8 @@ fun ProfileImage( icon: ImageResource = MR.images.ic_account_circle_filled, color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, - blurred: Boolean = false + blurred: Boolean = false, + async: Boolean = false ) { Box(Modifier.size(size)) { if (image == null) { @@ -85,13 +83,22 @@ fun ProfileImage( ) } } else { - val imageBitmap = base64ToBitmap(image) - Image( - imageBitmap, - stringResource(MR.strings.image_descr_profile_image), - contentScale = ContentScale.Crop, - modifier = ProfileIconModifier(size, blurred = blurred) - ) + if (async) { + Base64AsyncImage( + base64ImageString = image, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } else { + val imageBitmap = base64ToBitmap(image) + Image( + bitmap = imageBitmap, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index a14f163a91..9c6c0fa635 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -15,8 +15,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign + import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -38,7 +37,6 @@ fun ModalData.OnboardingConditionsView() { ModalView({}, showClose = false) { val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } - val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } ColumnWithScrollBar( Modifier @@ -84,7 +82,7 @@ fun ModalData.OnboardingConditionsView() { Spacer(Modifier.weight(1f)) Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperators, selectedOperatorIds) + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperatorIds) TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) { ModalManager.fullscreen.showModalCloseable { close -> ChooseServerOperators(serverOperators, selectedOperatorIds, close) @@ -217,7 +215,6 @@ private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) { @Composable private fun AcceptConditionsButton( enabled: Boolean, - selectedOperators: State>, selectedOperatorIds: State> ) { fun continueOnAccept() { @@ -235,9 +232,7 @@ private fun AcceptConditionsButton( onclick = { withBGApi { val conditionsId = chatModel.conditions.value.currentConditions.conditionsId - val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } - val operatorIds = acceptForOperators.map { it.operatorId } - val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds) + val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = selectedOperatorIds.value.toList()) if (r != null) { chatModel.conditions.value = r val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index cc72387875..c619ae6ebc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -612,13 +612,14 @@ private fun SingleOperatorUsageConditionsView( } } +val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + @Composable fun ConditionsTextView( rhId: Long? ) { val conditionsData = remember { mutableStateOf?>(null) } val failedToLoad = remember { mutableStateOf(false) } - val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" val scope = rememberCoroutineScope() // can show conditions when animation between modals finishes to prevent glitches val canShowConditionsAt = remember { System.currentTimeMillis() + 300 } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 96c539109d..b1d27b79a5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -910,7 +910,7 @@ أُزيل يُرجى تذكرها أو تخزينها بأمان - لا توجد طريقة لاستعادة كلمة المرور المفقودة! معاينة - من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح + البصمة في عنوان الخادم لا تتطابق مع الشهادة. يتم استلام الرسائل… يُرجى الاتصال بمُدير المجموعة. أعد التفاوض @@ -1050,8 +1050,8 @@ أظهر: أظهر خيارات المطور simplexmq: v%s (%2s) - يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور - يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور + يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور. + يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور. أظهر جهة الاتصال فقط مكالمات SimpleX Chat خدمة SimpleX Chat @@ -1131,7 +1131,7 @@ محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه. اختيار ملف إرسال غير مصرح به - محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). + محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). تشغيل خوادم WebRTC ICE أنت تستخدم ملف تعريف متخفي لهذه المجموعة - لمنع مشاركة ملفك التعريفي الرئيسي الذي يدعو جهات الاتصال غير مسموح به @@ -2396,7 +2396,7 @@ الكل دخول العضو راجع الأعضاء قبل القبول (الطرق). - حدث خطأ أثناء حذف الدردشة مع العضو. + خطأ في حذف الدردشة لا يمكن إرسال الرسائل رُفض طلب الانضمام غادرت @@ -2516,5 +2516,8 @@ افتح الرابط الكامل أزل تتبع الروابط رابط مُرحل SimpleX - خطأ في وضع علامة \"مقروءة\" على الدردشة مع العضو + خطأ في وضع علامة \"مقروءة\" + البصمة في عنوان الخادم الوجهة لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان خادم التحويل لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان الخادم لا تتطابق مع الشهادة: %1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b13e3f4046..8c8493358e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -32,9 +32,11 @@ connected error connecting - You are connected to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact (error: %1$s). - Trying to connect to the server used to receive messages from this contact. + no subscription + You are connected to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + Error connecting to the server used to receive messages from this connection: %1$s. + You are not connected to the server used to receive messages from this connection (no subscription). deleted diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index bf04fd6a1e..03a8bcfdc5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1182,7 +1182,7 @@ Тази настройка се прилага за съобщения в текущия ви профил За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). + Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. Базата данни не работи правилно. Докоснете, за да научите повече Изображението не може да бъде декодирано. Моля, опитайте с друго изображение или се свържете с разработчиците. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index c9b1f79694..a3b97f0be2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1556,7 +1556,7 @@ Comproveu que l\'enllaç SimpleX sigui correcte. l\'enviament de fitxers encara no està suportat Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. - S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). Usar perfil actual Usar nou perfil incògnit Error aplicació @@ -1613,9 +1613,9 @@ La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. Missatges no lliurats A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. - Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta - El servidor requereix autorització per crear cues, comproveu la contrasenya - El servidor requereix autorització per carregar, comproveu la contrasenya + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat. + El servidor requereix autorització per crear cues, comproveu la contrasenya. + El servidor requereix autorització per carregar, comproveu la contrasenya. La prova ha fallat al pas %s. Cua segura Carrega fitxer @@ -2381,7 +2381,7 @@ No hi ha xats amb membres Acceptar com a observador Desar la configuració d\'admissió? - Error en suprimir el xat amb membre + Error en suprimir el xat %d xat(s) el(la) membre té una versió antiga Un(a) nou(va) membre vol unir-se al grup. @@ -2497,4 +2497,8 @@ Eliminar el seguiment de l\'enllaç Opcions obsoletes Enllaç de servidor SimpleX + Error marcant com a llegit + L\'empremta digital a l\'adreça del servidor de destinació no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor de reenviament no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat: %1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 4d6aa47fd3..423f0e135f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -356,7 +356,7 @@ Nastavit 1 den Chyba spojení (AUTH) Odesílatel možná smazal požadavek připojení - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytvoření front, zkontrolujte heslo. Odstranit frontu Smazat Smazat čekající připojení\? @@ -493,7 +493,7 @@ Připojtíte se ke všem členům skupiny. Připojení Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). + Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). označeno jako smazáno Odesílání souborů zatím není podporováno přijímání souborů zatím není podporováno @@ -519,7 +519,7 @@ Chyba mazání žádosti kontaktu Chyba mazání probíhajícího připojení kontaktu Test selhal v kroku %s. - Je možné, že otisk certifikátu v adrese serveru je nesprávný. + Otisk adresy serveru neodpovídá certifikátu. Připojit Odpojit Chyba mazání uživatelského profilu @@ -1016,7 +1016,7 @@ Chyba načítání serverů XFTP Chyba ukládání XFTP serverů Ujistěte se, že adresy XFTP serverů jsou ve správném formátu s oddělenými řádky a nejsou duplicitní. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. Porovnat soubor Vytvořit soubor Smazat soubor @@ -1592,7 +1592,7 @@ Chyba otevření prohlížeče odstraněn profilový obrázek nastavit novou kontaktní adresu - nastavit nový profilový obrázek + nastavil nový profilový obrázek Uložené zprávy Pomalá funkce Soukromé poznámky @@ -2395,8 +2395,8 @@ Chat s adminy Přijmout Přijmout člena - schválen adminy - Upgradovat adresu + čeká na schválení adminy + Povýšit adresu přijat %1$s Vás přijal schválení @@ -2427,7 +2427,7 @@ Přijmout jako člena Přijmout jako pozorovatele Odmítnout člena? - Chyba odstranění chatu se členem + Chyba odstranění chatu Použït profil inkognito Otevřít chat Otevřít nový chat @@ -2468,5 +2468,64 @@ Odstranit zprávy a blokovat členy. koncovým šifrováním.]]> SimpleX relé odkaz - Bez soukromého směrování sezení + Sezení bez soukromého směrování + 4 nové jazyky rozhraní + Přijmout žádost o kontakt + Přijmout žádost o kontakt + Přidat zprávu + Povolit soubory a média pouze pokud, je váš kontakt povolí. + Povolit vašim kontaktům odesílání souborů a médii. + Bio: + Bio příliš velké + Bot + Vy i vaše kontakty můžete posílat soubory a média. + Obchodní spojení + Nelze změnit profil + Katalánština, Indonéština, Rumunština a Vietnamština - díky našim uživatelům! + až bude váš požadavek přijat.]]> + Chat se správci + Chat se členy než se připojí. + Chyba při označení jako přečteno + Otisk adresy cílového serveru neodpovídá certifikátu: %1$s. + Otisk adresy přeposílacího serveru neodpovídá certifikátu: %1$s. + Otisk adresy serveru neodpovídá certifikátu: %1$s. + Chatujte okamžitě po připojení. + požadováno spojení ze skupiny %1$s + požadavek odeslán + Zkontrolovat členy skupiny + Odeslat žádost o kontakt? + Odeslat žádost + Odeslat žádost bez zprávy + Posílání soukromé zpětné vazby do skupin. + Pošlete kontaktu po připojení. + Nastavení bio profilu a uvítací zprávy. + Sdílení staré adresy + Sdílení starého odkazu + Sdílet vaši adresu + Stručný popis: + Krátké SimpleX adresy + Klepněte na Připojte se k chatu + Klepněte na Připojit k odeslání požadavku + Klepněte na Připojit k použití bota + Klepněte na Připojit skupinu + Vypršelo TCP připojení na pozadí + Adresa bude krátká a Váš profil bude sdílen prostřednictvím adresy. + Odkaz bude krátký a profil skupiny bude sdílen prostřednictvím odkazu. + Odesílatel NEBUDE informován. + Toto nastavení je pro váš aktuální profil + Čas mizení, je nastaven pouze pro nové kontakty. + Pro odeslání příkazů musíte být připojen. + Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. + Aktualizovat vaši adresu + Povýšení + Povýšit adresu? + Povýšit odkaz skupiny + Povýšit odkaz skupiny? + Uvítací zpráva + Přivítejte vaše kontakty 👋 + Vaše bio: + Váš obchodní kontakt + Váš kontakt + Vaše skupina + Váš profil diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index c4f84d4397..30e557a4e3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -178,7 +178,7 @@ fejl forbinder Du har forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. - Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). + Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. slettet markeret som slettet @@ -715,4 +715,148 @@ Verificier sikkerhedsnummer Brug inkognito -profil Send besked + 4 nye interface -sprog + Tillad kun filer og medier, hvis din kontakt tillader dem. + Lad dine kontakter sende filer og medier. + Udseende + App krypterer nye lokale filer (undtagen videoer). + Appikon + Anvende + Ansøg på + App adgangskode + App adgangskode + App-adgangskode erstattes med selvdestruktionsskode. + App -session + App -tema + App værktøjslinjer + Appopdatering er downloadet + App version + App version: v%s + Arabisk, bulgarsk, finsk, hebraisk, thailandsk og ukrainsk - takket være brugerne og Weblate. + Arkiv og upload + Arkivkontakter for at chatte senere. + Arkiverede kontakter + Arkiveringsdatabase + Spørge + forsøg + Lyd- og videoopkald + lydopkald + lydopkald (ikke E2E krypteret) + Lyd fra + Lyd på + Audio & videoopkald + Audio/videoopkald + Audio/videoopkald er forbudt. + Autentificering annulleret + forfatter + Auto-accept + Auto-accept-kontaktanmodninger + Auto-accept-billeder + \nFås i v5.1 + Tilbage + Baggrund + Dårlig skrivebordsadresse + Dårlig besked hash + Dårlig besked hash + Dårligt besked -id + Dårligt besked -id + Beta + Bedre opkald + Bedre grupper + Bedre grupper ydeevne + Bedre meddelelsesdatoer. + Bedre beskeder + Bedre privatlivets fred og sikkerhed + Bedre sikkerhed ✅ + Bedre brugeroplevelse + Bio: + Bio for stor + Sort + Blok + blokeret + Blokeret af admin + blokeret %s + Blok for alle + Blokergruppemedlemmer + Blok medlem + Blok medlem? + Blokermedlem for alle? + Blokermedlem for alle? + Bluetooth + Sløret + Slør for bedre privatliv. + Slør medier + fed + Bot + Både dig og din kontakt kan tilføje meddelelsesreaktioner. + Både dig og din kontakt kan irreversibelt slette sendte beskeder. (24 timer) + Både dig og din kontakt kan foretage opkald. + Både dig og din kontakt kan sende forsvindende beskeder. + Både dig og din kontakt kan sende filer og medier. + Både dig og din kontakt kan sende stemmemeddelelser. + Forretningsadresse + Forretningschats + Med chatprofil (standard) eller ved forbindelse (beta). + Ved at bruge simplex chat accepterer du:\n- Send kun lovligt indhold i offentlige grupper.\n- Respekter andre brugere - ingen spam. + Opkald allerede afsluttet! + Opkald sluttede + Opkald sluttede %1$s + Opkaldsfejl + Ringer … + ring i gang + Ring i gang + Opkald + Opkald på låseskærmen: + Kalder forbudt! + Kamera + Kamera + Kamera og mikrofon + Kamera ikke tilgængeligt + Annuller + Annulleret %s + Annuller linkeksempel + Annuller live -meddelelsen + Annuller migration + Kan ikke få adgang til Keystore for at gemme databaseadgangskode + Kan ikke ringe til kontakten + Kan ikke ringe til gruppemedlem + Kan ikke ændre profil + Kan ikke invitere kontakt! + Kan ikke invitere kontakter! + Kan ikke sende besked til gruppemedlem + Katalansk, indonesisk, rumænsk og vietnamesisk - takket være vores brugere! + med kun en kontakt - Del personligt eller via enhver messenger.]]> + ende-til-ende krypteret med sikkerhed efter kvantet i direkte meddelelser.]]> + for hver chatprofil, du har i appen.]]> + til hvert kontakt- og gruppemedlem.\n Bemærk : Hvis du har mange forbindelser, kan dit batteri og trafikforbrug være væsentligt højere, og nogle forbindelser kan mislykkes.]]> + Tilføj kontakt: Sådan opretter du et nyt invitationslink eller opretter forbindelse via et link, du har modtaget.]]> + Bedst til batteri. Du modtager kun meddelelser, når appen løber (ingen baggrundstjeneste).]]> + Opret gruppe: At oprette en ny gruppe.]]> + godt til batteri . App kontrollerer meddelelser hvert 10. minut. Du kan gå glip af opkald eller presserende beskeder.]]> + Bemærk: Meddelelse og filrelæer er tilsluttet via SOCKS -proxy. Opkald og afsendelse af link -forhåndsvisninger Brug direkte forbindelse.]]> + Bemærk : Brug af den samme database på to enheder vil bryde dekryptering af meddelelser fra dine forbindelser som en sikkerhedsbeskyttelse.]]> + Bemærk : Du vil ikke være i stand til at gendanne eller ændre adgangskode, hvis du mister den.]]> + bruger mere batteri ! App løber altid i baggrunden - underretninger vises øjeblikkeligt.]]> + ADVARSEL : Arkivet slettes.]]> + Forbinde + Forbind automatisk + Opret forbindelse direkte? + tilsluttet + tilsluttet + tilsluttet + Tilsluttet + Forbundet desktop + Forbundet mobil + Forbundet servere + Forbundet til desktop + Forbundet til mobil + Forbind hurtigere! 🚀 + Tilslutning + Forbindelse… + Forbindelse + Forbindelse (accepteret) + Forbindelse (annonceret) + Tilslutning af opkald … + Tilslutning af opkald + Tilslutning (introduceret) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index db5e983983..e038207801 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -13,9 +13,9 @@ Verbunden Fehler Verbinde - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. Gelöscht als gelöscht markiert @@ -77,8 +77,8 @@ Fehler beim Löschen der ausstehenden Kontaktaufnahme Fehler beim Wechseln der Empfängeradresse Der Test ist beim Schritt %s fehlgeschlagen. - Um Warteschlangen zu erzeugen, benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort. - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig. + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. Verbinde Erzeuge Warteschlange Sichere Warteschlange @@ -1097,7 +1097,7 @@ Host Fehler beim Speichern der XFTP-Server Fehler beim Laden der SMP-Server - Bitte das Passwort überprüfen – für den Upload benötigt der Server eine Berechtigung + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. Datei herunterladen Datei vergleichen Datei löschen @@ -1652,8 +1652,8 @@ Private Notizen Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Private Notizen entfernen? - es wurde %s blockiert - Es wurden %s freigegeben + hat %s blockiert + hat %s freigegeben Sie haben %s blockiert Sie haben %s freigegeben Mitglied für Alle blockieren? @@ -1665,7 +1665,7 @@ Für Alle freigeben Mitglied für Alle freigeben? wurde blockiert - ist vom Administrator blockiert worden + wurde vom Administrator blockiert wurde vom Administrator blockiert Für Alle blockiert Erstellt um @@ -2500,7 +2500,7 @@ Sie haben die Gruppe verlassen Kontakt deaktiviert Nicht synchronisiert - Fehler beim Löschen des Chats mit dem Mitglied + Fehler beim Löschen des Chats Kontakt nicht bereit Sie können keine Nachrichten senden! Chat löschen @@ -2606,5 +2606,10 @@ Verbinden tippen, um den Bot zu nutzen. Um Befehle senden zu können, müssen Sie verbunden sein. SimpleX Relais-Link - Fehler beim Versuch, den Chat mit einem Mitglied als gelesen zu markieren + Fehler beim Markieren als gelesen + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Kein Abonnement + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 2d27bb3592..9e38019c8b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -29,7 +29,7 @@ πάντα Αποδοχή Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. + Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) Επιτρέψτε στις επαφές σας να στέλνουν μηνύματα που εξαφανίζονται. Επιτρέπονται τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σας. Επιτρέψτε στις επαφές σας να σας καλέσουν. @@ -48,9 +48,9 @@ Οι διαχειριστές μπορούν να δημιουργήσουν τους συνδέσμους συμμετοχής σε ομάδες. Όλες οι συνομιλίες και τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Τα μηνύματα θα διαγραφούν ΜΟΝΟ για εσάς. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν σας το επιτρέπει η επαφή σας. + Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σας. (24 ώρες) Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σας τις επιτρέπει. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. + Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. (24 ώρες) Να επιτρέπονται τα φωνητικά μηνύματα; Πάντα ενεργό Να χρησιμοποιείται πάντα αναμεταδότη @@ -230,7 +230,7 @@ Η αλλαγή διεύθυνσης θα ακυρωθεί. Θα χρησιμοποιηθεί η παλιά διεύθυνση παραλαβής. Ενεργές συνδέσεις Προχωρημένες ρυθμίσεις - Πρόσθετη προφορά + Πρόσθετος τόνος Προσθήκη επαφής Διακοπή αλλαγής διεύθυνσης Προχωρημένες ρυθμίσεις @@ -395,4 +395,46 @@ Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνετε ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> Beta Καλύτερες κλήσεις + %1$d σφάλμα/τα αρχείου/ων:\n%2$s + 1 συζήτηση με ένα μέλος + 1 αναφορά + 1 χρόνος + Σχετικά με χειρηστές + Αποδοχή + Αποδοχή + Αποδοχή ως μέλος + Αποδοχή ως παρατηρητής + Αποδοχή όρων + Αποδοχή αιτήματος επαφής + Αποδοχή αιτήματος επαφής + αποδέχτηκε %1$s + Αποδεχούμενοι όροι + αποδέχτηκε τη πρόσκληση + σε αποδέχτηκε + Αποδοχή μέλους + Προστέθηκαν διακομιστές πολυμέσων και αρχείων + Προστέθηκε διακομιστής μυνημάτων + Προσθήκη φίλων + Πρόσθετος τόνος 2 + Προσθήκη λίστας + Προσθήκη μυνήματος + Διεύθυνση ή σύνδεσμος μιας χρήσης; + Ρυθμίσεις διεύθυνσης + Προσθήκη μέλη ομάδας + Προσθήκη στην λίστα + Πρόσθεσε τα μέλη της ομάδας σου στις συνομιλίες. + όλα + Όλα + Όλες οι συζητήσεις θα διαγραφτούν απο την λίστα %s, και η λίστα θα διαγραφτεί + Όλα τα καινούργια μυνήματα από αυτά τα μέλη θα είναι κρυμμένα! + Επιτρέψτε τα αρχεία και πολυμέσα μόνο αν η επαφή σου το επιτρέπει. + Επιτρέψτε την αναφορά μυνημάτων στους διαχειριστές. + Επιτρέψτε τις επαφές σας να σας στέλνουν αρχεία και πολυμέσα. + Όλες η αναφορές θα αρχειοθετηθούν για εσένα. + Όλοι οι διακομιστές + Άλλος λόγος + Η εφαρμογή πάντα να τρέχει στο παρασκήνιο + Αρχειοθέτηση + Αρχειοθέτηση όλων των αναφορών; + αρχειοθετημένη αναφορά diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index fa356715db..b5e756aaad 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -329,7 +329,7 @@ Archivo: %s ¡Error al cambiar perfil! Añadir manualmente - Cómo usar los servidores + Cómo usar tus servidores Error al parar SimpleX Introduce la contraseña correcta. Introduce la contraseña… @@ -571,7 +571,7 @@ Establecer una conexión privada Comprueba tu conexión de red con %1$s e inténtalo de nuevo. El remitente puede haber eliminado la solicitud de conexión. - Posiblemente la huella del certificado en la dirección del servidor es incorrecta + La huella en la dirección del servidor no coincide con el certificado. Responder Guardar contraseña en Keystore Error al restaurar base de datos @@ -748,11 +748,11 @@ Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. Para actualizar la configuración el cliente se reconectará a todos los servidores. - ¿Usar servidores SimpleX Chat\? + ¿Usar servidores de SimpleX Chat? Enlace de grupo SimpleX Invitación SimpleX de un uso Enlaces SimpleX - El servidor requiere autorización para crear colas, comprueba la contraseña + El servidor requiere autorización para crear colas, comprueba la contraseña. Para recibir notificaciones, introduce la contraseña de la base de datos Llamadas de chat SimpleX Cíclico @@ -804,9 +804,9 @@ El rol cambiará a %s y el miembro recibirá una invitación nueva. Actualizar ¿Actualizar la configuración de red\? - Intentando conectar con el servidor para recibir mensajes de este contacto. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. formato de mensaje desconocido - Intentando conectar con el servidor para recibir mensajes de este contacto (error: %1$s). + Error al conectar con el servidor usado para recibir mensajes de esta conexión: (error: %1$s). Prueba no superada en el paso %s. Pulsa para iniciar chat nuevo Compartir mensaje… @@ -916,7 +916,7 @@ Tu perfil se enviará al contacto del que has recibido este enlace. Conectarás con todos los miembros del grupo. tu - Estás conectado al servidor usado para recibir mensajes de este contacto. + Estás conectado al servidor usado para recibir mensajes de esta conexión. mediante dirección de contacto mediante enlace de grupo enlace de un solo uso @@ -937,7 +937,7 @@ Tu servidor Dirección de tu servidor Tu perfil actual - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. Sistema Añadir mensaje de bienvenida Llamadas y videollamadas @@ -1016,7 +1016,7 @@ Error al cargar servidores XFTP Error al cargar servidores SMP Asegúrate de que las direcciones del servidor XFTP tienen el formato correcto, están separadas por líneas y no están duplicadas. - El servidor requiere autorización para subir, comprueba la contraseña + El servidor requiere autorización para subir, comprueba la contraseña. Comparar archivo Crear archivo Eliminar archivo @@ -2132,7 +2132,7 @@ Operador Servidores predefinidos Revisar condiciones - %s servidores + Servidores %s Las condiciones serán aceptadas el: %s. Condiciones de uso Para enrutamiento privado @@ -2166,7 +2166,7 @@ Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado - Selecciona los operadores de red a utilizar + Selecciona operadores a usar. Compartir dirección públicamente Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. Actualizar @@ -2402,7 +2402,7 @@ Admisión de miembros el miembro usa una versión antigua Sin chats - Error al eliminar el chat con el miembro + Error al eliminar el chat %d chats con miembros %d mensajes un chat con miembro @@ -2531,5 +2531,10 @@ Limpiar enlaces de seguimiento Abrir enlace completo Enlace de servidor SimpleX - Error al marcar el chat con miembro como leído + Error al marcar como leído + La huella en la dirección del servidor no coincide con el certificado: %1$s. + La huella en la dirección del servidor de destino no coincide con el certificado: %1$s. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %1$s. + Sin suscripciones + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index cbb4849067..eba31ba788 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -35,7 +35,7 @@ متصل شدن مسیر نامعتبر فایل برنامه از کار افتاد - در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). + در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). حذف شد علامت گذاشته شده به عنوان حذف شده توسط %s حذف شد @@ -119,8 +119,8 @@ خطا در لغو تغییر نشانی خطا در انطباق زمانی اتصال آزمایش در گام %s ناموفق بود. - سرور برای بارگذازی به اجازه نیاز دارد، گذرواژه را بررسی کنید - احتمال دارد اثر انگشت گواهینامه در نشانی سرور نادرست باشد + سرور برای بارگذاری نیاز به مجوز دارد، گذرواژه را بررسی کنید. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد. ایجاد صف بارگذاری فایل بارگیری فایل @@ -140,7 +140,7 @@ صف امن حذف صف ایجاد فایل - سرور برای ایجاد صف داده‌ها به اجازه نیاز دارد، گذرواژه را بررسی کنید + سرور برای ایجاد صف‌ها نیاز به مجوز دارد، گذرواژه را بررسی کنید. مگر اینکه مخاطبتان اتصال را حذف کرده یا این لینک قبلا استفاده شده باشد، ممکن است این یک اشکال باشد - لطفا آن را گزارش دهید. \nبرای متصل شدن، لطفا از مخاطبتان بخواهید لینک اتصال دیگری ایجاد کند و بررسی کنید که اتصال شبکه باثباتی دارید. عملکرد کند @@ -998,7 +998,7 @@ شما را حذف کرد رمزنگاری مورد توافق قرار گرفت ناظر - عضو پیشین %1$s + اعضا %1$s بسط دادن انتخاب نقش عبارت عبور رمزنگاری پایگاه داده به‌روز خواهد شد. درخواست اتصال کرد @@ -1938,7 +1938,7 @@ خطا در فوروارد کردن پیام‌ها خطا در ایجاد گزارش خطا در پذیرفتن عضو - خطا در پاک کردن چت با عضو + خطا در حذف کردن چت پیوند اتصال پشتیبانی‌نشده این پیوند به نسخه جدیدتری از برنامه نیاز دارد. لطفاً برنامه را به‌روزرسانی کنید یا از مخاطب خود بخواهید پیوند سازگاری بفرستد. اتصال مسدود شد @@ -2528,4 +2528,8 @@ فقط مخاطب شما می‌تواند فایل‌ها و رسانه‌ها را ارسال کند. ارسال فایل‌ها و رسانه‌ها در این چت ممنوع است. پل ارتباطی سیمپلکس + خطا در علامت گذاری به عنوان خوانده شده + اثر انگشت در نشانی سرور مقصد با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور انتقال با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد: ‎%1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 1dd6598ef3..c6b094ab56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1036,7 +1036,7 @@ Profiilisi lähetetään kontaktille, jolta sait tämän linkin. Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). + Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). sinä kertalinkillä %1$s:n kautta diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 46b6e6e2fd..563b1a02c7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -52,7 +52,7 @@ Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre. Erreur de connexion Erreur lors de l\'ajout de membre·s - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). + Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). format de message invalide Lien entier Erreur lors de la sauvegarde des serveurs SMP diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index a3c6a65e32..4162120ef7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1289,7 +1289,7 @@ A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a kapcsolatot és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. A partnerei továbbra is kapcsolódva maradnak. - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. @@ -1305,9 +1305,9 @@ Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. Kapcsolódni fog a csoport összes tagjához. - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! Ellenőrizze a hálózati kapcsolatát vele: %1$s, és próbálja újra. @@ -1317,12 +1317,12 @@ A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %1$s. A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Ön egy egyszer használható meghívót osztott meg inkognitóban - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a beállításokban Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s @@ -2502,4 +2502,7 @@ Hiba a csevegés olvasottként való megjelölésekor A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + nincs előfizetés + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 55b108cfb9..909c6c7cfe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -103,8 +103,8 @@ Menghubungkan Tautan tidak valid Periksa apakah tautan SimpleX sudah benar. - Anda terhubung ke server yang digunakan untuk menerima pesan dari kontak ini. - Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). + Anda terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini. + Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). Migrasi basis data sedang berlangsung, \nmemerlukan waktu beberapa menit. menghubungkan @@ -112,7 +112,7 @@ Tampilan macet Anda membagikan lokasi file yang tidak valid. Laporkan masalah ini ke pengembang aplikasi. galat - Tautan sekali + Tautan 1-kali %1$d pesan yang terlewati %1$d pesan yang dilewati %s tidak didukung. Harap pastikan kamu menggunakan versi yang sama pada kedua perangkat.]]> @@ -307,7 +307,7 @@ Kontak %d pesan dihapus dihapus - Mencoba terhubung ke server untuk menerima pesan dari kontak ini. + Mencoba terhubung ke server untuk menerima pesan dari koneksi ini. disimpan diundang untuk terhubung Deskripsi @@ -768,19 +768,19 @@ koneksi %1$d koneksi terjalin menghubungkan… - Anda bagikan tautan sekali - Anda bagikan tautan sekali samaran + Anda bagikan tautan 1-kali + Anda bagikan tautan samaran 1-kali via tautan grup samaran via tautan grup via tautan alamat kontak samaran via tautan alamat kontak - via tautan sekali + via tautan 1-kali Alamat kontak SimpleX - samaran via tautan sekali + samaran via tautan 1-kali Tautan lengkap Tautan grup SimpleX Tautan SimpleX - Undangan sekali SimpleX + Undangan 1-kali SimpleX via %1$s Gagal simpan server XFTP Nama tampilan tidak valid! @@ -1151,7 +1151,7 @@ Harap periksa apakah tautan yang digunakan benar atau minta kontak Anda untuk kirim tautan lain. Gagal menerima permintaan kontak Galat - Mungkin sidik jari sertifikat di alamat server salah + Sidik jari pada alamat server tidak cocok dengan sertifikat. Gagal mengatur alamat Gagal hapus profil pengguna Hapus antrian @@ -1193,7 +1193,7 @@ Gagal memuat obrolan Nama tampilan ini tidak valid. Silakan pilih nama lain. Pengirim mungkin telah hapus permintaan koneksi. - Server perlu otorisasi untuk membuat antrian, periksa kata sandi + Server perlu otorisasi untuk membuat antrean, periksa kata sandi. Gagal menghapus permintaan kontak Gagal menghapus koneksi kontak tertunda Gagal mengubah alamat @@ -1262,7 +1262,7 @@ Server tak dikenal! Kecuali kontak Anda hapus koneksi atau tautan ini sudah digunakan, mungkin ini adalah bug - harap laporkan.\nUntuk terhubung, harap minta kontak Anda untuk buat tautan koneksi lain dan periksa apakah Anda memiliki koneksi jaringan stabil. Gagal sinkronkan koneksi - Server perlu otorisasi untuk mengunggah, periksa kata sandi + Server perlu otorisasi untuk mengunggah, periksa kata sandi. Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> @@ -1340,7 +1340,7 @@ Ketentuan akan diterima pada: %s. Koneksi Peran - Ganti rol + Ganti peran Profil obrolan Anda akan dikirim ke anggota grup Profil obrolan Anda akan dikirim ke anggota obrolan Profil grup disimpan di perangkat anggota, bukan di server. @@ -1411,7 +1411,7 @@ Peningkatan basis data Konfirmasi peningkatan basis data Turunkan dan buka obrolan - mengubah hak %s menjadi %s + mengubah peran %s menjadi %s Dihapus pada: %s Hapus profil %1$s!]]> @@ -1491,7 +1491,7 @@ Anda dapat konfigurasi operator di pengaturan Jaringan dan server. Kesalahan basis data Frasa sandi basis data berbeda dengan yang disimpan di Keystore. - Ubah hak grup? + Ubah peran grup? %s.]]> Ketentuan Penggunaan %s.]]> @@ -1554,7 +1554,7 @@ Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. Obrolan sedang berjalan Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. - mengubah hak Anda jadi %s + mengubah peran Anda jadi %s mengubah alamat… mengubah alamat untuk %s… ID basis data: %d @@ -1670,7 +1670,7 @@ Gagal kirim undangan Undang Status berkas - Gagal ganti hak + Gagal ganti peran Gagal hapus anggota Samaran Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. @@ -1713,7 +1713,7 @@ Grup tidak aktif Undangan kedaluwarsa! profil grup diperbarui - Hak awal + Peran awal Gagal membuat tautan grup Gagal hapus tautan grup Gagal perbarui tautan grup @@ -1761,7 +1761,7 @@ Nama lengkap: Untuk melanjutkan, obrolan harus dihentikan. negosiasi ulang enkripsi diperlukan untuk %s - Perluas pemilihan hak + Perluas pemilihan peran Ditemukan desktop Gagal simpan pengaturan enkripsi ok untuk %s @@ -2069,8 +2069,8 @@ Anda dapat ubah di pengaturan Tampilan. Grup ini tidak ada lagi. Anda menolak undangan grup - Anda mengubah hak Anda menjadi %s - Anda mengubah hak %s menjadi %s + Anda mengubah peran Anda menjadi %s + Anda mengubah peran %s menjadi %s Lihat ketentuan Server untuk berkas baru dari profil obrolan Anda saat ini Perbarui aplikasi secara otomatis @@ -2081,7 +2081,7 @@ Anda sudah terhubung melalui tautan 1-kali ini! Gunakan dari desktop Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. - Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. Anda diundang ke grup Menunggu ponsel terhubung: @@ -2121,7 +2121,7 @@ Anda mengubah alamat untuk %s profil grup diperbarui %s, %s dan %s terhubung - Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. minggu Gagal unggah Mengunggah arsip @@ -2174,7 +2174,7 @@ Server SMP Profil obrolan Anda akan dikirim\nke kontak Anda Bagikan alamat - Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Peran akan diubah menjadi %s. Anggota akan menerima undangan baru. Perbarui pengaturan jaringan? Ketuk untuk aktifkan profil. Mulai dari %s. @@ -2388,7 +2388,7 @@ Tinjau anggota sebelum menerima (mengetuk) Mengobrol dengan pengurus semua - Ada kesalahan saat menghapus obrolan dengan anggota + Gagal hapus obrolan Laporan telah dikirim ke pengurus Anda tidak dapat mengirim pesan! kontak telah dihapus @@ -2503,4 +2503,10 @@ Tingkatkan tautan grup? Gunakan profil samaran Pesan sambutan + Gagal menandai dibaca + Sidik jari di alamat server tujuan tidak cocok dengan sertifikat: %1$s. + Sidik jari pada alamat server penerusan tidak cocok dengan sertifikat: %1$s. + Sidik jari di alamat server tidak cocok dengan sertifikat: %1$s. + tidak berlangganan + Anda tidak terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini (tidak berlangganan). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 35b0bab541..15641be96f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -18,8 +18,8 @@ connesso errore in connessione - Sei connesso al server usato per ricevere messaggi da questo contatto. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. eliminato contrassegnato eliminato l\'invio di file non è ancora supportato @@ -73,7 +73,7 @@ Errore di eliminazione della connessione del contatto in attesa Errore cambiando l\'indirizzo Test fallito al passo %s. - Il server richiede l\'autorizzazione di creare code, controlla la password + Il server richiede l\'autorizzazione di creare code, controlla la password. Connetti Crea coda Coda sicura @@ -201,7 +201,7 @@ Ripristina OK Connettere via indirizzo del contatto? - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). + Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). Ti connetterai a tutti i membri del gruppo. connessione %1$d Descrizione @@ -210,7 +210,7 @@ Sei già connesso a %1$s. A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo. \nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile. - Probabilmente l\'impronta del certificato nell\'indirizzo del server è sbagliata + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato. SimpleX funziona in secondo piano invece di usare le notifiche push.]]> Consentilo nella prossima schermata per ricevere le notifiche immediatamente.]]> Servizio SimpleX Chat @@ -1015,7 +1015,7 @@ In attesa del video Errore nel caricamento dei server XFTP Errore nel salvataggio dei server XFTP - Il server richiede l\'autorizzazione per l\'invio, controlla la password + Il server richiede l\'autorizzazione per l\'invio, controlla la password. Confronta file Crea file Scarica file @@ -2437,7 +2437,7 @@ Eliminare la chat con il membro? Rifiutare il membro? Elimina chat - Errore di eliminazione della chat con il membro + Errore di eliminazione della chat Aggiorna l\'indirizzo Accetta la richiesta di contatto Aggiungi un messaggio @@ -2535,5 +2535,10 @@ Apri link completo Rimuovi il tracciamento del link Link del relay SimpleX - Errore nel segnare la chat con il membro come letta + Errore nel segnare la lettura + L\'impronta digitale nell\'indirizzo del server di destinazione non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server di inoltro non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato: %1$s. + nessuna iscrizione + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index b71831c9e0..6b413c9bfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1063,7 +1063,7 @@ כדי לאמת הצפנה מקצה־לקצה עם איש הקשר שלכם, יש להשוות (או לסרוק) את הקוד במכשירים שלכם. פרופילי צ׳אט לעדכן מצב בידוד תעבורה\? - מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). + מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). פורמט הודעה לא ידוע דרך הדפדפן בטל השתקה diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index efc1810bac..cc85e49a8c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -887,7 +887,7 @@ あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに公開されます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 - このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 + このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 使用済みリンク、または連絡先による接続の削除ではなければ、バッグの可能性があります。開発者にお伝えください。 \n繋がるには、連絡先に新しくリンクを発行してもらって、電波が安定かどうかご確認ください。 接続を完了するには、連絡相手がオンラインになる必要があります。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index adf66650f1..1e71459df9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1404,7 +1404,7 @@ Jūsų profilis bus išsiųstas kontaktui iš kurio gavote šią nuorodą. Prisijungsite prie visų grupės narių. Esate prisijungę prie serverio skirto gauti žinutes iš šio kontakto. - Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). + Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto. nėra detalių SimpleX fono tarnybą - ji naudoja kelis procentus akumuliatoriaus per dieną.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml new file mode 100644 index 0000000000..2843b2cf8d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -0,0 +1,2503 @@ + + + 1 minūte + 1 mēnesis + tūkst. + Vai izveidot savienojumu, izmantojot vienreizēju saiti? + Pievienoties grupai? + Izmantot pašreizējo profilu + Izmantot jaunu inkognito profilu + Izmantot inkognito profilu + Jūsu profils tiks nosūtīts kontaktpersonai, no kuras saņēmāt šo saiti. + Jūs pievienosities visiem grupas dalībniekiem. + Pievienoties + Atvērt tērzēšanu + Atvērt jaunu tērzēšanu + Atvērt grupu + Atvērt jaunu grupu + Nederīga saite + Lūdzu, pārbaudiet, vai SimpleX saite ir pareiza. + Tiek atvērta datubāze… + Notiek datubāzes migrācija.\nTas var aizņemt dažas minūtes. + Nederīgs faila ceļš + Jūs kopīgojāt nederīgu faila ceļu. Ziņojiet par problēmu lietotnes izstrādātājiem. + Skats pārstāja darboties + savienots + kļūda + notiek savienošana + Jūs esat izveidojis savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + Mēģina izveidot savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + izdzēsts + atzīmēts kā izdzēsts + %d ziņojumi atzīmēti kā izdzēsti + moderē %s + %1$d ziņojumus moderē %2$s + To redzat tikai Jūs un moderatori + 1 diena + 1 nedēļa + 1 gads + 30 sekundes + 4 jaunas saskarnes valodas + 5 minūtes + 6 jaunas saskarnes valodas + a + b + Pārtraukt + Atcelt adreses maiņu + Vai atcelt adreses maiņu? + bloķēts + bloķējis administrators + %d ziņojumi bloķēti + Administrators bloķējis %d ziņojumus + failu sūtīšana vēl netiek atbalstīta + failu saņemšana vēl netiek atbalstīta + Jūs + pārsūtīts + saglabāts + saglabāts no %s + nederīga tērzēšana + nederīgi dati + kļūda, rādot ziņojumu + kļūda, rādot saturu + Atšifrēšanas kļūda + Šifrēšanas atkārtotas saskaņošanas kļūda + Šī tērzēšana ir aizsargāta ar pilnīgu šifrēšanu. + Šo tērzēšanu aizsargā kvantu izturīga pilnīga šifrēšana. + Privātās piezīmes + pieprasīts savienojums + pieņemts ielūgums + savienojas… + Jūs kopīgojāt vienreizēju saiti + Pilnā saite + Saites atvēršana pārlūkprogrammā var samazināt savienojuma privātumu un drošību. Neuzticamas SimpleX saites būs sarkanas. + uzaicināts pievienoties + Savienoties, izmantojot kontaktpersonas saiti + Savienoties, izmantojot saiti inkognito režīmā + Ziņot par vienuma redzamību moderatoriem + Ziņot par vienuma arhivēšanu + Ziņot par vienuma arhivēšanu, ko veica + Nezināms ziņojuma formāts + Nederīgs ziņojuma formāts + Tiešraide + Moderēts apraksts + E2ee Info E2ee + E2ee Info No Pq + E2ee Info Pq + Savienojuma lokālais displeja vārds + Displeja vārds Savienojums izveidots + Apraksts Jūs kopīgojāt vienreizējo saiti inkognito režīmā + Apraksts Izmantojot grupas saiti + Apraksts Izmantojot grupas saiti inkognito režīmā + Apraksts Izmantojot kontaktadreses saiti + Apraksts Izmantojot kontaktadreses saiti inkognito režīmā + Apraksts, izmantojot vienreizēju saiti + Apraksts, izmantojot vienreizēju saiti inkognito režīmā + Simplex saites kontakts + Simplex saites uzaicinājums + Simplex saites grupa + Simplex saites kanāls + Simplex saites relejs + Simplex saites savienojums + Simplex saites režīms + Simplex saites režīma apraksts + Simplex Link Mode Pārlūkprogramma + Ziņošanas iemesls: Spams + Ziņošanas iemesls: Nelegāls saturs + Ziņošanas iemesls: Kopienas noteikumu pārkāpums + Ziņošanas iemesls: Profils + Ziņošanas iemesls: Cits + Kļūda, saglabājot Smp serverus + Kļūda, saglabājot Xftp serverus + Pārliecinieties, vai Smp serveru adreses ir pareizā formātā un unikālas + Pārliecinieties, vai Xftp serveru adreses ir pareizā formātā un unikālas + Kļūda ielādējot Smp serverus + Kļūda ielādējot Xftp serverus + Kļūda iestatot tīkla konfigurāciju + Neizdevās parsēt čata nosaukumu + Neizdevās parsēt čatu nosaukumus + Sazinieties ar izstrādātājiem + Neizdevās izveidot lietotāju + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (nepareizs) + Neizdevās izveidot lietotāju, nederīgs apraksts + Neizdevās aktivizēt lietotāju + Neizdevās saglabāt serverus + Nav konfigurēti ziņojumu serveri + Nav konfigurēti ziņojumu serveri saņemšanai + Nav konfigurēti ziņojumu serveri privātai maršrutēšanai + Nav konfigurēti mediju serveri + Nav konfigurēti mediju serveri sūtīšanai + Nav konfigurēti mediju serveri privātai maršrutēšanai + Čata profilam + Kļūdas serveru konfigurācijā + Kļūda, pieņemot operatora nosacījumus + Bloķēšanas iemesls: spams + Bloķēšanas iemesls: saturs + Savienojuma taimauts + Savienojuma kļūda + Tīkla kļūda: nezināms CA + Tīkla kļūdas apraksts + Tīkla kļūda: brokera resursdatora apraksts + Tīkla kļūda: brokera versijas apraksts + Privātās maršrutēšanas taimauts + Privātās maršrutēšanas kļūda + Privātās maršrutēšanas sesijas nav + Smp Proxy kļūda, nezināms CA + Smp Proxy kļūda, savienojuma izveide + Smp Proxy kļūda, brokera resursdators + Smp Proxy kļūda, brokera versija + Proxy galamērķa kļūda, nezināms CA + Proxy galamērķa kļūda, neizdevās izveidot savienojumu + Proxy galamērķa kļūda, brokera resursdators + Proxy Destination Error Broker Version + Lūdzu, mēģiniet vēlāk + Kļūda, sūtot ziņojumu + Kļūda, pārsūtot ziņojumus + Kļūda, veidojot ziņojumu + Kļūda, veidojot atskaiti + Kļūda, ielādējot detaļas + Kļūda, pievienojot dalībniekus + Kļūda, pievienojoties grupai + Kļūda, apstiprinot dalībnieku + Kļūda, atzīmējot dalībnieka atbalsta čatu kā lasītu + Kļūda, dzēšot dalībnieka atbalsta čatu + Nevar saņemt failu + Sūtītājs atcēla faila pārsūtīšanu + Fails Nav Apstiprināts (Nosaukums) + Fails Nav Apstiprināts (Apraksts) + %d Citas Failu Kļūdas + Kļūda, saņemot failu + %d Failu Kļūdas + Kļūda, veidojot adresi + Kontakts jau eksistē + Jūs jau esat savienots ar %s caur šo saiti + Nederīga savienojuma saite + Lūdzu, pārbaudiet pareizu saiti un, iespējams, pieprasiet jaunu + Neatbalstīta savienojuma saite + Saite prasa jaunāku lietotnes versiju, lūdzu, atjauniniet + Savienojuma kļūda Auth + Savienojuma kļūda Auth apraksts + Savienojuma kļūda Bloķēts + Savienojuma kļūda Bloķēts apraksts + Auth Open Migration uz citu ierīci + Bloķēšana nav iespējota + Jūs varat ieslēgt bloķēšanu + Ziņojuma piegādes kļūdas virsraksts + Ziņojuma piegādes brīdinājuma virsraksts + Ziņojuma piegādes kļūdas apraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas virsraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas apraksts + Ziņošanas iemesla brīdinājuma virsraksts + Ziņošanas arhīva brīdinājuma virsraksts + Pārsūtīt Brīdinājumu Pārsūtīt Ziņas Bez Failiem + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Apraksta + Pārsūtīt Failus Nav Akceptēts Apraksts + Pārsūtīt Failus Notiek Apraksts + Pārsūtīt Failus Neizdevās Saņemt Apraksts + Pārsūtīt Failus Trūkst Apraksts + Pārsūtīt Failus Nav Akceptēts Saņemt Failus + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Nosaukuma + Čata Saraksts Izlase + Čata Saraksts Kontakti + Čatu saraksta grupas + Čatu saraksta uzņēmumi + Čatu saraksta piezīmes + Čatu saraksta grupu atskaites + Paziņojumu grupas atskaite + Čatu saraksts Visi + Čatu saraksts Pievienot sarakstu + Grupu atskaites Aktīva viena + Grupu atskaites Aktīvas + Grupu atskaites Dalībnieku atskaites + Jauni atbalsta grupas ziņojumi + Jaunas atbalsta grupas čata sarunas + Jauna atbalsta grupas čata saruna + Jaunas atbalsta grupas čata sar. + Pievienoties čata sarunai + Nosūtīt pieprasījumu, lai pievienotos + Pievienoties, lai izmantotu botu + Apstiprināt kontaktpersonas pieprasījumu + Jūsu kontaktpersona + Bots + Čata baneris - pievienoties grupai + Čata baneris - tava grupa + Čata baneris - grupa + Čata baneris - biznesa savienojums + Čata baneris - tavs biznesa kontakts + Dalīties ar ziņu + Dalīties ar attēlu + Dalīties ar failu + Pārsūtīt ziņu + Pārsūtīt vairākas + Nevar koplietot ziņas brīdinājuma virsraksts + Nevar koplietot ziņas brīdinājuma teksts + Pievienot + Ikonas apraksts kontekstam + Ikonas apraksts attēla priekšskatījuma atcelšanai + Ikonas apraksts faila priekšskatījuma atcelšanai + Attēlu limita virsraksts + Video limita virsraksts + Attēlu limita apraksts + Video limita apraksts + Attēla atkodēšanas izņēmums + Attēla atkodēšanas izņēmuma apraksts + Video atkodēšanas izņēmuma apraksts + Faili un multivide ir aizliegti + Tikai īpašnieki var atļaut failus un multividi + Rakstiet un sūtiet tiešo ziņojumu, lai izveidotu savienojumu + Pārsūtīt ziņojumus (%d) + Saglabāt ziņojumus (%d) + SimpleX saites nav atļautas + Faili un multivide nav atļauti + Balss ziņas nav atļautas + Ierakstiet ziņu + Maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums (ne teksts) + Ir sasniegts maksimālais ziņas lielums (pārsūtīšana) + Ziņot par iemeslu: spams + Ziņot par iemeslu: profils + Ziņot par iemeslu: kopiena + Ziņot par iemeslu: nelikumīgs saturs + Ziņošanas iemesla virsraksts - cits + Ziņojums Nosūtīts - Brīdinājuma Virsraksts + Ziņojums Nosūtīts - Brīdinājuma Ziņa - Skatīt Atbalsta Čatā + Pievienoties grupai + Pievienot ziņu + Savienoties + Vai sūtīt kontakta pieprasījumu? + Sūtot kontakta pieprasījumu, jūs atklāsiet savu SimpleX lietotājvārdu šim kontaktam. Vai vēlaties turpināt? + Sūtīt pieprasījumu bez ziņas + Sūtīt pieprasījumu + Nevar nosūtīt ziņu + Nevar nosūtīt ziņu – kontaktpersona nav gatava + Nevar nosūtīt ziņu – pieprasījums ir nosūtīts + Nevar nosūtīt ziņu – kontaktpersona ir izdzēsta + Nevar nosūtīt ziņu – kontaktpersona nav sinhronizēta + Nevar nosūtīt ziņu – kontaktpersona ir atspējota + Novērotājs nevar nosūtīt ziņu + Novērotājs nevar nosūtīt ziņu + Nevar nosūtīt ziņu – noraidīts + Nevar nosūtīt ziņu – grupa ir izdzēsta + Nevar nosūtīt ziņu, dalībnieks ir noņemts + Nevar nosūtīt ziņu, jūs esat izgājis + Nevar nosūtīt ziņu + Jūs esat vērotājs + Pārbaudīts ar administratoriem + Nevar nosūtīt ziņu, dalībniekam ir veca versija + Nevar Nosūtīt Komandas Brīdinājuma Teksts + Attēla Apraksts + Ikonas Apraksts Gaidot Attēlu + Ikonas Apraksts Lūgts Saņemt + Ikona Apraksts Attēls Nosūtīts Pabeigts + Gaida Attēlu + Attēls Tiks Saņemts, Kad Kontaktpersona Pabeigs Augšupielādi + Attēls Tiks Saņemts, Kad Kontaktpersona Būs Tiešsaistē + Attēls Saglabāts + Video Apraksts + Ikona Apraksts Gaida Video + Ikona Apraksts Video Pieprasīts Saņemt + Ikona Apraksts Video Nosūtīts Pabeigts + Gaida Video + Izmantot kameras pogu + No galerijas + Izvēlēties failu + Izvēlēties faila nosaukumu + Galerijas attēla poga + Galerijas video poga + Paldies, ka instalējāt SimpleX + Jūs varat sazināties ar SimpleX čata dibinātāju + Lai sāktu jaunu čatu, palīdzības virsraksts + Čata palīdzības pieskāriena poga + Saglabāt neizmantoto uzaicinājuma jautājumu + Jūs varat vēlreiz apskatīt uzaicinājuma saiti + Saglabāt uzaicinājuma saiti + Izveido saiti + Mēģināt vēlreiz + Kopīgojiet šo vienreizējo saiti + Ielīmējiet saņemto saiti + Ielīmētais teksts nav saite + Pieskarieties, lai ielīmētu saiti + Ielādē profilu + Nederīgs Qr kods + Noskenētais kods nav SimpleX Link Qr kods + Dzēstās sarunas + Nav filtrētu kontaktu + Kontaktu saraksta galvenes nosaukums + Konteksta lietotāja atlasītājs - Tavs profils + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma virsrakstu + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma ziņojums + Skenēt kodu + Nepareizs kods + Skenēt kodu no kontaktu lietotnes + Drošības kods + Atzīmēt kodu kā verificētu + Notīrīt verifikāciju + Salīdzināt, lai verificētu + Ir verificēts + Nav verificēts + Tavi iestatījumi + Tava SimpleX kontaktadreses + Tavi čata profili + Izveidot čata profilu + Datubāzes parole un eksportēšana + Par SimpleX Chat + Kā lietot SimpleX Chat + Markdown palīdzība + Markdown ziņās + Čats ar dibinātāju + Sūtiet mums e-pastu + Čata slēdzene + Čata konsole + Ziņojumu serveri + Smp serveri + Konfigurēti Smp serveri + Citi Smp serveri + Smp serveru iepriekš iestatīta adrese + Pievienot iepriekš iestatītu Smp serveri + Pievienot Smp serveri + Smp serveru testa serveris + Smp serveru testēšana + Saglabāt Smp serverus + Smp serveru tests neizdevās + Dažu Smp serveru tests neizdevās + Smp serveru QR koda skenēšana + Smp serveru ievadīšana manuāli + Smp serveru jauns serveris + Smp serveru iepriekš iestatīts serveris + Smp serveru jūsu serveris + Smp serveru jūsu servera adrese + Smp serveru izmantot serveri + Smp serveru izmantot serveri jaunam savienojumam + Smp Serveru pievienošana citai ierīcei + Smp Serveru nederīga adrese + Smp Serveru adreses pārbaude + Smp Servera dzēšana + Smp Serveri katram lietotājam + Vai saglabāt Smp Serverus? + Multivides un failu serveri + Xftp Serveri + Konfigurēti Xftp Serveri + Citi Xftp Serveri + Abonēšanas Procentuālais Daudzums + Instalēt SimpleX Chat Terminālim + Atiestatīt Visus Padomus + Atzīmēt Ar Zvaigzni Github + Ziedot + Novērtēt Aplikāciju + Izmantot SimpleX Chat Serverus? + Jūsu SMP Serveri + Jūsu XFTP Serveri + Izmantojot SimpleX Chat Serverus + Kā to darīt + Kā lietot savus serverus + Saglabātie ICE serveri tiks noņemti + Jūsu ICE serveri + Konfigurēt ICE serverus + Ievadiet vienu ICE serveri katrā rindā + Kļūda, saglabājot ICE serverus + Pārliecinieties, vai ICE serveru adreses ir pareizā formātā un unikālas + Poga \"Saglabāt serverus + Tīkls un serveri + Tīkla iestatījumi + Tīkla iestatījumu nosaukums + Tīkla Socks Proxy + Tīkla Socks Proxy iestatījumi + Tīkla Socks ieslēdzējs izmantot Socks Proxy + Tīkla Proxy autentifikācija + Tīkla Proxy nejauši akreditācijas dati + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas lietotāja + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas entītijas + Tīkla Proxy autentifikācijas režīms bez autentifikācijas + Tīkla starpniekservera autentifikācijas režīms - lietotājvārds un parole + Tīkla starpniekservera lietotājvārds + Tīkla starpniekservera parole + Tīkla starpniekservera ports + Nepareiza tīkla starpniekservera konfigurācija + Nepareiza tīkla starpniekservera konfigurācija + Resursdatora darbība + Porta darbība + Iespējot SOCKS tīklu + SOCKS tīkla informācija + Slīpsvītra Teksts + Pārsvītrots Teksts + Krāsains Teksts + Slepenais Teksts + Zvana Statuss Zvana + Zvana Statuss Garām Palaists + Zvana Statuss Noraidīts + Zvana Statuss Pieņemts + Zvana Statuss Savienojas + Zvana Statuss Notiek + Kontakts vēlas savienoties, izmantojot zvanu + Videozvans bez šifrēšanas + Šifrēts videozvans + Audiozvans bez šifrēšanas + Šifrēts audiozvans + Pieņemt + Noraidīt + Ignorēt + Zvans jau ir beidzies + Ikonas apraksts videozvanam + Ikona Audio zvans + Zvana piekļuves atļauja darbvirsmai noraidīta + Zvana piekļuves atļauja darbvirsmai noraidīta Chrome pārlūkā + Zvana piekļuves atļauja darbvirsmai noraidīta Safari pārlūkā + Audio un video zvanu iestatījumi + Tavi zvani + Vienmēr izmantot releju + Zvans bloķēšanas ekrānā + Pieņemt zvanu bloķēšanas ekrānā + Rādīt zvanu bloķēšanas ekrānā + Zvans nav atļauts bloķēšanas ekrānā + Jūsu Ice Serveri + Webrtc Ice Serveri + Relay Serveris Aizsargā Ip + Relay Serveris Ja Nepieciešams + Atveriet SimpleX Chat, lai pieņemtu zvanu + Atļaut zvanu pieņemšanu no bloķēšanas ekrāna + Atvērt + Statuss E2E Šifrēts + Statuss Bez E2E Šifrēšanas + Statusa kontaktam ir E2E šifrēšana + Statusa kontaktam nav E2E šifrēšanas + Zvana savienojums starp lietotājiem (Peer To Peer) + Zvana savienojums caur releju + Ikonas apraksts - pārtraukt zvanu + Ikonas apraksts - izslēgt video + Ikonas apraksts - ieslēgt video + Ikonas apraksts - izslēgt audio + Ikonas apraksts - ieslēgt audio + Ikonas apraksts - izslēgt skaļruni + Ikona Apraksts Skaļrunis Ieslēgts + Ikona Apraksts Skaņa Izslēgta + Ikona Apraksts Apgriezt Kameru + Ikona Apraksts Zvans Gaida Nosūtīšanu + Ikona Apraksts Neatbildēts Zvans + Ikona Apraksts Zvans Noraidīts + Ikona Apraksts Zvans Savienojas + Ikona Apraksts Zvans Notiek + Ikona Apraksts Zvans Beidzās + Atbildēt uz Zvanu + Izlaista integritātes ziņa + Bojāts integritātes ziņas jaucējkods (hash) + Bojāts integritātes ziņas ID + Dublēta integritātes ziņa + Brīdinājums par izlaistām ziņām + Brīdinājums par izlaistām ziņām. Tas var notikt, kad + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu ID + Brīdinājums par ziņu ar bojātu ID + Brīdinājums: Atšifrēšanas kļūda, neizdevās atšifrēt %d ziņas + Brīdinājums: Atšifrēšanas kļūda, pārāk daudz izlaistu ziņu + Brīdinājums: Fragmentu šifrēšana nav sinhronizēta, vecā datubāze + Brīdinājums: Neizdevās šifrēšanas atkārtota vienošanās + Brīdinājums: Lūdzu, ziņojiet par šo kļūdu izstrādātājiem + Privātums un drošība + Tavs privātums + Aizsargāt lietotnes ekrānu + Šifrēt lokālās datnes + Automātiski pieņemt attēlus + Aizsargāt IP adresi + Lietotne lūgs apstiprināt nezināmus failu serverus + Bez Tor vai VPN IP adrese būs redzama failu serveriem + Sūtīt saišu priekšskatījumus + Notīrīt saišu pārslēgu + Privātums Rādīt pēdējās ziņas + Privātums Ziņas melnraksts + Pilna rezerves kopija + Ieslēgt bloķēšanu + Bloķēšanas režīms + Bloķēt pēc + Iesniegt piekļuves kodu + Apstiprināt piekļuves kodu + Nepareizs piekļuves kods + Jauns piekļuves kods + Autentifikācija atcelta + La Mode System + La Mode Passcode + La App Passcode + La Mode Off + Parole uzstādīta + Parole mainīta + Parole nav mainīta + Mainīt bloķēšanas režīmu + Pašiznīcināšanās + Aktivizēta pašiznīcināšanās parole + Mainīt pašiznīcināšanās režīmu + Mainīt pašiznīcināšanās paroli + Pašiznīcināšanās parole aktivizēta + Pašiznīcināšanās parole mainīta + Pašiznīcināšanās parole + Ieslēgt pašiznīcināšanos + Pašiznīcināšanās Jauns Parādāmais Vārds + Ja ievadīsiet pašiznīcināšanās kodu + Visi lietotnes dati tiks dzēsti + Lietotnes parole aizstāta ar pašiznīcināšanos + Tiek izveidots tukšs čata profils + Ja ievadīsiet paroli, dati tiks izdzēsti + Uzstādīt paroli + Šis iestatījums ir paredzēts jūsu pašreizējam profilam + Saņemts grupas notikums: 1 dalībnieks pievienojies + Saņemts grupas notikums: 2 dalībnieki pievienojušies + Saņemts grupas notikums: 3 dalībnieki pievienojušies + Saņemts grupas notikums: N dalībnieki pievienojušies + Saņemto grupas notikumu skaits + Saņemti grupas un citi notikumi + Grupas dalībnieki 2 + Grupas dalībnieki N + Saņemts grupas notikums: Atvērt čatu + Profila atjaunināšanas notikums: Kontakta vārds ir mainīts + Snd Conn Event Switch Queue Phase Completed + Snd Conn Event Switch Queue Phase Changing + Conn Event Ratchet Sync Ok + Conn Event Ratchet Sync Allowed + Conn Event Ratchet Sync Required + Conn Event Ratchet Sync Started + Conn Event Ratchet Sync Agreed + Snd Conn Event Ratchet Sync Ok + Snd Conn Event Ratchet Sync Allowed + Snd Conn Event Ratchet Sync Required + Snd Conn Event Ratchet Sinhronizācija Sākta + Snd Conn Event Ratchet Sinhronizācija Apstiprināta + Rcv Conn Event Verifikācijas Kods Atiestatīts + Conn Event Iespējots Pq + Conn Event Atspējots Pq + Grupas Dalībnieka Loma Vērotājs + Grupas Dalībnieka Loma Autors + Grupas Dalībnieka Loma Dalībnieks + Grupas Dalībnieka Loma Moderators + Grupas Dalībnieka Loma Administrators + Grupas dalībnieka loma - īpašnieks + Grupas dalībnieka statuss - noraidīts + Grupas dalībnieka statuss - izņemts + Grupas dalībnieka statuss - atstājis + Grupas dalībnieka statuss - grupa izdzēsta + Grupas dalībnieka statuss - nezināms + Grupas dalībnieka statuss - uzaicināts + Grupas dalībnieka statuss - gaida apstiprinājumu + Grupas dalībnieka statuss - gaida apstiprinājumu (saīsināts) + Grupas dalībnieka statuss - gaida pārskatīšanu + Grupas dalībnieka statuss Gaida apstiprinājumu (īsais) + Grupas dalībnieka statuss Iepazīstināts + Grupas dalībnieka statuss Iepazīstināšanas uzaicinājums + Grupas dalībnieka statuss Pieņemts + Grupas dalībnieka statuss Paziņots + Grupas dalībnieka statuss Savienots + Grupas dalībnieka statuss Pabeigts + Grupas dalībnieka statuss Izveidotājs + Grupas dalībnieka statuss Savienojas + Grupas dalībnieka statuss Nezināms (īsais) + Iepriekšējais dalībnieks Vārds + Nav kontaktu, ko pievienot + Jaunā dalībnieka loma + Sākotnējā dalībnieka loma + Ikonas apraksts - izvērst lomu + Uzaicināt uz grupu + Uzaicināt uz čatu + Izlaist uzaicināšanu + Izvēlieties kontaktus + Ikonas apraksts - kontakts atzīmēts + Notīrīt kontaktu atlases pogu + Atlasīto kontaktu skaits + Nav atlasītu kontaktu + Uzaicinājums aizliegts + Uzaicinājuma aizlieguma apraksts + Poga Pievienot dalībniekus + Poga Pievienot komandas dalībniekus + Poga Pievienot draugus + Grupas informācijas sadaļas virsraksts - dalībnieku skaits + Grupas informācija - Tu + Poga Dzēst grupu + Poga Dzēst čatu + Vai dzēst grupu? + Vai dzēst čatu? + Dzēšot grupu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot čatu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot grupu sev, šo darbību nevarēs atsaukt. + Dzēšot čatu sev, šo darbību nevarēs atsaukt. + Poga Atstāt grupu + Poga Atstāt čatu + Poga Rediģēt grupas profilu + Poga Pievienot sagaidīšanas ziņu + Poga Sagaidīšanas ziņa + Grupas saite + Izveidot grupas saiti + Poga Izveidot grupas saiti + Dzēst saites jautājumu + Dzēst saiti + Jūs varat dalīties ar grupas saiti, un jebkurš varēs pievienoties + Visi grupas dalībnieki paliks savienoti + Kļūda, veidojot saiti grupai + Kļūda, atjauninot saiti grupai + Kļūda, dzēšot saiti grupai + Kļūda, veidojot dalībnieka kontaktu + Kļūda, sūtot ziņu kontakta uzaicinājumu + Tikai grupas īpašnieki var mainīt iestatījumus + Tikai čata īpašnieki var mainīt iestatījumus + Adreses sadaļas nosaukums + Dalīties ar adresi + Jūs varat dalīties ar šo adresi ar saviem kontaktiem + Koplietojamā teksta atjaunināšanas laiks + Koplietojamā teksta ziņojuma statuss + Koplietojamā teksta faila statuss + Koplietojamā teksta nosūtīšanas laiks + Koplietojamā teksta izveides laiks + Koplietojamā teksta saņemšanas laiks + Koplietojamā teksta dzēšanas laiks + Koplietojamā teksta moderēšanas laiks + Koplietojamā teksta pazušanas laiks + Pašreizējā vienuma informācija + Sūtītājs Ts laikā + Pašreizējās versijas laika zīmogs + Vienuma informācija bez teksta + Saņēmējs: Piegādes statuss + Saglabātās ziņas nosaukums + Poga \"Noņemt dalībnieku? + Poga \"Noņemt dalībniekus? + Poga \"Noņemt dalībnieku + Poga \"Atbalsta čata dalībnieks + Poga \"Sūtīt tiešo ziņu + Gaišā tēma + Tumšā tēma + SimpleX tēma + Melnā tēma + Sistēmas valoda + Tēma + Krāsu režīms + Tumšā tēma + Tumšā režīma krāsas + Importēt tēmu + Motīva importēšanas kļūda + Motīva importēšanas kļūdas apraksts + Eksportēt motīvu + Atiestatīt krāsu + Atiestatīt atsevišķu krāsu + Motīva galamērķa lietotnes motīvs + Primārā krāsa + Primārās krāsas variants + Sekundārā krāsa + Sekundārās krāsas variants + Fona krāsa + Virsmas krāsa + Virsraksta krāsa + Primary Variant2 krāsa + Nosūtītās ziņas krāsa + Nosūtītā citāta krāsa + Saņemtās ziņas krāsa + Saņemtā citāta krāsa + Fona tapetes krāsa + Fona tapetes tonis + Motīva Attēla Noņemšana + Izskata Fonta Izmērs + Izskata Tālummaiņa + Izskata Lietotnes Rīkjoslas + Izskata Lietotnē Iebūvēto Joslu Alfa + Izskata Joslu Izplūšanas Rādiuss + Sistēmas Režīma Uznirstošais Paziņojums + Fona Attēla Priekšskatījums - Sveika, Alise! + Fona Attēla Priekšskatījums - Sveiks, Bob! + Fona Attēla Mērogs + Fona Attēla Mērogošana Atkārtoti + Fona Attēla Mērogošana Aizpildīt + Fona Attēla Mērogošana Ietilpināt + Fona Attēla Paplašinātie Iestatījumi + Čata Tēmas Atiestatīšana uz Lietotnes Tēmu + Čata Tēmas Atiestatīšana uz Lietotāja Tēmu + Iestatīt Čata Tēmu uz Noklusējuma Tēmu + Lietot Čata Tēmu Režīmam + Lietot Čata Tēmu Visiem Režīmiem + Lietot Čata Tēmu Gaišajam Režīmam + Tērzēšanas tēma tumšajam režīmam + Tavas atļautās tērzēšanas preferences + Kontakta atļautās tērzēšanas preferences + Tērzēšanas preferenču noklusējums + Tērzēšanas preferences Jā + Tērzēšanas preferences Nē + Tērzēšanas preferences Vienmēr + Tērzēšanas preferences Ieslēgts + Tērzēšanas preferences Izslēgts` + Tērzēšanas preferences + Kontaktu preferences + Grupu preferences + Iestatīt grupu preferences + Iestatīt dalībnieku uzņemšanu + Tavas preferences + Ziņas ar taimeri + Tiešās ziņas + Pilnīga dzēšana + Ziņu reakcijas + Balss ziņas + Faili un Multivide + SimpleX Saites + Nesenā Vēsture + Audio Video Zvani + \nPieejams V51 versijā + Funkcija Ieslēgta + Funkcija Ieslēgta Jums + Funkcija Ieslēgta Kontaktam + Funkcija Izslēgta + Funkcijas Saņemšana Aizliegta + Pieņemt funkciju + Pieņemt funkciju kopumu 1 dienu + Atļaut saviem kontaktiem sūtīt pazūdošus ziņojumus + Atļaut pazūdošus ziņojumus tikai tad, ja + Pazūšanas laiks ir iestatīts tikai jauniem kontaktiem + Aizliegt sūtīt pazūdošus ziņojumus + Atļaut saviem kontaktiem neatgriezeniski dzēst + Atļaut neatgriezenisku ziņojumu dzēšanu tikai tad, ja + Kontakti var atzīmēt ziņojumus dzēšanai + Atļaut saviem kontaktiem sūtīt balss ziņojumus + Atļaut balss ziņas tikai tad, ja + Aizliegt balss ziņu sūtīšanu + Atļaut jūsu kontaktiem sūtīt failus un multivides saturu + Atļaut failus un multivides saturu tikai tad, ja + Aizliegt failu un multivides satura sūtīšanu + Atļaut jūsu kontaktiem pievienot ziņu reakcijas + Atļaut ziņu reakcijas tikai tad, ja + Aizliegt ziņu reakcijas + Atļaut jūsu kontaktiem zvanīt + Atļaut zvanus tikai tad, ja + Aizliegt zvanus + Gan jūs, gan jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Tikai jūs varat sūtīt pazūdošus ziņojumus + Tikai jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Pazūdoši ziņojumi šajā čatā ir aizliegti + Gan jūs, gan jūsu kontaktpersonas var dzēst + Tikai jūs varat dzēst ziņojumus + Tikai jūsu kontaktpersona var dzēst + Ziņojumu dzēšana ir aizliegta + Gan jūs, gan jūsu kontaktpersona var sūtīt balss ziņojumus + Aizliegt ziņu dzēšanu + Atļaut sūtīt balss ziņas + Aizliegt sūtīt balss ziņas + Atļaut ziņu reakcijas + Aizliegt ziņu reakcijas grupā + Atļaut sūtīt failus + Aizliegt sūtīt failus + Atļaut sūtīt SimpleX saites + Aizliegt sūtīt SimpleX saites + Iespējot nesenās vēstures sūtīšanu + V6 3 Reports Descr + V6 3 Organizēt čatu sarakstus + V6 3 Organizēt čatu sarakstu apraksts + V6 3 Labāka privātums un drošība + V6 3 Privāti multivides failu nosaukumi + V6 3 Iestatīt ziņojumu derīguma termiņu čatos + V6 3 Labāka grupu veiktspēja + V6 3 Ātrāka ziņojumu sūtīšana + V6 3 Ātrāka grupu dzēšana + V6 4 Savienoties ātrāk + V6 4 Ātrāks savienojums Descr + V6 4 Pārskatīt dalībniekus + V6 4 Pārskatīt dalībniekus Descr + V6 4 Atbalsta čats + V6 4 Atbalsta čats Descr + V6 4 Moderatora loma + V6 4 Moderatora loma Descr + V6 4 Ziņojumu piegāde Descr + V6 4 1 Sveicināti kontakti + V6 4 1 Sveicināti kontakti Descr + V6 4 1 Uzturiet tērzēšanas tīras + V6 4 1 Uzturiet tērzēšanas tīras Apraksts + V6 4 1 Īsā adrese + V6 4 1 Īsās adreses izveide + V6 4 1 Īsās adreses atjaunināšana + V6 4 1 Īsās adreses kopīgošana + V6 4 1 Jaunu saskarnes valodu apraksts + Skatīt atjauninātos nosacījumus + Pielāgota laika vienība sekundēs + Pielāgota laika vienība minūtēs + Pielāgots laika vienības stundas + Pielāgots laika vienības dienas + Pielāgots laika vienības nedēļas + Pielāgots laika vienības mēneši + Pielāgota laika izvēles atlasīšana + Pielāgota laika izvēles pielāgošana + Piegādes apstiprinājumu nosaukums + Ieslēgt apstiprinājumus visiem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta visiem profiliem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta + Neieslēgt kvītis + Jūs varat ieslēgt piegādes kvītis vēlāk + Piegādes kvītis ir atspējotas + Jūs varat ieslēgt piegādes kvītis vēlāk (brīdinājums) + Kļūda, ieslēdzot piegādes kvītis + Saistīt mobilo ierīci + Saistītās mobilās ierīces + Skenēt no mobilās ierīces + Pārbaudīt savienojumu + Pārbaudīt kodu mobilajā ierīcē + Šīs ierīces nosaukums + Šīs ierīces versija + Savienots mobilais tālrunis + Savienots ar mobilo tālruni + Ievadiet šīs ierīces nosaukumu + Šīs ierīces nosaukums, kas koplietots ar mobilo tālruni + Kļūda + Šī ierīce + Ierīces + Jauna mobilā ierīce + Atvienot darbvirsmas jautājums + Atvienot darbvirsmu + Atvienot attālo resursdatoru + Atvienot attālos resursdatorus + Attālais resursdators tika atvienots (paziņojums) + Attālais resursdators tika atvienots (virsraksts) + Attālā vadība tika atvienota (virsraksts) + Attālais resursdators atvienots no + Attālā vadība atvienota ar iemeslu + Attālās vadības savienojums apturēts (apraksts) + Remote Ctrl savienojums ir pārtraukts Identity Desc + Kopēšanas kļūda + Vai atvienoties no darbvirsmas? + Vienlaicīgi var darboties tikai viena ierīce + Atveriet mobilajā ierīcē un skenējiet QR kodu + Gaida, kad mobilā ierīce pieslēgsies + Nepareiza darbvirsmas adrese + Nesaderīga darbvirsmas versija + Darbvirsmas lietotnes versija nav saderīga + Darbvirsmas savienojums ir pārtraukts + Sesijas kods + Savienojamies ar darbvirsmu + Gaidām darbvirsmu + Atrasta darbvirsma + Savienoties ar darbvirsmu + Savienots ar darbvirsmu + Savienota darbvirsma + Pārbaudiet kodu ar darbvirsmu + Jauna darbvirsma + Saistītās darbvirsmas + Datoru Ierīces + Saistīto Datoru Iestatījumi + Skenēt QR Kodu No Datora + Datora Adrese + Pārbaudīt Savienojumus + Atklāt Tīklā + Multicast Atklājams Vietējā Tīklā + Multicast Automātiski Savienoties + Ielīmēt Datora Adresi + Datora Ierīce + Nav Saderīgs + Atjaunot QR Kodu + Nav Pievienots Mobilais Tālrunis + Nejaušs Ports + Atvērt Portu Ugunsmūrī + Atvērt Portu Ugunsmūrī Apraksts + Attālā Saimniekdatora Kļūda - Trūkst + Attālā Saimniekdatora Kļūda - Nav Aktīvs + Attālā Saimniekdatora Kļūda - Aizņemts + Attālā Saimniekdatora Kļūda - Noildze + Migrate To Device Imports Neizdevās + Migrate To Device Atkārtot Importu + Migrate To Device Ievadiet Paroli + Migrate To Device Faila Dzēšana Vai Saite Ir Nederīga + Migrate To Device Kļūda Arhīva Lejupielādē + Migrate To Device Čats Ir Pārcelts + Migrate To Device Pabeigt Migrāciju + Migrate To Device Apstipriniet Tīkla Iestatījumus + Migrate To Device Apstipriniet Tīkla Iestatījumu Kājeni + Migrate To Device Lietot Onion + Savienojuma kvotas kļūda + Savienojums neizdevās servera kvotas dēļ. + Kļūda, pieņemot kontaktu pieprasījumu + Kļūda, noraidot kontaktu pieprasījumu + Sūtītājs, iespējams, ir izdzēsis savienojuma pieprasījumu. + Kļūda, dzēšot kontaktu + Kļūda, dzēšot grupu + Kļūda, dzēšot piezīmju mapi + Kļūda, dzēšot kontaktu pieprasījumu + Kļūda, dzēšot gaidošo kontaktu savienojumu + Kļūda, mainot adresi + Kļūda, pārtraucot adreses maiņu + Kļūda, sinhronizējot savienojumu + Kļūda smp testa posmā + Kļūda smp testa servera autentifikācijā + Kļūda xftp testa servera autentifikācijā + Kļūda smp testa sertifikātā + Kļūda, iestādot adresi + Kļūda + Savienot + Atslēgt + Izveidot rindu + Droša rinda + Dzēst rindu + Izveidot failu + Augšupielādēt failu + Lejupielādēt failu + Salīdzināt failu + Dzēst failu + Kļūda, dzēšot lietotāju + Kļūda, atjauninot lietotāja privātumu + Iespējami lēni procesi + Iespējami lēni procesi + Kļūda, atjauninot čata tagus + Kļūda, izveidojot čata tagus + Kļūda, ielādējot čata tagus + Kļūda, sagatavojot kontaktu + Kļūda, sagatavojot grupu + Kļūda, mainot lietotāju + Tūlītējas paziņojumi + Pakalpojumu paziņojumi + Pakalpojumu paziņojumi atslēgti + Lai saglabātu privātumu, Simplex izmanto fona pakalpojumu, nevis uznirstošos paziņojumus, tas patērē mazāk datora akumulatora. + To var atslēgt caur iestatījumiem, paziņojumi joprojām tiek rādīti. + Izslēgt akumulatora optimizāciju + Izslēdzot pakalpojumu un periodiskos paziņojumus + Periodiskie paziņojumi + Periodiskie paziņojumi atslēgti + Periodiski paziņojumi + Izslēgt akumulatora optimizāciju + Izslēgt sistēmas ierobežojumu + Atslēgt paziņojumus + Sistēmas ierobežots fons + Brīdinājums par sistēmas ierobežotu fonu + Sistēmas ierobežots fons zvanā + Sistēmas ierobežots fons zvanā + Brīdinājums par sistēmas ierobežotu fonu zvanā + Ievadiet paroli + Ievadiet paroli + Datu bāzes inicializācijas kļūda + Neizdevās inicializēt datu bāzi. + Xiaomi ignorēt akumulatora optimizāciju + Simplex pakalpojumu paziņojums + Simplex pakalpojumu paziņojuma teksts + Zvana pakalpojumu paziņojums audio zvanam + Zvana pakalpojumu paziņojums video zvanam + Zvana pakalpojumu paziņojums par zvana beigām + Paslēpt paziņojumu + Paziņojumu kanāla ziņas + Paziņojumu kanāla zvanus + Iestatījumi par paziņojumu režīmu + Iestatījumi par paziņojumu priekšskatījuma režīmu + Iestatījumi par paziņojumu priekšskatījumu + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma priekšskatījuma režīms slēpts + Paziņojuma priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma attēlošanas režīms slēpts + Paziņojuma priekšskatījums kādam + Jauna ziņa + Jauns kontaktu pieprasījums + Kontakts savienots + Kļūda, rādot darbvirsmas paziņojumu + SimpleX Bloķēšana + Lai aizsargātu jūsu informāciju, ieslēdziet SimpleX bloķēšanu; jums tiks lūgts pabeigt autentifikāciju, pirms šī funkcija tiks aktivizēta. + Ieslēgt + Bloķēšanas režīms + Izmantot ierīces bloķēšanu + Izmantot lietotnes piekļuves kodu + Autentifikācija neizdevās + Nevarēja pārbaudīt + Nav lietotnes paroli + Ievadiet lietotnes piekļuves kodu + Pašreizējais lietotnes piekļuves kods + Mainīt lietotnes piekļuves kodu + Autentificēt + Uzreiz + sekundes + minūtes + Lūdzu, atcerieties saglabāt paroli + SimpleX bloķēšana ieslēgta + Jums būs jāveic autentifikācija, kad sākat vai atsākat + Atbloķēt + Pieslēgties, izmantojot akreditācijas datus + Ieslēgt SimpleX Bloķēšanu + Izslēgt SimpleX Bloķēšanu + Apstiprināt akreditācijas datus + Autentifikācija nav pieejama + Ierīces autentifikācija nav iespējota; to varat ieslēgt iestatījumos, kad tā būs iespējota. + Ierīces autentifikācija ir atspējota; izslēdzam. + Apturēt sarunu + Atvērt sarunu konsoli + Atvērt sarunu profilus + Ziņojumu arhīvs %dth + Ziņojumu arhīvs visi + Ziņojumu arhīvs + Ziņojumu arhīvs apraksts visi + Ziņojumu arhīvs man + Ziņojumu arhīvs visiem moderātoriem + Ci cita kļūda + Sūtīšanas autentifikācijas kļūda + Sūtīšanas kvotas kļūda + Sūtīšanas derīguma termiņš beidzies + Sūtīšanas pārsūtīšanas kļūda + Sūtīšanas starpniekservera kļūda + Sūtīšanas starpniekservera pārsūtīšanas kļūda + Servera hosta kļūda + Servera versijas kļūda + Faila autentifikācijas kļūda + Faila bloķēšanas kļūda + Faila nav + Faila pārsūtīšanas kļūda + Atbildēt + Kopīgot + Kopēt + Saglabāt + Rediģēt + Informācijas izvēlne + Meklēt + Arhivēt + Arhivēt ziņojumu + Arhivēt ziņojumus + Dzēst ziņojumu + Nosūtīta ziņa + Saņemta ziņa + Rediģēšanas vēsture + Nav vēstures + Atbilde uz + Saglabāts + Pārsūtīts + Saglabāts no + Pārsūtīts no + Saņēmēji nevar redzēt, no kura ir ziņa. + Piegāde + Nav piegādes informācijas + Dzēst + Atklāt + Paslēpt + Atļaut + Moderēt + Ziņot + Izvēlēties + Paplašināt + Dzēst ziņu? + Dzēst ziņas? + Šo darbību nevar atcelt. + Šo darbību nevar atcelt. + Ziņas dzēšanas atzīme dzēsta + Ziņu dzēšanas atzīme dzēsta + Dzēst? + Dzēst dalībniekus? + Moderētā ziņa tiks dzēsta + Moderētās ziņas tiks dzēstas + Moderētā ziņa tiks atzīmēta + Moderētās ziņas tiks atzīmētas + Tikai man + Visiem + Apturēt pārsūtīšanu + Apturēt faila sūtīšanu + Vai vēlaties apturēt šī faila sūtīšanu? + Apturēt faila saņemšanu + Vai vēlaties apturēt šī faila saņemšanu? + Apturēt + Atcelt failu + Atcelt failu + Vai vēlaties atcelt piekļuvi šim failam? + Atcelt + Pārsūtīt + Lejupielādēt failu + Saraksta izvēlne + Ziņa pārsūtīta + Šī ziņa tika pārsūtīta no citas sarunas. + Dalībnieks neaktīvs + Dalībnieks ir neaktīvs + Rediģēts + Ziņa nosūtīta + Ziņa nosūtīta bez atļaujas + Ziņas nosūtīšana neizdevās + Saņemtā ziņa nav izlasīta + Laipni lūdzam + Laipni lūdzam + Šis teksts ir pieejams iestatījumos + Jūsu sarunas + Rīkjoslas iestatījumi + Kontakta savienojums gaida apstiprinājumu + Dalībnieka kontakts nosūtīt tiešo ziņu + Grupas priekšskatījums ir atvērts pievienošanai + Grupas priekšskatījums, jūs esat aicināts + Grupas priekšskatījums, pievienojieties kā + Grupas priekšskatījums noraidīts + Grupas savienojums gaida apstiprinājumu + Noklikšķiniet, lai uzsāktu jaunu sarunu + Sarunājieties ar izstrādātājiem + Jums nav nevienas sarunas + Ielādēju sarunas… + Nav sarunu, kas atbilst filtram + Sarunu sarakstā nav + Nav neizlasītu sarunu + Nav sarunu + Nav atrastas sarunas + Kontakts, noklikšķiniet, lai savienotos + Atvērts savienojumam + Atvērts, lai izmantotu robotu + Atvērts, lai pieņemtu + Kontaktam jāpieņem + Savienoties ar kontaktu %s? + Meklējiet vai ielīmējiet simplex saiti + Adreses izveides instrukcija + Nav izvēlēta saruna + Izvēlēto sarunu vienības nav izvēlētas + Izvēlēto sarunu vienības izvēlētas %d + Pārsūtīt ziņas + Nav ko pārsūtīt + Video tiks saņemts, kad kontakts pabeigs augšupielādi. + Video tiks saņemts, kad kontakts būs tiešsaistē. + Fails + Liels fails + Kontakts nosūtīja lielu failu + Maksimālais atbalstītais faila izmērs + Gaidām failu + Fails tiks saņemts, kad kontakts pabeigs augšupielādi. + Fails tiks saņemts, kad kontakts būs tiešsaistē. + Fails saglabāts + Fails nav atrasts + Kļūda, saglabājot failu + Attālinātā faila ielāde + Attālinātā faila ielāde. + Faila kļūda + Pagaidu faila kļūda + Atvērt ar lietotni + Balss ziņa + Balss ziņa ar ilgumu + Nosūtīt + Paziņojumi + Atspējot automātisko dzēšanu? + Mainīt automātisko dzēšanu? + Atspējot automātisko dzēšanu + Mainīt automātisko sarunu dzēšanu + Atspējot automātisko dzēšanu + Sarunu laika ierobežojuma opciju apakšdaļa + Skatīt savienojumu + Skatīt atvērt + Skatīt + Skatīt zvanu + Skatīt meklēšanu + Skatīt video + Dzēst kontaktu? + Visas ziņas tiks dzēstas. To nevar atcelt. + Šo darbību nevar atcelt. + Saglabāt sarunu + Tikai dzēst sarunu + Apstiprināt kontakta dzēšanu? + Dzēst un paziņot kontaktam + Dzēst bez paziņojuma + Dzēst kontaktu + Saruna dzēsta + Jūs joprojām varat sūtīt ziņas kontaktam + Kontakts dzēsts + Jūs joprojām varat skatīt sarunu ar kontaktu + Ievadiet kontakta vārdu + Ievadiet čata nosaukumu + Serveris ir savienots + Serveris nav savienots + Servera kļūda + Serveris gaida + Vai vēlaties mainīt saņemšanas adresi? + Mainīt saņemšanas adresi + Pārtraukt saņemšanas adreses maiņu + Vai vēlaties piespiedu sinhronizāciju? + Piespiedu sinhronizācija + Apstiprināt piespiedu sinhronizāciju + Vai vēlaties sinhronizēt savienojumu? + Sinhronizēt savienojumu + Apstiprināt savienojuma sinhronizāciju + Šifrēšanas pārrunāšana notiek + Skatīt drošības kodu + Verificēt drošības kodu + Sūtīt ziņu + Ierakstīt balss ziņu + Vai vēlaties atļaut balss ziņas? + Jums jāatļauj balss ziņas, lai tās sūtītu. + Balss ziņas šajā čatā ir aizliegtas. + Lūdziet kontaktam iespējot balss ziņas + Tikai grupas īpašnieki var iespējot balss ziņas + Sūtīt tiešo ziņu + Izbeidzoša ziņa + Sūtīt izbeidzošu ziņu + Pielāgots laiks + Sūtīt + Tiešā ziņa + Sūtīt tiešo ziņu + Sūtīt + Atcelt tiešo ziņu + Atpakaļ + Atcelt + Apstiprināt + Atjaunot + Labi + Nav detaļu + Pievienot kontaktu + Kopēts + Pievienot kontaktu vai izveidot grupu + Kopīgot vienreizēju saiti + Savienot, izmantojot saiti vai QR + Nolasīt QR kodu + Izveidot grupu + Lai kopīgotu ar savu kontaktu + Savienot, izmantojot saiti vai QR no starpliktuves vai klātienē + Tikai saglabāts dalībnieku ierīcēs + Iespējot kameras piekļuvi + Noklikšķiniet, lai nolasītu + Kamera nav pieejama + Atļauja noraidīta + Augstāk minētais, tad prievārds turpinājums + Pievienot kontaktu, lai izveidotu saiti vai savienotu, izmantojot saiti + Izveidot grupu, lai izveidotu jaunu grupu + Lai savienotu, izmantojot saiti + Ja esat saņēmis simplex ielūguma saiti, varat to atvērt pārlūkā + Datorā nolasīt QR kodu no lietotnes, izmantojot QR koda nolasīšanu + Mobilajā ierīcē noklikšķiniet uz atvērt mobilajā lietotnē, tad noklikšķiniet uz savienot lietotnē + Pieņemt savienojuma pieprasījumu? + Ja izvēlēsieties noraidīt, sūtītājs netiks informēts + Pieņemt kontaktu + Pieņemt kontaktu inkognito + Noraidīt kontaktu + Pieņemt kontaktu pieprasījumu + Noraidīt kontaktu pieprasījumu + Sūtītājs netiks informēts + Dalībnieks ir izdzēsts, nevar pieņemt pieprasījumu + Notīrīt sarunu? + Notīrīt piezīmju mapi? + Notīrīt sarunu + Notīrīt piezīmju mapi + Notīrīt + Notīrīt sarunu + Notīrīt sarunas darbība + Dzēst kontaktu darbība + Dzēst grupu darbība + Atzīmēt kā izlasītu + Atzīmēt kā neizlasītu + Iestatīt kontakta vārdu + Atskaņot sarunu + Atskaņot visas sarunas + Atjaunot sarunu + Atzīmēt kā iecienītu + Noņemt no iecienītajiem + Neizlasītie minējumi + Izveidot sarakstu + Pievienot sarakstam + Mainīt sarakstu + Saglabāt sarakstu + Saraksta nosaukums + Saraksts ar šo nosaukumu jau pastāv + Dzēst sarakstu + Dzēst sarakstu? + Šo darbību nevar atcelt. + Rediģēt sarakstu + Mainīt secību + Jūs esat aicinājis kontaktu + Jūs esat pieņēmis savienojumu + Dzēst gaidošo? + Kontakts, ar kuru jūs dalījāties ar saiti, nevarēs izveidot savienojumu + Savienojums, ko jūs esat pieņēmis, tiks atcelts + Kontakta savienojums gaida + Savienojums gaida, viņiem jābūt tiešsaistē, varat dzēst un mēģināt vēlreiz + Kontakts vēlas izveidot savienojumu ar jums + Profila attēla vietturis + Profila attēls + Aizvērt + Saites priekšskatījums + Atcelt saites priekšskatījumu + Iestatījumi + QR kods + Adrese + Palīdzība + Simplex komanda + SimpleX logo + E-pasts + Vairāk + Rādīt QR kodu + Nederīgs QR kods + Šis QR kods nav saite + Nederīga kontaktu saite + Šī saite nav derīga savienojuma saite + Savienojuma pieprasījums nosūtīts + Jūs tiksiet savienots, kad grupas saimnieka ierīce būs tiešsaistē + Jūs tiksiet savienots, kad jūsu savienojuma pieprasījums tiks pieņemts + Jūs tiksiet savienots, kad jūsu kontaktu ierīce būs tiešsaistē + Ja jūs nevarat tikties klātienē, rādiet QR videozvanā vai caur citu kanālu + Jūsu čata profils tiks nosūtīts jūsu kontaktam + Ja jūs nevarat tikties klātienē, skenējiet QR videozvanā vai lūdziet ielūguma saiti + Kopīgot ielūguma saiti + Ielīmējiet saiti, ko saņēmāt, lai savienotos ar savu kontaktu + Uzzināt vairāk + Uzzināt vairāk par adresi + Savienojiet, tiks kopīgots jauns nejaušs profils + Savienojiet, jūsu profils tiks kopīgots + Skenējiet QR, lai savienotos ar kontaktu + Ja jūs nevarat tikties klātienē + Kopīgot adresi publiski + Kopīgot simplex adresi sociālajos tīklos + Jūs varat kopīgot savu adresi + Jūs nezaudēsiet savus kontaktus, ja izdzēsīsiet adresi + Kopīgot vienreizēju saiti ar draugu + Vienreizēju saiti var izmantot tikai ar vienu kontaktu + Jūs varat iestatīt savienojuma nosaukumu, lai atcerētos + Savienojuma drošība + Simplex adrese un vienreizējās saites ir drošas kopīgošanai + Lai pasargātu no jūsu saites aizvietošanas, salīdziniet kodus + Jūs varat pieņemt vai noraidīt savienojumu + Lasiet vairāk lietotāja rokasgrāmatā ar saiti + Adrese vai vienreizēja saite + Savienoties caur saiti + Savienoties + Ielīmēt + Šī virkne nav savienojuma saite + Jūs varat arī savienoties, noklikšķinot uz saites + Jauna saruna + Jauns + Pievienot kontaktu cilni + Skatīt ielīmēto saiti + Ielīmēt saiti + Vienreizēja saite + Vienreizēja saite īsi + Simplex adrese + Vai arī parādiet šo QR kodu + Pilna saite + Īsa saite + Jauna saruna dalīties profilā + Izvēlēties sarunas profilu + Profila maiņas kļūda + Profila maiņas kļūda + Vai arī skenējiet QR kodu + Tīkls atspējot SOCKS + Tīkls atspējot SOCKS + Tīkls izmantot sīpolu viesus + Tīkls izmantot sīpolu viesus priekšroka + Tīkls izmantot sīpolu viesus nē + Tīkls izmantot sīpolu viesus nepieciešams + Dodiet priekšroku sīpolu viesiem tīklā. + Sīpolu viesi netiks izmantoti tīklā. + Sīpolu viesi ir obligāti tīklā. + Tīkla sesijas režīms transporta izolācija + Tīkla sesijas režīms lietotājs + Tīkla sesijas režīms sesija + Tīkla sesijas režīms serveris + Tīkla sesijas režīms entitāte + Tīkla sesijas režīms lietotājs. + Tīkla sesijas režīms sesija. + Tīkla sesijas režīms serveris. + Tīkla sesijas režīms entitāte. + Atjaunināt tīkla sesijas režīmu? + Atspējot sīpolu viesus, ja nav atbalsta + Socks proxy iestatījumu ierobežojumi + Tīkla smp proxy režīms privātā maršrutēšana + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Atjaunināt tīkla smp proxy režīmu? + Tīkla smp proxy rezerves atļaut samazināšanu + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Atjaunināt tīkla smp proxy rezerves? + Privātā maršrutēšana rādīt + Privātā maršrutēšana skaidrojums + Tīkla smp tīmekļa ports + Tīkla smp tīmekļa porta slēdzis + Tīkla smp tīmekļa porta kājenes + Tīkla smp tīmekļa porta iepriekš iestatītā kājenes + Tīkla smp tīmekļa ports viss + Tīkla smp tīmekļa porta iepriekš iestatījums + Tīkla smp tīmekļa ports izslēgts + Izskata iestatījumi + Pielāgot tēmu + Tēmas krāsas + Lietotnes versija + Lietotnes versijas nosaukums + Lietotnes versijas kods + Pamatversija + Pamat simplexmq versija + Pārbaudīt atjauninājumus + Lietotnes atjauninājumu pārbaude atspējota + Lietotnes atjauninājumu pārbaude stabila + Lietotnes atjauninājumu pārbaude beta + Ir pieejams atjauninājums + Lejupielādēt + Izlaist + Lejupielāde uzsākta + Lejupielāde pabeigta + Atvērt + Instalēt + Veiksmīgi instalēts + Atjauninājums veiksmīgi instalēts. + Lejupielāde atcelta + Atgādināt vēlāk + Paziņojums par atjauninājumu + Saņemiet paziņojumus par pieejamiem atjauninājumiem. + Atspējot paziņojumus par atjauninājumiem + Rādīt izstrādātāja opcijas + Paslēpt izstrādātāja opcijas + Rādīt izstrādātāja opcijas + Kļūdu žurnāli + Izstrādātāja opcijas + Izstrādātāja opcijas + Novecojušas opcijas + Rādīt iekšējās kļūdas + Rādīt lēnus API izsaukumus + Izslēgt? + Izslēgšana + Kļūda saglabājot iestatījumus + Izveidot adresi + Dzēst adresi? + Jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti, atjauninājums nosūtīts. + Kopīgot saiti + Pievienot adresi savai profilam + Izveidot adresi un ļaut cilvēkiem savienoties + Izveidot simplex adresi + Kopīgot ar kontaktiem + Kopīgot adresi ar kontaktiem? + Profila atjauninājums tiks nosūtīts kontaktiem. + Pārtraukt adreses kopīgošanu + Pārtraukt kopīgošanu + Automātiski pieņemt kontaktus + Nosūtīts jūsu kontaktam pēc savienojuma izveides + Sveiciena ziņa + Ievadiet sveiciena ziņu (pēc izvēles) + Saglabāt iestatījumus? + Saglabāt automātiskā pieņemšanas iestatījumus + Dzēst adresi + Aicināt draugus + E-pasta aicinājuma temats + E-pasta aicinājuma saturs + Sociālajiem tīkliem + Vai arī, lai dalītos privāti + Simplex adrese vai vienreizējs saite + Izveidot vienreizēju saiti + Adreses iestatījumi + Uzņēmuma adrese + Pievienojiet savus komandas locekļus sarunām + Pievienot īsu saiti + Dalīties ar profilu, izmantojot saiti + Dalīties ar profilu, izmantojot saiti (teksts) + Dalīties + Uzlabot grupas saiti + Dalīties ar grupas profilu, izmantojot saiti + Dalīties ar grupas profilu, izmantojot saiti (teksts) + Dalīties ar veco adresi + Dalīties ar veco saiti + Turpināt uz nākamo soli + Nekādā gadījumā neveidot adresi + Jūs varat to izveidot vēlāk + Jūs varat padarīt adresi redzamu caur iestatījumiem + Aicināt draugus (īsi) + Rādāmais vārds + Pilns vārds + Īsa apraksts + Biogrāfija ir pārāk liela + Jūsu pašreizējais profils + Jūsu profils tiek glabāts ierīcē un tiek dalīts tikai ar kontaktiem, Simplex to neredz + Rediģēt attēlu + Dzēst attēlu + Saglabāt uzņemšanu? + Vai vēlaties saglabāt preferences? + Saglabāt un paziņot kontaktam + Saglabāt un paziņot kontaktiem + Saglabāt un paziņot grupas dalībniekiem + Iziet bez saglabāšanas + Paslēpt profilu + Parole, lai parādītu + Saglabāt profila paroli + Paslēptā profila parole + Apstiprināt paroli + Lai atklātu profilu, ievadiet paroli + Kļūda, saglabājot lietotāja paroli + Jūs kontrolējat savu sarunu + Ziņojumu un lietotņu platforma, kas aizsargā jūsu privātumu un drošību + Mēs nesaglabājam kontaktus vai ziņas serveros + Izveidot profilu + Jūsu profils tiek saglabāts jūsu ierīcē + Profils tiek koplietots tikai ar jūsu kontaktiem + Parādāmā vārda laukā nedrīkst būt atstarpes + Parādāmais vārds + Īss + Izveidot profilu + Izveidot citu profilu + Izveidot adresi + Nederīgs vārds + Labot vārdu uz + Par SimpleX + Kā izmantot Markdown + Jūs varat izmantot Markdown, lai formatētu ziņas + Trekns teksts + Zvana statuss beidzies + Zvana statuss kļūda + Zvana stāvoklis sākas + Zvana stāvoklis gaida atbildi + Zvana stāvoklis gaida apstiprinājumu + Zvana stāvoklis saņēmis atbildi + Zvana stāvoklis saņēmis apstiprinājumu + Zvana stāvoklis savienojas + Zvana stāvoklis savienots + Zvana stāvoklis beidzies + Nevar atvērt pārlūkprogrammu + Nevar atvērt pārlūkprogrammu + Nepieciešamas atļaujas + Atļaujas audio ierakstīšanai + Atļaujas kamerai + Atļaujas kamerai un audio ierakstīšanai + Piešķirt atļaujas + Piešķirt atļaujas iestatījumos + Atrast iestatījumos un piešķirt atļaujas + Atvērt iestatījumus + Audio ierīce - austiņas + Audio ierīce - skaļrunis + Audio ierīce - vadu austiņas + Audio ierīce - Bluetooth + Kļūda, inicializējot tīmekļa skatu + WebView nav atbalstīts šajā ierīces arhitektūrā. + Nākamā paaudze privātajai ziņošanai + Privātums pārdefinēts + Pirmā platforma bez lietotāju ID + Imūna pret surogātpastu un ļaunprātīgu izmantošanu + Cilvēki var savienoties tikai caur saites, ko jūs kopīgojat + Decentralizēts + Atvērtā koda protokols un kods, ko ikviens var palaist serveros + Izveidot savu profilu + Izveidot privātu savienojumu + Migrēt no citas ierīces + Kā tas darbojas + Kā darbojas SimpleX + Lai aizsargātu privātumu, SimpleX izmanto ID rindām + Tikai klientu ierīces glabā kontaktu grupas un e2e šifrētas ziņas + Visas ziņas un faili ir e2e šifrēti + Lasiet vairāk GitHub krātuvē. + Izmantot čatu + Ievada paziņojumu režīms + Ievada paziņojumu režīma apakšvirsraksts + Ievada paziņojumu režīms izslēgts + Ievada paziņojumu režīms periodisks + Ievada paziņojumu režīms pakalpojums + Ievada paziņojumu režīms izslēgts + Ievada paziņojumu režīms izslēgts, īss apraksts + Ievada paziņojumu režīms periodisks + Ievada paziņojumu režīms periodisks apraksts īsi + Ievada paziņojumu režīms pakalpojums + Ievada paziņojumu režīms pakalpojuma apraksts īsi + Ievada paziņojumu režīms akumulators + Iestatīt datu bāzes paroli + Jūs varat to mainīt vēlāk + Izmantot nejaušu paroli + Ievada nosacījumi privātās sarunas nav pieejamas + Ievada nosacījumi, izmantojot jūs piekrītat + Ievada nosacījumi privātuma politika un lietošanas noteikumi + Ievada nosacījumi pieņemt + Ievada nosacījumi konfigurēt servera operatorus + Ievada izvēlēties servera operatorus + Ievada tīkla operatori + Ievada tīkla operatori simplex flux vienošanās + Ievada tīkla operatori lietotne izmantos citus operatorus + Ievada tīkla operatori nevar redzēt, kas ar ko runā + Ievada tīkla operatori lietotne izmantos maršrutēšanai + Ievada tīkls par operatoriem + Ievada izvēlēties tīkla operatorus, ko izmantot + Kā tas palīdz privātumam + Ievada tīkla operatori konfigurēt caur iestatījumiem + Ievada tīkla operatori nosacījumi tiks pieņemti + Ievada tīkla operatori nosacījumi, ko jūs varat konfigurēt + Ievada tīkla operatori pārskatīt vēlāk + Ievada tīkla operatori atjaunināt + Ievada tīkla operatori turpināt + Ienākošais video zvans + Ienākošais audio zvans + Čeki + Čeku apraksts 1 + Čeku kontakti + Čeku kontakti iespējot + Čeku kontakti atspējot + Čeku kontakti pārsniegšana iespējota + Čeku kontakti pārsniegšana atspējota + Čeku kontakti iespējot saglabāt pārsniegumus + Čeku kontakti atspējot saglabāt pārsniegumus + Čeku kontakti iespējot visiem + Rēķinu kontaktu atslēgšana visiem + Rēķinu grupas + Rēķinu grupu aktivizēšana + Rēķinu grupu atslēgšana + Rēķinu grupu pārsniegšana aktivizēta + Rēķinu grupu pārsniegšana atslēgta + Rēķinu grupu aktivizēšana ar saglabātām pārsniegšanām + Rēķinu grupu atslēgšana ar saglabātām pārsniegšanām + Rēķinu grupu aktivizēšana visiem + Rēķinu grupu atslēgšana visiem + Privātuma mediju izplūšanas rādiuss + Privātuma mediju izplūšanas rādiuss izslēgts + Privātuma mediju izplūšanas rādiuss mīksts + Privātuma mediju izplūšanas rādiuss vidējs + Privātuma mediju izplūšanas rādiuss spēcīgs + Privātuma čata saraksta atvērtie saites + Privātuma čata saraksta atvērtie saites jā + Privātuma čata saraksta atvērtie saites nē + Privātuma čata saraksta atvērtie saites jautāt + Vai vēlaties atvērt tīmekļa saiti? + Privātuma čata saraksta atvērt tīmekļa saiti + Privātuma čata saraksta atvērt pilnu tīmekļa saiti + Privātuma čata saraksta atvērt tīru tīmekļa saiti + Iestatījumi jūs + Iestatījumi + Iestatījumi čata datubāze + Iestatījumi palīdzība + Iestatījumi atbalsts + Iestatījumi lietotne + Iestatījumi ierīce + Iestatījumi čati + Iestatījumi faili + Iestatījumi piegādes rēķini + Iestatījumi kontaktu pieprasījumi no grupām + Iestatījumi restartēt lietotni + Iestatījumi izslēgt + Iestatījumi izstrādātāja rīki + Iestatījumi eksperimentālās funkcijas + Iestatījumi zeķes + Iestatījumi + Tēmas + Profila attēli + Ziņu forma + Ziņu formas stūris + Ziņu formas aste + Sarunu tēmas iestatījumi + Lietotāja tēmas iestatījumi + Sarunu krāsu iestatījumi + Ziņu iestatījumi + Privāto ziņu maršrutēšanas iestatījumi + Zvanīšanas iestatījumi + Tīkla savienojuma iestatījumi + Incognito iestatījumi + Eksperimentālie iestatījumi + Izmantot no darbvirsmas + Jūsu sarunu datubāze + Palaist sarunu + Attālie hosti + Saruna notiek + Saruna ir apstājusies + Sarunu datubāze + Datubāzes parolfrāze + Eksportēt datubāzi + Importēt datubāzi + Jauns datubāzes arhīvs + Vecs datubāzes arhīvs + Atvērt datubāzes mapi + Dzēst datubāzi + Kļūda, sākot sarunu + Apstāt sarunu? + Apstāt sarunu, lai eksportētu, importētu vai dzēstu sarunu datubāzi + Apstiprināt sarunas apstāšanu + Iestatīt paroli eksportēšanai + Iestatiet paroli, lai eksportētu + Kļūda, apstādot sarunu + Kļūda, eksportējot sarunu datubāzi + Importēt datubāzi? + Jūsu pašreizējā sarunu datubāze tiks dzēsta un aizvietota ar importēto + Apstiprināt datubāzes importēšanu + Kļūda, dzēšot datubāzi + Kļūda, importējot datu bāzi + Sarunu datu bāze importēta + Restartējiet lietotni, lai izmantotu importēto sarunu datu bāzi + Notikušas nenozīmīgas kļūdas importēšanas laikā + Vai vēlaties dzēst sarunu? + Sarunu dzēšanas darbību nevar atcelt + Sarunu datu bāze dzēsta + Restartējiet lietotni, lai izveidotu jaunu sarunu profilu + Jums jāizmanto jaunākā datu bāzes versija + Faili un mediji + Dzēst failus un medijus visiem lietotājiem + Dzēst visus failus un medijus + Vai vēlaties dzēst failus un medijus? + Dzēst failus un medijus + Nav saņemtu lietotnes failu + Kopējais failu skaits un izmērs + Sarunu vienības derīguma termiņš nav + Sarunu vienības derīguma termiņš sekundēs + Sarunu vienības derīguma termiņš pēc noklusējuma + Ziņas + Ziņas + Dzēst ziņas pēc + Vai vēlaties iespējot automātisko dzēšanu? + Iespējot automātisko dzēšanu + Dzēst ziņas + Kļūda, mainot dzēšanu + Sarunu datu bāze eksportēta + Sarunu datu bāze eksportēta un saglabāta + Sarunu datu bāze eksportēta un migrēta + Sarunu datu bāze eksportēta, ne visi faili + Sarunu datu bāze eksportēta, turpināt + Kļūda, saglabājot datu bāzi + Saglabāt paroli atslēgu glabātājā + Saglabāt paroli iestatījumos + Datu bāze ir šifrēta + Kļūda, šifrējot datu bāzi + Noņemt paroli no atslēgu glabātāja + Noņemt paroli no iestatījumiem + Paziņojumi tiks slēpti + Noņemt paroli + Šifrēt datubāzi + Atjaunot datubāzi + Pašreizējā frāze + Jaunā frāze + Apstiprināt jauno frāzi + Atjaunot datubāzes frāzi + Iestatīt datubāzes frāzi + Iestatīt frāzi + Ievadiet pareizo pašreizējo frāzi + Datubāze nav šifrēta + Atslēgu glabātuve tiek droši glabāta + Iestatījumi tiek glabāti parastā tekstā + Šifrēts ar nejaušu frāzi + Nav iespējams atgūt frāzi + Atslēgu glabātuve ļauj saņemt ntfs + Frāze tiks saglabāta iestatījumos + Jums katru reizi jāievada frāze + Šifrēt datubāzi? + Mainīt datubāzes frāzi? + Datubāze tiks šifrēta + Datubāze tiks šifrēta un frāze tiks saglabāta + Datubāze tiks šifrēta un frāze tiks saglabāta iestatījumos + Datubāzes šifrēšana tiks atjaunināta + Datubāzes šifrēšana tiks atjaunināta iestatījumos + Datubāzes frāze tiks atjaunināta + Droši glabāt frāzi + Droši glabāt frāzi bez atgūšanas + Nepareiza frāze + Kļūda, lasot frāzi + Šifrēta datubāze + Datubāzes kļūda + Atslēgu glabātuves kļūda + Frāze ir atšķirīga + Fails ar ceļu + Nepieciešama datubāzes frāze + Kļūda ar + Nav iespējams piekļūt atslēgu glabātuvei + Nezināma datubāzes kļūda ar + Nepareiza frāze + Ievadiet pareizo frāzi + Nezināma kļūda + Ievadiet atslēgvārdu + Saglabāt atslēgvārdu un atvērt sarunu + Atvērt sarunu + Datu bāzes dublējumu var atjaunot. + Atjaunot datu bāzi + Atjaunot datu bāzi + Vai tiešām vēlaties atjaunot datu bāzi? + Apstiprināt datu bāzes atjaunošanu + Datu bāzes atjaunošanas kļūda + Atslēgvārds nav atrasts + Atslēgvārdu nevar nolasīt + Atslēgvārdu nevar nolasīt, lūdzu, ievadiet to manuāli + Datu bāzes jaunināšana + Datu bāzes samazināšana + Nesaderīga datu bāzes versija + Apstiprināt datu bāzes jauninājumus + Vienas rokas saskarne + Sarunu apakšējā josla + Vienas rokas saskarnes karte + Vienas rokas saskarnes maiņas instrukcija + Terminālis vienmēr redzams + Sarunu saraksts vienmēr redzams + Nederīga migrācijas apstiprināšana + Jaunināt un atvērt sarunu + Samazināt un atvērt sarunu + Mtr kļūda: nav lejupvērstas migrācijas + Mtr kļūda: atšķirīgs + Datu bāzes migrācijas + Datu bāzes samazināšana + Saruna ir apturēta + Jūs varat uzsākt sarunu, izmantojot iestatījumus vai restartējot lietotni + Sākt sarunu? + Saruna ir apturēta, jums jāveic datu bāzes pārsūtīšana + Grupas ielūguma vienums + Pievienoties grupai? + Jūs esat aicināts pievienoties grupai, lai sazinātos ar grupas dalībniekiem + Pievienoties grupai + Pievienoties grupai incognito + Pievienojos grupai + Jūs esat pieņēmuši grupas ielūgumu, savienojoties ar grupas dalībnieku, kurš jūs aicināja. + Atstāt grupu + Vai vēlaties atstāt grupu? + Vai vēlaties atstāt čatu? + Jūs pārtrauksiet saņemt ziņas no šīs grupas, bet sarunu vēsture tiks saglabāta. + Jūs pārtrauksiet saņemt ziņas no šī čata, bet sarunu vēsture tiks saglabāta. + Pievienot dalībniekus + Grupa ir neaktīva + Grupas ielūgums ir beidzies + Grupas ielūgums ir beidzies + Nav grupas + Nav grupas + Nav iespējams aicināt kontaktus + Nav iespējams aicināt kontaktus, jo nav pieejamu kontaktu. + Jūs nosūtījāt grupas ielūgumu + Jūs esat aicināts uz grupu + Grupas ielūgums - pieskarieties, lai pievienotos + Grupas ielūgums - pieskarieties, lai pievienotos incognito + Jūs pievienojāties šai grupai + Jūs noraidījāt grupas ielūgumu + Grupas ielūgums ir beidzies + Saņemts tiešais notikums: kontakts izdzēsts + Saņemts tiešais notikums: grupas ielūguma saite saņemta + Saņemts grupas notikums: dalībnieks pievienots + Saņemts grupas notikums: dalībnieks savienots + Saņemts grupas notikums: dalībnieks pieņēmis + Saņemts grupas notikums: lietotājs pieņēmis + Saņemts grupas notikums: dalībnieks atstājis + Saņemts grupas notikums: dalībnieka loma mainīta + Saņemts grupas notikums: dalībnieks bloķēts + Saņemts grupas notikums: dalībnieks atbloķēts + Saņemts grupas notikums: jūsu loma mainīta + Saņemts grupas notikums: dalībnieks izdzēsts + Saņemts grupas notikums: lietotājs izdzēsts + Saņemts grupas notikums: grupa izdzēsta + Saņemts grupas notikums: grupas profils atjaunināts + Saņemts grupas notikums: aicināts caur jūsu grupas saiti + Saņemts grupas notikums: dalībnieks izveidojis kontaktu + Saņemts grupas notikums: jauns dalībnieks gaida apstiprinājumu + Nosūtīts grupas notikums: dalībnieka loma mainīta + Grupas notikums mainījis lomu jums + Grupas notikums: dalībnieks bloķēts + Grupas notikums: dalībnieks atbloķēts + Grupas notikums: dalībnieks dzēsts + Grupas notikums: lietotājs pametis + Grupas notikums: grupas profils atjaunināts + Grupas notikums: dalībnieks pieņemts + Grupas notikums: lietotājs gaida pārskatīšanu + Profila atjauninājuma notikums: attēls noņemts + Profila atjauninājuma notikums: jauns attēls iestatīts + Profila atjauninājuma notikums: adrese noņemta + Profila atjauninājuma notikums: jauna adrese iestatīta + Profila atjauninājuma notikums: profils atjaunināts + Profila atjauninājuma notikums: dalībnieka vārds mainīts + Saņemšanas savienojuma notikums: maiņas rinda fāze pabeigta + Saņemšanas savienojuma notikums: maiņas rinda fāze mainās + Sūtīšanas savienojuma notikums: maiņas rinda fāze pabeigta dalībniekam + Sūtīšanas savienojuma notikums: maiņas rinda fāze mainās dalībniekam + Sūtīt kvītis + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana ir atspējota + Pievienot dalībniekus + Atbalsta čats + Konsolei + Rindas vietējais nosaukums + Rindas datu bāzes ID + Rindas debug piegāde + Rindas atjaunināts + Rindas ziņas statuss + Rindas faila statuss + Rindas nosūtīts + Rindas izveidots + Rindas saņemts + Rindas dzēsts + Rindas moderēts + Rindas pazūd + Kopīgojiet teksta datu bāzes ID + Dalībnieks tiks noņemts no grupas, šo darbību nevar atcelt + Dalībnieki tiks noņemti no grupas, šo darbību nevar atcelt + Dalībnieks tiks noņemts no sarunas, šo darbību nevar atcelt. + Dalībnieki tiks noņemti no sarunas, šo darbību nevar atcelt. + Apstiprināt dalībnieka noņemšanu + Noņemt dalībnieku + Bloķēt dalībnieku? + Bloķēt dalībnieku + Apstiprināt dalībnieka bloķēšanu + Bloķēt visiem? + Bloķēt dalībniekus visiem? + Bloķēt visiem + Bloķēt dalībnieku + Bloķēt dalībniekus + Atbloķēt dalībnieku? + Atbloķēt dalībnieku + Apstiprināt dalībnieka atbloķēšanu + Atbloķēt visiem? + Atbloķēt dalībniekus visiem? + Atbloķēt visiem + Atbloķēt dalībnieku + Atbloķēt dalībniekus + Dalībnieks ir bloķēts no administratora puses + Dalībnieks ir bloķēts + Dalībnieks ir atspējots + Dalībnieks ir neaktīvs + Dalībnieks + Loma grupā + Mainīt lomu + Mainīt + Pārslēgt + Mainīt dalībnieka lomu? + Dalībnieka loma tiks mainīta ar paziņojumu + Dalībnieka loma tiks mainīta ar paziņojumu sarunā + Dalībnieka loma tiks mainīta ar ielūgumu + Savienot caur dalībnieka adresi + Savienot caur dalībnieka adresi + Kļūda, noņemot dalībnieku + Kļūda, mainot lomu + Kļūda, bloķējot dalībnieku visiem + Rinda grupā + Rinda sarunā + Savienojuma rinda + Tiešais savienojuma līmenis + Netiešais savienojuma līmenis + Rinda + Nav rindas + Rindas serveris + Nevar zvanīt kontaktam + Nevar zvanīt kontaktam, lūdzu, gaidiet savienojumu. + Nevar zvanīt kontaktam, jo tas ir dzēsts. + Atļaut zvanus? + Jums jāatļauj zvanīt. + Zvanīšana aizliegta + Zvanīšana ir aizliegta, lūdzu, atļaujiet zvanus. + Nevar zvanīt dalībniekam + Nevar zvanīt dalībniekam, lūdzu, nosūtiet ziņu. + Nevar nosūtīt dalībniekam + Savienojums nav gatavs + Laipni lūdzam ziņa + Saglabāt laipni lūdzam? + Laipni lūdzam ziņa ir pārāk gara + Saglabāt un atjaunināt grupas profilu + Grupas laipni lūdzam priekšskatījums + Ievadiet laipni lūdzam ziņu + Pārāk liels + Savienojuma statistika serveriem + Saņemšana caur + Sūtīšana caur + Tīkla statuss + Mainīt saņemšanas adresi + Labot savienojumu + Labot savienojumu? + Apstiprināt savienojuma labošanu + Savienojuma labošana nav atbalstīta no kontakta puses + Savienojuma labošana nav atbalstīta no grupas dalībnieka puses + Pārrunāt šifrēšanu + Izveidot slepenu grupu + Grupa ir decentralizēta + Grupas redzamā nosaukuma lauks + Grupas pilnā nosaukuma lauks + Grupas īsā apraksta lauks + Grupa ir pārāk liela + Grupas galvenais profils nosūtīts + Sarunas galvenais profils nosūtīts + Izveidot grupu + Grupas profils tiek glabāts dalībnieku ierīcēs. + Saglabāt grupas profilu + Kļūda, saglabājot grupas profilu + Tīkla iepriekš iestatītie serveri + Operatora pārskata nosacījumi + Operatora nosacījumi pieņemti + Operatora nosacījumi pieņemti aktivizētiem operatoriem + Jūsu serveri + Operatoru nosacījumi pieņemti + Operatoru nosacījumi tiks pieņemti + Operators + Operatora serveri + Operators + Operatora mājaslapa + Operatora nosacījumi pieņemti + Operatora nosacījumi tiks pieņemti + Operatora izmantošanas slēdzis + Izmantot operatora x serverus + Operatora nosacījumi neizdevās ielādēt + Operatora nosacījumi pieņemti dažiem + Operatora tie paši nosacījumi tiks piemēroti + Operatora tie paši nosacījumi tiks piemēroti operatoriem + Operatora nosacījumi tiks piemēroti + Operatora nosacījumi tiks pieņemti dažiem + Operatoru nosacījumi arī tiks piemēroti + Skatīt nosacījumus + Pieņemt nosacījumus + Operatora lietošanas nosacījumi + Operatora atjaunotie nosacījumi + Operatora, lai izmantotu, pieņemiet nosacījumus + Operatora izmantošana ziņām + Operatora izmantošana ziņu saņemšanai + Operatora izmantošana ziņu privātai maršrutēšanai + Operatora pievienotie serveri + Operatora izmantošana failiem + Operatora izmantošana nosūtīšanai + Xftp serveri uz lietotāju + Operatora pievienotie xftp serveri + Operatora atvērtie nosacījumi + Operatora atvērtās izmaiņas + Kļūda servera atjaunināšanā + Kļūda servera protokola maiņā + Kļūda servera operatora maiņā + Operatora serveris + Serveris pievienots operatora nosaukumam + Kļūda servera pievienošanā + Tīkla opcija TCP savienojums + Tīkla opcijas atiestatītas uz noklusējumu + Tīkla opcija sekundes + Tīkla opcija TCP savienojuma laika limits + Tīkla opcija TCP savienojuma laika limits fons + Tīkla opcija protokola laika limits + Tīkla opcija protokola laika limits fons + Tīkla opcija protokola laika limits uz kb + Tīkla opcija RCV vienlaicība + Tīkla opcija ping intervāls + Tīkla opcija ping skaits + Tīkla opcija iespējot TCP keep alive + Tīkla opcijas saglabāt + Tīkla opcijas saglabāt un atkārtoti savienot + Atjaunināt tīkla iestatījumus? + Iestatījumu atjaunināšana atkārtoti savienos klientu ar visiem serveriem + Atjaunināt iestatījumus? + Pievienot lietotājus + Dzēst lietotājus? + Lietotāji dzēš visas sarunas + Lietotāji dzēš profilu priekš + Lietotāji dzēš ar savienojumiem + Lietotāji dzēš tikai datus + Paslēpt lietotāju + Atklāt lietotāju + Noklusināt lietotāju + Atcelt lietotāja noklusināšanu + Ievadiet paroli, lai parādītu + Noklikšķiniet, lai aktivizētu profilu + Padarīt profilu privātu + Jūs varat paslēpt vai izslēgt lietotāja profilu + Nerādīt atkal + Izslēgts, kad neaktīvs + Jūs joprojām saņemsiet zvanus un paziņojumus + Dzēst profilu + Dzēst sarunas profilu + Atvērt profilu + Atvērt sarunas profilu + Profila parole + Incognito + Incognito nejaušs profils + Incognito aizsargā + Incognito ļauj + Incognito kopīgo + Krāsu režīms: sistēmas + Krāsu režīms: gaišs + Krāsu režīms: tumšs + Tēmas režīms: sistēmas + Tikai jūs varat sūtīt balsi + Tikai jūsu kontakts var sūtīt balsi + Balsi aizliegts šajā sarunā + Gan jūs, gan jūsu kontakts var sūtīt failus + Tikai jūs varat sūtīt failus + Tikai jūsu kontakts var sūtīt failus + Faili aizliegti šajā sarunā + Gan jūs, gan jūsu kontakts var pievienot reakcijas + Tikai jūs varat pievienot reakcijas + Tikai jūsu kontakts var pievienot reakcijas + Reakcijas aizliegtas šajā sarunā + Gan jūs, gan jūsu kontakts var veikt zvanus + Tikai jūs varat veikt zvanus + Tikai jūsu kontakts var veikt zvanus + Zvanus aizliegts ar šo kontaktu + Atļaut sūtīt iznīkstošas ziņas + Aizliegt sūtīt iznīkstošas ziņas + Atļaut tiešās ziņas + Aizliegt tiešās ziņas + Atļaut dzēst ziņas + Atspējot neseno vēstures sūtīšanu + Iespējot dalībnieku ziņojumu sūtīšanu + Aizliegt locekļu ziņojumu sūtīšanu + Grupas locekļi var sūtīt iznīkstošas ziņas + Iznīkstošas ziņas ir aizliegtas + Grupas locekļi var sūtīt tiešās ziņas + Tiešās ziņas ir aizliegtas + Tiešās ziņas grupā ir aizliegtas + Tiešās ziņas čatā ir aizliegtas + Grupas locekļi var dzēst + Dzēšana čatā ir aizliegta + Grupas locekļi var sūtīt balss ziņas + Balss ziņas ir aizliegtas + Grupas locekļi var pievienot reakcijas + Reakcijas ir aizliegtas + Grupas locekļi var sūtīt failus + Faili grupā ir aizliegti + Grupas locekļi var sūtīt simplex saites + Simplex saites grupā ir aizliegtas + Jauniem locekļiem tiek nosūtīta nesenā vēsture + Jauniem locekļiem netiek nosūtīta nesenā vēsture + Grupas locekļi var sūtīt ziņojumus + Dalībnieku ziņojumi ir aizliegti + Dzēst pēc + sekundes + s + minūtes + mēnesis + mēneši + m + mth + stunda + stundas + h + diena + dienas + d + nedēļa + nedēļas + w + Piedāvātā funkcija + Piedāvātā funkcija ar parametru + Funkcija atcelta + Funkcijas lomas visiem dalībniekiem + Funkcijas lomas moderatoriem + Funkcijas lomas administratoriem + Funkcijas lomas īpašniekiem + Funkcija iespējota + Dalībnieku uzņemšana + Uzņemšanas posma pārskats + Pārskats par uzņemšanas posmu. + Dalībnieku kritēriji izslēgti + Dalībnieku kritēriji visi + Dalībnieku atbalsts + Nav atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Atbalsta saruna + Noraidīt gaidošo dalībnieku + Noraidīt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Vai pieņemt gaidošo dalībnieku? + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā dalībniekam + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā novērotājam + Kas jauns + Jaunumi šajā versijā + Lasiet vairāk par jaunumiem + Drošības novērtējums + Drošības novērtējums. + Grupu saites + Grupu saites. + Automātiski pieņemt kontaktu pieprasījumus + Automātiski pieņemt kontaktu pieprasījumus. + Balss ziņas + Balss ziņas. + Neatgriezeniska dzēšana + Neatgriezeniska ziņu dzēšana. + Uzlabota servera konfigurācija + Uzlabota servera konfigurācija. + Uzlabota privātums un drošība + Uzlabota privātums un drošība. + Izbeigušās ziņas + Izbeigušās ziņas, kas pazūd pēc noteikta laika. + Tiešraides ziņas + Ziņas, kas tiek nosūtītas un saņemtas reālajā laikā. + Pārbaudīt savienojuma drošību + Pārbaudiet, vai jūsu savienojums ir drošs. + Franču saskarne + Izvēlieties franču valodu kā saskarnes valodu. + Vairāki čata profili + Izveidojiet un pārvaldiet vairākus čata profilus. + Melnraksts + Saglabājiet ziņu kā melnrakstu, lai to vēlāk pabeigtu. + Transporta izolācija + Izolējiet transporta slāni, lai uzlabotu drošību. + Privāti failu nosaukumi + Izmantojiet privātus failu nosaukumus, lai aizsargātu jūsu datus. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Itāļu saskarne + Izvēlieties itāļu valodu kā saskarnes valodu. + Slēptie čata profili + Pārvaldiet slēptos čata profilus, kas nav redzami citiem. + Audio un video zvanīšana + Veiciet audio un video zvanus ar citiem lietotājiem. + Grupas moderēšana + Moderējiet grupas sarunas un saturu. + Grupas sveiciena ziņa + Sveiciena ziņa, kas tiek nosūtīta jaunajiem grupas dalībniekiem. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Ķīniešu un spāņu saskarne + Izvēlieties ķīniešu vai spāņu valodu kā saskarnes valodu. + Lielu failu atbalsts + Atbalstiet lielu failu nosūtīšanu un saņemšanu. + Lietotnes piekļuves kods + Iestatiet piekļuves kodu, lai aizsargātu lietotni. + Poļu saskarne + Izvēlieties poļu valodu kā saskarnes valodu. + Ziņu reakcijas + Izteikiet savas reakcijas uz ziņām. + Pašiznīcinošs piekļuves kods + Piekļuves kods, kas nodrošina pašiznīcināšanos. + Pielāgotas tēmas + Izvēlieties un pielāgojiet tēmas pēc savas gaumes. + Uzlabotas ziņas + Saņemiet labākas un skaidrākas ziņas. + Japāņu-portugāļu saskarne + Jaunumi, pateicoties lietotāju ieguldījumam Weblate. + Ziņu piegādes apstiprinājumi + Saņemiet apstiprinājumus par ziņu piegādi. + Iemīļoto filtrs + Filtrējiet savas iecienītākās ziņas. + Šifrēšanas labojums + Uzlabojiet šifrēšanas drošību. + Izzust viena ziņa + Ziņa izzudīs pēc noteikta laika. + Vairāk iespēju + Atklājiet vēl vairāk funkciju. + Jauna darbvirsmas lietotne + Izmantojiet jauno un uzlaboto darbvirsmas lietotni. + Šifrēt vietējās failus + Aizsargājiet savus failus ar šifrēšanu. + Atklājiet grupas + Pievienojieties interesantām grupām. + Vienkāršāks inkognito režīms + Izmantojiet inkognito režīmu vieglāk. + Jaunas saskarnes valodas + Saistīt mobilo un darbvirsmas lietotni + Savienojiet mobilo un darbvirsmas lietotni. + Uzlabotas grupas + Izveidojiet un pārvaldiet grupas efektīvāk. + Inkognito grupas + Izveidojiet grupas, kas ir privātas un anonīmas. + Bloķēt grupas dalībniekus + Bloķējiet nevēlamus grupas dalībniekus. + Atklājiet vēl vairāk iespēju. + Privātas piezīmes + Saglabājiet savas piezīmes drošībā. + Vienkāršāka savienojuma saskarne + Izmantojiet savienojuma saskarni vieglāk. + Pievienoties grupas sarunai + Pievienojieties grupas sarunai. + Piegāde + Ziņu piegāde. + Jaunas saskarnes valodas + Kvantumam izturīga šifrēšana + Kvantumam izturīga šifrēšana. + Lietotnes datu migrācija + Lietotnes datu migrācija. + Attēls attēlā zvani + Attēls attēlā zvani. + Drošākas grupas + Drošākas grupas. + Kvantumam izturīga šifrēšana. + Pārsūtīt + Pārsūtīt. + Zvana skaņas + Zvana skaņas. + Profila attēlu forma + Profila attēlu forma. + Tīkls + Tīkls. + Jaunas saskarnes valodas + Privāta maršrutēšana + Privāta maršrutēšana. + Sarunu tēmas + Sarunu tēmas. + Droši faili + Droši faili. + Piegāde + Ziņu piegāde. + Persiešu saskarne + Jauna sarunu pieredze + Jaunas mediju iespējas + Privāta maršrutēšana. + Jūsu kontakti. + Pieejamā sarunu rīkjosla + Pieejamā sarunu rīkjosla. + Savienojieties ātrāk. + Dzēst daudz ziņu. + Sarunu saraksta multivide + Privātuma izplūšana + Palielināt fonta izmēru + Atjaunināt lietotni + Atjauniniet lietotni, lai iegūtu jaunākās funkcijas un uzlabojumus. + Savienojuma serveri + Pārbaudiet savienojuma serveru statusu. + Labāka drošība + Uzlabota drošība jūsu datiem. + Labāki zvani + Uzlabota zvanu kvalitāte. + Labāka lietotāja pieredze + Mainiet sarunu profilu. + Pielāgojamas ziņas + Ziņu datumi + Pārsūtīt vairākas ziņas + Dzēst vairākas ziņas + Tīkla decentralizācija + Decentralizējiet tīklu, lai uzlabotu drošību. + Tīkla decentralizācija, iespējot plūsmu + Tīkla decentralizācija, iespējot plūsmu iemesls + Uzlabota sarunu navigācija + Vieglāka navigācija sarunās. + Uzņēmumu sarunas + Sarunas uzņēmumiem. + Atsauces + Atsauces uz jums sarunās. + Ziņojumi + Attālinātā hosta kļūda: slikts stāvoklis + Attālinātā hosta kļūda: slikta versija + Attālinātā hosta kļūda: atslēgts + Attālinātā kontrole: neaktīva + Attālinātā kontrole: slikts stāvoklis + Attālinātā kontrole: aizņemta + Attālinātā kontrole: laika ierobežojums + Attālinātā kontrole: atslēgta + Attālinātā kontrole: slikta ielūgums + Attālinātā kontrole: slikta versija + Izstrādē + Šī funkcija ir izstrādē. + Savienojiet plānu, lai savienotos ar sevi + Šis ir jūsu personīgais vienreizējais saite + Jūs jau savienojaties ar %1$s + Jūs jau savienojaties + Jūs jau savienojaties, izmantojot šo vienreizējo saiti + Šis ir jūsu personīgais simplex adrese + Atkārtot savienojuma pieprasījumu + Jūs jau esat pieprasījis savienojumu, izmantojot šo adresi + Pievienojieties savai grupai + Šis ir jūsu saite grupai %1$s + Atkārtot pievienošanās pieprasījumu + Grupa jau pastāv + Čats jau pastāv + Jūs jau pievienojaties grupai %1$s + Jūs jau pievienojaties grupai + Jūs jau pievienojaties grupai, izmantojot šo saiti + Jūs jau esat grupā %1$s + Jūs jau esat savienots ar %1$s + Savienojiet, izmantojot saiti + Aģenta kritiska kļūda + Notikusi kritiska kļūda aģentā. + Aģenta iekšēja kļūda + Notikusi iekšēja kļūda aģentā. + Restartēt čatu + Migrēt uz ierīci + Vai nu ielīmējiet arhīva saiti + Ielīmējiet arhīva saiti + Nederīga faila saite + Čata arhīvs + Migrē uz ierīci + Migrē uz ierīci, datu bāzes inicializācija + Migrē uz ierīci, lejupielādējot detaļas + Migrē uz ierīci, lejupielādējot arhīvu + Migrē uz ierīci, lejupielādēti %d baiti + Migrē uz ierīci, lejupielāde neizdevās + Atkārtot lejupielādi + Mēģiniet vēlreiz + Migrē uz ierīci, importējot arhīvu + Migrēt no ierīces + Migrēt no ierīces uz citu ierīci + Kļūda, migrējot no ierīces, saglabājot iestatījumus + Migrēt no ierīces, eksportētā faila nav + Kļūda, migrējot no ierīces, eksportējot arhīvu + Migrēt no ierīces, datu bāzes inicializācija + Kļūda, migrējot no ierīces, augšupielādējot arhīvu + Kļūda, migrējot no ierīces, dzēšot datu bāzi + Migrēt no ierīces, apturot sarunu + Migrēt no ierīces, sarunai jābūt apturētai + Migrēt no ierīces, arhivēt un augšupielādēt + Migrēt no ierīces, apstiprināt augšupielādi + Migrēt no ierīces, visi dati tiks augšupielādēti + Migrēt no ierīces, arhivējot datu bāzi + Migrēt no ierīces, augšupielādētie biti + Migrēt no ierīces, augšupielādējot arhīvu + Migrēt no ierīces, augšupielāde neizdevās + Migrēt no ierīces, atkārtot augšupielādi + Migrēt no ierīces, mēģiniet vēlreiz + Migrēt no ierīces, veidojot arhīva saiti + Migrēt no ierīces, atcelt migrāciju + Migrēt no ierīces, pabeigt migrāciju + Migrēt no ierīces, vai dzēst arhīvu? + Migrēt no ierīces, augšupielādētais arhīvs tiks dzēsts + Migrēt no ierīces, izvēlieties migrēt no citas ierīces + Migrēt no ierīces, vai kopīgot šo faila saiti + Migrēt no ierīces, dzēst datu bāzi no ierīces + Migrēt no ierīces, sarunas uzsākšana vairākās ierīcēs nav atbalstīta + Migrēt no ierīces, uzsākt sarunu + Migrēt no ierīces, migrācija pabeigta + Migrēt no ierīces, nedrīkstat uzsākt datu bāzi divās ierīcēs + Migrēt no ierīces, izmantošana divās ierīcēs pārtrauc šifrēšanu + Migrēt no ierīces, pārbaudīt datu bāzes paroli + Migrēt no ierīces, pārbaudīt paroli + Migrēt no ierīces, apstipriniet, ka atceraties paroli + Migrēt no ierīces, pārbaudiet savienojumu un mēģiniet vēlreiz + Migrēt no ierīces, arhīvs tiks dzēsts + Kļūda, migrējot no ierīces, pārbaudot paroli + Tīkla veids: nav tīkla savienojuma + Tīkla veids: mobilais + Tīkla veids: Wi-Fi + Tīkla veids: Ethernet + Cita tīkla veida + Serveri + Serveru failu cilne + Trūkstošie serveri + Serveru mērķis + Visi lietotāji + Pašreizējais lietotājs + Serveru transporta sesiju virsraksts + Savienotās serveru sesijas + Savienojamās serveru sesijas + Serveru sesiju kļūdas + Serveru statistikas virsraksts + Nosūtītās ziņas no serveriem + Saņemtās ziņas no serveriem + Serveru detaļas + Serveru privāto datu atruna + Serveru abonementu virsraksts + Abonētās serveru savienojumu + Gaidošie serveru abonementu savienojumi + Kopējais serveru abonementu skaits + Savienoto serveru virsraksts + Iepriekš savienoto serveru virsraksts + Proksēto serveru virsraksts + Proksēto serveru kājenes + Pārsavienot serverus + Pārsavienot serverus + Pārsavienot serveri + Pārsavienot serveri + Kļūda pārsavienojot serverus + Kļūda pārsavienojot serveri + Serveru modalitātes kļūda + Pārsavienot visus serverus + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Apstiprināt serveru statistikas atjaunošanu + Kļūda atjaunojot serveru statistiku + Augšupielādēti serveri + Lejupielādēti serveri + Detalizēta serveru statistika + Serveru detalizētā statistika par nosūtītajām ziņām + Serveru detalizētā statistika par kopējo nosūtīto ziņu skaitu + Serveru detalizētā statistika par saņemtajām ziņām + Serveru detalizētā statistika par kopējo saņemto ziņu skaitu + Serveru detalizētā statistika par saņemšanas kļūdām + Serveri sākot no + Smp serveris + Xftp serveris + Pārlādēt + Mēģinājumi + Nosūtīts tieši + Nosūtīts caur proxy + Proxy + Nosūtīšanas kļūdas + Beidzies + Citi + Dublikāti + Atšifrēšanas kļūdas + Citas kļūdas + Apstiprināts + Apstiprināšanas kļūdas + Savienojumi + Izveidots + Aizsargāts + Pabeigts + Izdzēsts + Izdzēšanas kļūdas + Abonēts + Abonēšanas rezultāti ignorēti + Abonēšanas kļūdas + Augšupielādētie faili + Izmērs + Augšupielādītie fragmenti + Augšupielādes kļūdas + Izdzēstie fragmenti + Lejupielādētie fragmenti + Lejupielādētie faili + Lejupielādes kļūdas + Servera adrese + Atvērt servera iestatījumus + Sasniegts maksimālais grupas pieminējumu skaits ziņā. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml new file mode 100644 index 0000000000..a6385a5ce0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -0,0 +1,220 @@ + + + 1 dag + 1 minutt + 1 måned + 1 rapport + Engangslenke + 1 uke + 1 år + 30 sekunder + 5 minutter + a + b + Avbryt + Avbryt adresseendring + Avbryt adresseendring? + Om operatører + Om SimpleX + Om SimpleX-adressen + Om SimpleX Chat + Aksentfarge + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter som medlem + Aksepter som observatør + Aksepter vilkår + Aksepter tilkoblingsforespørsel? + Aksepter kontaktforespørsel + Aksepter kontaktforespørsel + Akseptert %1$s + Akseptert anrop + Akseptert vilkår + Akseptert invitasjon + Aksepterte deg + Aksepter inkognito + Aksepter medlem + Koble til servere via SOCKS proxy på port %d? Proxy må startes før du skrur på dette valget. + Bekreftelsesfeil + Aktive tilkoblinger + Legg til en adresse i profilen din slik at kontaktene dine kan dele denne med andre. Profiloppdateringen vil bli sendt til dine kontakter. + Legg til kontakt + Lagt til medie- og filservere + Meldingsservere er lagt til + Legg til venner + Legg til en liste + Legg til en melding + Legg til forhåndsvalgte servere + Legg til en profil + Adresse + Adresseendringen vil bli avbrutt. Den gamle mottakeradressen vil bli brukt. + Adresse eller engangslenke? + Adresseinnstillinger + Legg til en server + Legg til servere ved å skanne QR-koder. + Legg til teammedlemmer + Legg til en annen enhet + Legg til listen + Legg til velkomstmelding + Legg til dine teammedlemmer i samtalene. + administrator + administratorer + Administratorer kan blokkere ett medlem for alle. + Administratorer kan lage lenker for å bli med i grupper. + Avanserte nettverksinnstillinger + Avanserte innstillinger + Avanserte innstillinger + godkjenner krypteringen… + alle + Alle + All appdata er slettet. + Alle chatter og meldinger vil bli slettet - dette kan ikke angres! + Alle chatter vil bli fjernet fra listen %s, og listen vil bli slettet + Alle gruppemedlemmer vil forbli tilkoblet. + alle medlemmer + Alle meldinger vil bli slettet - dette kan ikke angres! + Alle meldinger vil bli slettet – dette kan ikke angres! Meldingene vil KUN bli slettet for deg. + Alle nye meldinger fra %s vil bli skjult! + Alle nye meldinger fra disse medlemmene vil bli skjult! + Tillat + Tillate + Tillat anrop? + Tillat samtaler kun vis kontakten din tillater dem. + Tillat forsvinnende meldinger kun vis kontakten din tillater dem. + Tillat nedgradering + Tillat filer og medier kun vis kontakten din tillater det. + Tillat irreversibel sletting av meldinger kun hvis kontakten din tillater det. (24 timer) + Tillat meldingsreaksjoner. + Tillat meldingsreaksjoner kun vis kontakten din tillater det. + Tillat direktemeldinger til medlemmer. + Tillat å slette sendte meldinger irreversibelt. (24 timer) + Tillat å rapportere meldinger til moderatorer. + Tillat å sende forsvinnende meldinger. + Tillat å sende filer og medier. + Tillat sending av SimpleX-lenker. + Tillat sending av talemeldinger. + Tillat talemeldinger? + Tillat talemeldinger kun vis kontakten din tillater det. + Tillat kontaktene dine å sende meldingsreaksjoner. + Tillat kontaktene dine å ringe deg. + Tillat kontaktene dine å irreversibelt slette sendte meldinger. (24 timer) + Tillat kontaktene dine å sende forsvinnende meldinger. + Tillat kontaktene dine å sende filer og medier. + Tillat kontaktene dine å sende talemeldinger. + Alle profiler + Alle rapporter vil bli arkivert for deg. + Alle servere + Alle dine kontakter, samtaler og filer vil bli kryptert og lastet opp i deler til konfigurerte XFTP-reléer. + Alle kontaktene dine vil forbli tilkoblet. + Alle kontaktene dine vil forbli tilkoblet. Profiloppdatering vil bli sendt til kontaktene dine. + Kobler allerede til! + alltid + Alltid + Alltid på + Bruk alltid privat ruting. + Bruk alltid relé + og %d andre hendelser + Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. + Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler. + En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig. + En ny tilfeldig profil vil bli delt. + En annen grunn + Svar anrop + Hvem som helst kan være vert for servere. + APP + Appen kjører alltid i bakgrunnen + App build: %s + Appen kan bare motta varsler når den er åpen, ingen bakgrunnstjeneste vil bli startet. + Backup av appdata + Migrering av appdata + Utseende + Appen krypterer nye lokale filer (unntatt videoer). + Appikon + Bruk + Bruk på + App-passord + App-passord + App-passordet byttes med det selvdestruerende passordet. + Appøkt + Apptema + App-verktøylinjer + Appoppdatering er lastet ned. + Appversjon + Appversjon: v%s + Arabisk, bulgarsk, finsk, hebraisk, thai og ukrainks - takk til brukerne og Weblate. + Arkiv + Arkiver alle rapporter? + Arkiver og last opp + Arkiver kontakter for å chatte senere. + Arkiverte kontakter + arkivert rapport + arkivert rapport av %s + Arkiver %d rapporter? + Arkiver rapport + Arkiver rapport? + Arkiver rapporter + Arkiverer database + Spør + Bedt om å motta bildet + Bedt om å motta videoen + Legg ved + forsøk + Lyd- og videosamtaler + lydanrop + Lydanrop + lydanrop (ikke E2E-kryptert) + Lyd av + Lyd på + Lyd- og videosamtaler + Lyd/videosamtaler + Lyd/videosamtaler er ikke tillatt. + Autentisering avbrutt + Autentisering mislyktes + Autentisering er ikke tilgjengelig + forfatter + Godta automatisk + Godta kontaktforespørsler automatisk + Godta bilder automatisk + \nTilgjengelig i v5.1 + Tilbake + Bakgrunn + Bakgrunnstjenesten kjører alltid - varsler vises så snart meldingene er tilgjengelige. + Batterioptimalisering er aktivert, og bakgrunnstjenesten og periodiske forespørsler om nye meldinger er slått av. Du kan aktivere det igjen i innstillingene. + Beta + Bedre anrop + Bedre grupper + Bedre gruppeytelse + Bedre meldingsdatoer. + Bedre meldinger + Bedre personvern og sikkerhet + Bedre sikkerhet ✅ + Bedre brukeropplevelse + Bio: + Bio er for stor + Svart + Blokker + blokkert + blokkert + blokkert av administrator + Blokkert av administrator + blokkert %s + Blokker for alle + Blokker gruppemedlemmer + Blokker medlem + Blokker medlem? + Blokker medlem for alle? + Blokker medlemmer for alle? + Bluetooth + fet + Bot + Både du og kontakten din kan legge til reaksjoner på meldinger. + Både du og kontakten din kan slette sendte meldinger irreversibelt. (24 timer) + Både du og kontakten din kan ringe. + Både du og kontakten din kan sende forsvinnende meldinger. + Både du og kontakten din kan sende filer og medier. + Både du og kontakten din kan sende talemeldinger. + Firmaadresse + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index b5be8b9773..655b1cedbd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -811,7 +811,7 @@ Dank aan de gebruikers – draag bij via Weblate! Transport isolation SimpleX - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. + U bent verbonden met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. Je profiel wordt verzonden naar het contact van wie je deze link hebt ontvangen. Je maakt verbinding met alle groepsleden. Uitvoeren bij geopende app @@ -912,8 +912,8 @@ SimpleX groep link SimpleX links Eenmalige SimpleX uitnodiging - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). onbekend berichtformaat Via browser via contact adres link @@ -2436,4 +2436,11 @@ Accepteer Lid accepteren Lid zal toetreden tot de groep, lid accepteren? + Incognitoprofiel gebruiken + Open chat + Open een nieuwe chat + Nieuwe groep openen + geen abonnement + U bent niet verbonden met de server die u gebruikt om berichten van deze verbinding te ontvangen (geen abonnement). + end-to-end-encryptie.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a7862ffcf1..0b59cc1b06 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -26,7 +26,7 @@ wysyłanie plików nie jest jeszcze obsługiwane odbieranie plików nie jest jeszcze obsługiwane Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). + Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). nieznany format wiadomości SimpleX Ty diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 285d6f3802..c0bbe4d6bb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -971,7 +971,7 @@ Toque para ativar o perfil. Mostrar perfil de chat Mostrar perfil - Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). + Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). Tentando se conectar ao servidor utilizado para receber mensagens deste contato. Você está conectado ao servidor usado para receber mensagens desse contato. Seu servidor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 7232cc56d3..2f19492237 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -1902,7 +1902,7 @@ Bun venit, %1$s! Atinge pentru a începe o conversație nouă Folosește acreditări proxy diferite pentru fiecare conexiune. - Adresă SimpleX sau link unic? + Adresă SimpleX sau link de unică folosință? Pentru a-ți proteja confidențialitatea, SimpleX folosește ID-uri separate pentru fiecare persoană de contact. Ai fost invitat în grup Pentru a primi @@ -1933,7 +1933,7 @@ necunoscut Poți ascunde sau dezactiva notificările unui profil de utilizator – ține apăsat pentru meniu. Ați permis - Partajează linkul unic cu un prieten + Partajează link de unică folosință cu un prieten Mesaje vocale Mesaj vocal… Setări proxy SOCKS @@ -1946,7 +1946,7 @@ Mesajele vocale sunt interzise! Ați acceptat conexiunea Partajează adresa public - Partajează acest link de invitație unic + Partajează acest link de invitație de unică folosință Pentru a verifica criptarea end-to-end, compară (sau scanează) codul de pe dispozitivele voastre cu persoana de contact. Utilizare pentru conexiuni noi Utilizați proxy SOCKS @@ -1989,7 +1989,7 @@ Videoclipuri și fișiere de până la 1 GB Comută modul incognito la conectare. Formă imagine de profil - Schimbă profilul de conversații pentru invitații unice. + Schimbă profilul de conversație pentru invitații de unică folosință. Puteți activa mai târziu prin Setări săptămâni Acesta este linkul tău unic, valabil o singură dată! @@ -2077,7 +2077,7 @@ Imaginea nu poate fi decodificată. Vă rugăm să încercați o altă imagine sau să contactați dezvoltatorii. Serverele pentru fișierele noi ale profilului tău de conversații actual Acest grup nu mai există. - Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). + Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). Actualizarea setărilor va reconecta clientul la toate serverele. Acest șir de caractere nu este un link! S-a atins timpul de expirare la conectarea la desktop @@ -2099,7 +2099,7 @@ (pentru a partaja cu persoana de contact) Pentru a începe o nouă conversație Video - Partajează link unic + Partajează link de unică folosință Acest link nu este un link de conectare valid! Acest cod QR nu este un link! Link scurt @@ -2174,7 +2174,7 @@ Partajează adresa SimpleX pe rețelele de socializare. Pentru a te conecta, persoana ta de contact poate scana codul QR sau poate folosi linkul din aplicație. Poți seta numele conexiunii, ca să-ți amintești cu cine ai partajat linkul. - Adresa SimpleX și linkurile unice pot fi partajate în siguranță prin orice aplicație de mesagerie. + Adresa SimpleX și linkurile de unică folosință pot fi partajate în siguranță prin orice aplicație de mesagerie. Codul scanat nu este un cod QR de tip link SimpleX. Server de testare Servere de testare @@ -2257,7 +2257,7 @@ Întreabă %s.]]> Schimbați ștergerea automată a mesajelor? - Adresă sau link unic? + Adresă sau link de unică folosință? Sesiune de aplicație Contactele tale Datele tale de conectare pot fi trimise necriptat. @@ -2270,7 +2270,7 @@ Contacte Conexiunea necesită renegocierea criptării. Adresa ta SimpleX - Creează link unic + Creează un link de unică folosință Profilul tău actual Aplicația rulează întotdeauna în fundal acceptat %1$s @@ -2388,7 +2388,7 @@ toți Afaceri Prin utilizarea SimpleX Chat ești de acord să:\n- trimiți doar conținut legal în grupurile publice.\n- respecți ceilalți utilizatori – fără spam. - doar cu un singur contact - partajează-l personal sau prin orice altă aplicație de mesagerie.]]> + doar cu un singur contact - partajează-l personal sau prin orice aplicație de mesagerie.]]> Nu trebuie să utilizați aceeași bază de date pe două dispozitive. arătați codul QR în apelul video sau distribuiți linkul.]]> Utilizare de pe desktop în aplicația mobilă și scanează codul QR.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 6bb357fea8..95e5ed9409 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -14,7 +14,7 @@ ошибка соединяется Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). + Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. удалено @@ -42,7 +42,7 @@ SimpleX одноразовая ссылка SimpleX ссылка группы через %1$s - SimpleX ссылки + Ссылки SimpleX Описание Полная ссылка В браузере @@ -207,7 +207,7 @@ В настоящее время максимальный поддерживаемый размер файла составляет %1$s. Ожидается приём файла Файл будет принят, когда Ваш контакт будет в сети, подождите или проверьте позже! - Файл сохранен + Файл сохранён Файл не найден Ошибка сохранения файла @@ -379,12 +379,12 @@ Используются серверы предоставленные SimpleX Chat. Инфо Как использовать серверы - Сохраненные WebRTC ICE серверы будут удалены. - Ваши ICE серверы - Настройка ICE серверов - ICE серверы (один на строке) - Ошибка при сохранении ICE серверов - Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + Сохранённые WebRTC ICE-серверы будут удалены. + Ваши ICE-серверы + Настройка ICE-серверов + ICE-серверы (один на строке) + Ошибка при сохранении ICE-серверов + Пожалуйста, проверьте, что адреса WebRTC ICE-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Сохранить Сеть и серверы Настройки сети @@ -412,7 +412,7 @@ Имя профиля: Полное имя: Ваш активный профиль - Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю. + Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. Серверы SimpleX не могут получить доступ к Вашему профилю. Поменять аватар Удалить аватар Сохранить предпочтения? @@ -503,8 +503,8 @@ Принимать Показывать Выключить - Ваши ICE серверы - WebRTC ICE серверы + Ваши ICE-серверы + WebRTC ICE-серверы Relay-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. Relay-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. @@ -633,22 +633,22 @@ Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. База данных зашифрована случайным паролем, Вы можете его поменять. Внимание: Вы не сможете восстановить или поменять пароль, если потеряете его.]]> - Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. - Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата. + Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. + Пароль не сохранён на устройстве — Вы будете должны ввести его при каждом запуске чата. Зашифровать базу данных? Поменять пароль базы данных? База данных будет зашифрована. - База данных будет зашифрована и пароль сохранен в Keystore. - Пароль базы данных будет изменен и сохранен в Keystore. + База данных будет зашифрована и пароль сохранён в Keystore. + Пароль базы данных будет изменён и сохранён в Keystore. Пароль базы данных будет изменен. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. Неправильный пароль базы данных База данных зашифрована Ошибка базы данных Ошибка Keystore - Пароль базы данных отличается от сохраненного в Keystore. + Пароль базы данных отличается от сохранённого в Keystore. Файл: %s Введите пароль базы данных, чтобы открыть чат. Ошибка: %s @@ -682,7 +682,7 @@ Выйти Выйти из группы Вы перестанете получать сообщения от этой группы. История чата будет сохранена. - Пригласить членов группы + Пригласить в группу Группа неактивна Приглашение истекло! Приглашение в группу больше не действительно, оно было удалено отправителем. @@ -752,7 +752,7 @@ Нельзя пригласить контакт! Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль. - Пригласить членов группы + Пригласить в группу %1$s ЧЛЕНОВ ГРУППЫ Вы: %1$s Удалить группу @@ -1433,8 +1433,8 @@ Использовать активный профиль Использовать новый профиль инкогнито Расход батареи приложением / Без ограничений в настройках приложения.]]> - База данных будет зашифрована, и пароль сохранен в настройках. - Шифруйте сохраненные файлы и медиа + База данных будет зашифрована, и пароль сохранён в настройках. + Шифруйте сохранённые файлы и медиа Обратите внимание: соединение с серверами файлов и сообщений устанавливаются через SOCKS-прокси. Звонки и картинки ссылок используют прямое соединение.]]> Шифровать локальные файлы Приложение для компьютера! @@ -1496,7 +1496,7 @@ Вступление в группу уже начато! %1$d сообщений отмодерировано членом %2$s %s был отключен]]> - Быстрое вступление и надежная доставка сообщений. + Быстрое вступление и надёжная доставка сообщений. Соединиться с самим собой? Связанные мобильные Компьютер @@ -1739,7 +1739,7 @@ Остановка чата Архивировать и загрузить Подтвердить загрузку - Все ваши контакты, разговоры и файлы будут надежно зашифрованы и загружены на выбранные XFTP-серверы. + Все ваши контакты, разговоры и файлы будут надёжно зашифрованы и загружены на выбранные XFTP-серверы. Ошибка загрузки Повторить загрузку Вы можете попробовать ещё раз. @@ -1801,7 +1801,7 @@ Наушники Громкоговоритель Звуки во время звонков - Более надежное соединение с сетью. + Более надёжное соединение с сетью. Статус сети сохранено сохранено из %s @@ -1815,7 +1815,7 @@ Ссылки SimpleX Разрешить отправлять ссылки SimpleX. Запретить отправку ссылок SimpleX - Члены могут отправлять ссылки SimpleX + Члены могут отправлять SimpleX ссылки. админы все члены владельцы @@ -2000,7 +2000,7 @@ Блоков удалено Блоков принято Подписок игнорировано - Ошибка копирования + Скопировать ошибку видеозвонок Контакт будет удален — это нельзя отменить! Оставить разговор @@ -2464,7 +2464,7 @@ Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. Настроить операторов серверов Политика конфиденциальности и условия использования. - все + всех Принять Член группы хочет присоединиться. Принять? группа удалена @@ -2492,7 +2492,7 @@ Одобрять членов группы Чаты с членами группы Приём членов в группу - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. Нет чатов с членами группы Принять как читателя Принять в группу @@ -2512,7 +2512,7 @@ Установить вступление в группу Удалить чат с членом группы? Удалить разговор - принят %1$s + принял %1$s Чат с админами Вы приняты 1 чат с членом группы diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index b4d854c3d1..411cbde4c4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1043,7 +1043,7 @@ คุณได้รับเชิญให้เข้าร่วมกลุ่ม โปรไฟล์แบบสุ่มของคุณ ธีม - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) + กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) SimpleX คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ โปรไฟล์ของคุณจะถูกส่งไปยังผู้ติดต่อที่ส่งลิงก์นี้มาให้คุณ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index ec8af9053e..501f07ea50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1023,7 +1023,7 @@ Sohbet profillerini parola ile koru! Daha az pil kullanımı Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). + Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın Mesajlarda Markdown diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index ddd54b717f..6d498ef4ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -124,7 +124,7 @@ помилка підключення Ви підключені до сервера для отримання повідомлень від цього контакту. - Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). + Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). видалено Спроба підключитися до сервера для отримання повідомлень від цього контакту. відзначено як видалено @@ -2512,4 +2512,23 @@ Ваш контакт Ваша група Ваш профіль + Дозволяйте файли та медіа лише за умови, що ваш контакт їх дозволяє. + Дозвольте своїм контактам надсилати файли та медіа. + Бот + Ви, і ваш контакт можете надсилати файли та медіа. + ЗАПИТИ НА ЗВ’ЯЗОК ВІД ГРУП + Застарілі опції + Помилка при відмітці як прочитане + Файли та медіа заборонені у цьому чаті. + Відбиток у адресі сервера переадресації не співпадає з сертифікатом: %1$s. + Відбиток у адресі сервера не співпадає з сертифікатом: %1$s. + Користувача видалено - не може прийняти запит. + Тільки ви можете надсилати файли та медіа. + Лише ваш контакт може надсилати файли та медіа. + Відкрити чисте посилання + Відкрити повне посилання + Відкрити для використання бота + Заборонити надсилання файлів і медіа. + Видалити відстеження посилань + запит на підключення до групи %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index b71350ea50..ae22c40277 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -2034,7 +2034,7 @@ Bật Tổng không xác định - Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. Cách ly truyền tải diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index d6686f358d..5f729d4ea3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -575,7 +575,7 @@ 更新网络设置? 只有你可以不可逆地删除消息(你的联系人可以将它们标记为删除)。(24小时) 重新启动应用程序以创建新的聊天资料。 - 服务器需要授权才能创建队列,检查密码 + 服务器需要授权才能创建队列,检查密码。 测试在步骤 %s 失败。 你已经有一个显示名相同的聊天资料。请选择另一个名字。 已发送 @@ -589,8 +589,8 @@ 已删除 %1$s 你删除了 %1$s 你的个人资料将发送给你收到此链接的联系人。 - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 - 你已连接到用于接收该联系人消息的服务器。 + 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 + 你已连接到用于接收该连接消息的服务器。 你分享了一次性链接 很可能此联系人已经删除了与你的联系。 资料图片占位符 @@ -609,7 +609,7 @@ 你当前的聊天数据库将被删除并替换为导入的数据库。 \n此操作无法撤消——你的个人资料、联系人、消息和文件将不可逆地丢失。 已邀请 %1$s 保存群资料 - 服务器地址中的证书指纹可能不正确 + 服务器地址指纹和证书不匹配。 请使用 %1$s 检查你的网络连接,然后重试。 多个聊天资料 数据库不能正常工作。点击了解更多 @@ -777,7 +777,7 @@ 给我们发电子邮件 SMP 服务器 尚不支持发送文件 - 正在尝试连接到用于从该联系人接收消息的服务器。 + 尝试连接到用于从该连接接收消息的服务器。 尚不支持接收文件 未知消息格式 测试服务器 @@ -1004,7 +1004,7 @@ 视频已发送 要求接收视频 视频将在你的联系人完成上传后收到。 - 服务器需要授权来上传,检查密码 + 服务器需要授权来上传,检查密码。 上传文件 XFTP 服务器 你的 XFTP 服务器 @@ -2409,7 +2409,7 @@ 和一名成员的一个聊天 无法发送消息 你离开了 - 删除和成员的聊天出错 + 删除聊天出错 你无法发送消息! 禁用了联系人 群被删除了 @@ -2519,5 +2519,10 @@ 打开完整链接 删除链接跟踪 SimpleX 中继链接 - 将和成员的聊天标记为已读时出错 + 标记为已读时出错 + 目标服务器地址的指纹和证书不匹配:%1$s。 + 转发服务器地址的指纹和证书不匹配:%1$s。 + 服务器地址证书和证书不匹配:%1$s。 + 无订阅 + 未连接到用于从该连接接收消息的服务器(无订阅)。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index ee0c785e9f..8a444d6c2b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -238,7 +238,7 @@ 錯誤 連接中 你已連接到此聯絡人使用的伺服器以接收訊息。 - 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 + 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 正在嘗試連接到用於接收此聯絡人訊息伺服器。 已刪除 已標記為已刪除 diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b4f41b8221..53e4c60872 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4.5 -android.version_code=319 +android.version_name=6.4.6 +android.version_code=321 android.bundle=false -desktop.version_name=6.4.5 -desktop.version_code=120 +desktop.version_name=6.4.6 +desktop.version_code=121 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 2091ab444b..33145497ea 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Main where @@ -5,17 +6,22 @@ module Main where import Directory.Options import Directory.Service import Directory.Store -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Directory.Store.Migrate import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts - st <- restoreDirectoryStore directoryLog - if runCLI - then directoryServiceCLI st opts - else do - env <- newServiceState opts - let cfg = terminalChatConfig {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env + opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts + case migrateDirectoryLog of + Just cmd -> migrate cmd opts terminalChatConfig + Nothing -> do + st <- openDirectoryLog directoryLog + if runCLI + then directoryServiceCLI st opts + else directoryService st opts terminalChatConfig + where + migrate = \case + MLCheck -> checkDirectoryLog + MLImport -> importDirectoryLogToDB + MLExport -> exportDBToDirectoryLog + MLListing -> saveGroupListingFiles diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 8ae7f60b3d..1f075c677c 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -135,6 +135,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin + DCPromoteGroup_ :: DirectoryCmdTag 'DRSuperUser DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -157,7 +158,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser DCShowUpgradeGroupLink :: GroupId -> Maybe GroupName -> DirectoryCmd 'DRUser - DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin + DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId, promote :: Maybe Bool} -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin @@ -167,6 +168,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin + DCPromoteGroup :: GroupId -> GroupName -> Bool -> DirectoryCmd 'DRSuperUser DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -211,6 +213,7 @@ directoryCmdP = "invite" -> au DCInviteOwnerToGroup_ -- "block_word" -> au DCAddBlockedWord_ -- "unblock_word" -> au DCRemoveBlockedWord_ + "promote" -> su DCPromoteGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -270,7 +273,8 @@ directoryCmdP = DCApproveGroup_ -> do (groupId, displayName) <- gc (,) groupApprovalId <- A.space *> A.decimal - pure DCApproveGroup {groupId, displayName, groupApprovalId} + promote <- Just <$> (" promote=" *> onOffP) <|> pure Nothing + pure DCApproveGroup {groupId, displayName, groupApprovalId, promote} DCRejectGroup_ -> gc DCRejectGroup DCSuspendGroup_ -> gc DCSuspendGroup DCResumeGroup_ -> gc DCResumeGroup @@ -283,12 +287,17 @@ directoryCmdP = DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCPromoteGroup_ -> do + (groupId, displayName) <- gc (,) + promote <- A.space *> onOffP + pure $ DCPromoteGroup groupId displayName promote DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) where gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) -- wordP = spacesP *> A.takeTill isSpace spacesP = A.takeWhile1 isSpace + onOffP = (A.string "on" $> True) <|> (A.string "off" $> False) directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case @@ -314,6 +323,7 @@ directoryCmdTag = \case DCInviteOwnerToGroup {} -> "invite" -- DCAddBlockedWord _ -> "block_word" -- DCRemoveBlockedWord _ -> "unblock_word" + DCPromoteGroup {} -> "promote" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs new file mode 100644 index 0000000000..0d4e8d351c --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Directory.Listing where + +import Control.Applicative ((<|>)) +import Control.Monad +import Crypto.Hash (Digest, MD5) +import qualified Crypto.Hash as CH +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64URL +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Int (Int64) +import Data.List (isPrefixOf) +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock +import Data.Time.Clock.System +import Data.Time.Format.ISO8601 (iso8601Show) +import Directory.Store +import Simplex.Chat.Markdown +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import System.Directory +import System.FilePath + +directoryDataPath :: String +directoryDataPath = "data" + +listingFileName :: String +listingFileName = "listing.json" + +promotedFileName :: String +promotedFileName = "promoted.json" + +listingImageFolder :: String +listingImageFolder = "images" + +data DirectoryEntryType = DETGroup + { admission :: Maybe GroupMemberAdmission, + summary :: GroupSummary + } + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DET") ''DirectoryEntryType) + +data DirectoryEntry = DirectoryEntry + { entryType :: DirectoryEntryType, + displayName :: Text, + groupLink :: CreatedLinkContact, + shortDescr :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + imageFile :: Maybe String, + activeAt :: Maybe UTCTime, + createdAt :: Maybe UTCTime + } + +$(JQ.deriveJSON defaultJSON ''DirectoryEntry) + +data DirectoryListing = DirectoryListing {entries :: [DirectoryEntry]} + +$(JQ.deriveJSON defaultJSON ''DirectoryListing) + +type ImageFileData = ByteString + +newOrActive :: NominalDiffTime +newOrActive = 30 * nominalDay + +recentRoundedTime :: Int64 -> UTCTime -> UTCTime -> Maybe UTCTime +recentRoundedTime roundTo now t + | diffUTCTime now t > newOrActive = Nothing + | otherwise = + let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo + in Just $ systemToUTCTime $ MkSystemTime secs 0 + +groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ = + let GroupProfile {displayName, shortDescr, description, image, memberAdmission} = groupProfile + entryType = DETGroup memberAdmission groupSummary + entry groupLink = + let de = + DirectoryEntry + { entryType, + displayName, + groupLink, + shortDescr = toFormattedText <$> shortDescr, + welcomeMessage = toFormattedText <$> description, + imageFile = fst <$> imgData, + activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, + createdAt = recentRoundedTime 86400 now createdAt + } + imgData = imgFileData groupLink =<< image + in (de, imgData) + in (entry . connLinkContact) <$> gLink_ + where + imgFileData :: CreatedConnLink 'CMContact -> ImageData -> Maybe (FilePath, ByteString) + imgFileData groupLink (ImageData img) = + let (img', imgExt) = + fromMaybe (img, ".jpg") $ + (,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img + <|> (,".png") <$> T.stripPrefix "data:image/png;base64," img + imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) $ strEncode (connFullLink groupLink) + imgFile = listingImageFolder imgName <> imgExt + in case B64.decode $ encodeUtf8 img' of + Right img'' -> Just (imgFile, img'') + Left _ -> Nothing + +generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO () +generateListing dir gs = do + createDirectoryIfMissing True dir + oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir + ts <- getCurrentTime + let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" + newDir = dir newDirPath + createDirectoryIfMissing True (newDir listingImageFolder) + gs' <- + fmap catMaybes $ forM gs $ \(g, gr, link_) -> + forM (groupDirectoryEntry ts g link_) $ \(g', img) -> do + forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData + pure (g', gr) + saveListing newDir listingFileName gs' + saveListing newDir promotedFileName $ filter (\(_, GroupReg {promoted}) -> promoted) gs' + -- atomically update the link + let newSymLink = newDir <> ".link" + symLink = dir directoryDataPath + createDirectoryLink newDirPath newSymLink + renamePath newSymLink symLink + mapM_ (removePathForcibly . (dir )) oldDirs + where + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst + +toFormattedText :: Text -> MarkdownList +toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 7ad3512fe9..fb82c27b78 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -7,16 +7,20 @@ module Directory.Options ( DirectoryOpts (..), + MigrateLog (..), getDirectoryOpts, mkChatOpts, ) where +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Parsers (parseAll) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, @@ -30,12 +34,16 @@ data DirectoryOpts = DirectoryOpts profileNameLimit :: Int, captchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, + migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, runCLI :: Bool, searchResults :: Int, + webFolder :: Maybe FilePath, testing :: Bool } +data MigrateLog = MLCheck | MLImport | MLExport | MLListing + directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts directoryOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -106,12 +114,20 @@ directoryOpts appDir defaultDbName = do <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) directoryLog <- - Just - <$> strOption + optional $ + strOption ( long "directory-file" <> metavar "DIRECTORY_FILE" <> help "Append only log for directory state" ) + migrateDirectoryLog <- + optional $ + option + parseMigrateLog + ( long "migrate-directory-file" + <> metavar "MIGRATE_COMMAND" + <> help "Command to import/export directory log file" + ) serviceName <- strOption ( long "service-name" @@ -124,6 +140,13 @@ directoryOpts appDir defaultDbName = do ( long "run-cli" <> help "Run directory service as CLI" ) + webFolder <- + optional $ + strOption + ( long "web-folder" + <> metavar "WEB_FOLDER" + <> help "Folder to store static web assets" + ) pure DirectoryOpts { coreOptions, @@ -137,9 +160,11 @@ directoryOpts appDir defaultDbName = do profileNameLimit, captchaGenerator, directoryLog, + migrateDirectoryLog, serviceName = T.pack serviceName, runCLI, searchResults = 10, + webFolder, testing = False } @@ -172,3 +197,14 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} = createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}, maintenance = False } + +parseMigrateLog :: ReadM MigrateLog +parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack + where + mlP = + A.takeTill (== ' ') >>= \case + "check" -> pure MLCheck + "import" -> pure MLImport + "export" -> pure MLExport + "listing" -> pure MLListing + _ -> fail "bad MigrateLog" diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs index 5b0d650444..d71c128370 100644 --- a/apps/simplex-directory-service/src/Directory/Search.hs +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -1,12 +1,5 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} - module Directory.Search where -import Data.List (sortOn) -import Data.Ord (Down (..)) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Types @@ -14,19 +7,7 @@ import Simplex.Chat.Types data SearchRequest = SearchRequest { searchType :: SearchType, searchTime :: UTCTime, - sentGroups :: Set GroupId + lastGroup :: GroupId -- cursor for search } data SearchType = STAll | STRecent | STSearch Text - -takeTop :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers}) -> Down currentMembers) - -takeRecent :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _) -> Down createdAt) - -groupIds :: [GroupInfoSummary] -> Set GroupId -groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _) -> groupId) - -filterNotSent :: Set GroupId -> [GroupInfoSummary] -> [GroupInfoSummary] -filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index f95b04dee1..5901e1d61a 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -8,30 +8,27 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, - newServiceState, - acceptMemberHook ) where import Control.Concurrent (forkIO) -import Control.Concurrent.Async import Control.Concurrent.STM -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Data.Bifunctor (first) import Data.List (find, intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) -import Data.Set (Set) +import Data.Maybe (fromMaybe, isJust, isNothing) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -41,20 +38,22 @@ import Data.Time.LocalTime (getCurrentTimeZone) import Directory.BlockedWords import Directory.Captcha import Directory.Events +import Directory.Listing import Directory.Options import Directory.Search import Directory.Store +import Directory.Store.Migrate +import Directory.Util import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core -import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList, viewName) +import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) -import Simplex.Chat.Store (GroupLink (..)) import Simplex.Chat.Store.Direct (getContact) -import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, setGroupCustomData) +import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) @@ -63,14 +62,11 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) -import Simplex.Messaging.Agent.Store.Common (withTransaction) -import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) -import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact) import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) import System.Process (readProcess) @@ -97,7 +93,8 @@ data GroupRolesStatus data ServiceState = ServiceState { searchRequests :: TMap ContactId SearchRequest, blockedWordsCfg :: BlockedWordsConfig, - pendingCaptchas :: TMap GroupMemberId PendingCaptcha + pendingCaptchas :: TMap GroupMemberId PendingCaptcha, + updateListingsJob :: TMVar ChatController } data PendingCaptcha = PendingCaptcha @@ -120,7 +117,8 @@ newServiceState opts = do searchRequests <- TM.emptyIO blockedWordsCfg <- readBlockedWordsConfig opts pendingCaptchas <- TM.emptyIO - pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} + updateListingsJob <- newEmptyTMVarIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, updateListingsJob} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -142,32 +140,64 @@ welcomeGetOpts = do knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n knownName i n = show i <> ":" <> T.unpack (viewName n) -directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO () directoryServiceCLI st opts = do env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - chatHooks = defaultChatHooks {postStartHook = Just postStartHook, eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} - race_ - (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) - (processEvents eventQ env) + chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + eventHook = Just eventHook, + acceptMember = Just $ acceptMemberHook opts env + } + raceAny_ $ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processEvents eventQ env + ] + <> updateListingsThread_ opts env where processEvents eventQ env = forever $ do (cc, resp) <- atomically $ readTQueue eventQ u_ <- readTVarIO (currentUser cc) forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp - postStartHook cc = - readTVarIO (currentUser cc) >>= \case - Nothing -> putStrLn "No current user" >> exitFailure - Just User {userId, profile = p@LocalProfile {preferences}} -> do - let cmds = fromMaybe [] $ preferences >>= commands_ - unless (cmds == directoryCommands) $ do - let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences - p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile - liftIO $ sendChatCmd cc (APIUpdateProfile userId p') >>= \case - Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands" - Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure - Left e -> putStrLn ("Error: " <> show e) >> exitFailure + +updateListingDelay :: Int +updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes + +updateListingsThread_ :: DirectoryOpts -> ServiceState -> [IO ()] +updateListingsThread_ opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts + where + updateListingsThread f = do + cc <- atomically $ takeTMVar $ updateListingsJob env + forever $ do + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> updateGroupListingFiles cc user f + delay <- registerDelay updateListingDelay + atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry + +listingsUpdated :: ServiceState -> ChatController -> IO () +listingsUpdated env = void . atomically . tryPutTMVar (updateListingsJob env) + +directoryPreStartHook :: DirectoryOpts -> ChatController -> IO () +directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore + +directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () +directoryPostStartHook opts env cc = + readTVarIO (currentUser cc) >>= \case + Nothing -> putStrLn "No current user" >> exitFailure + Just User {userId, profile = p@LocalProfile {preferences}} -> do + listingsUpdated env cc + let cmds = fromMaybe [] $ preferences >>= commands_ + unless (cmds == directoryCommands) $ do + let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences + p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile + liftIO $ + sendChatCmd cc (APIUpdateProfile userId p') >>= \case + Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands" + Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure + Left e -> putStrLn ("Error: " <> show e) >> exitFailure directoryCommands :: [ChatBotCommand] directoryCommands = @@ -185,12 +215,24 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} env user cc = do - initializeBotAddress' (not testing) cc - race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - directoryServiceEvent st opts env user cc resp +directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO () +directoryService st opts@DirectoryOpts {testing} cfg = do + env <- newServiceState opts + let chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + acceptMember = Just $ acceptMemberHook opts env + } + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> do + initializeBotAddress' (not testing) cc + raceAny_ $ + [ forever $ void getLine, + forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + directoryServiceEvent st opts env user cc resp + ] + <> updateListingsThread_ opts env acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook @@ -215,7 +257,7 @@ acceptMemberHook when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance -groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData +groupMemberAcceptance GroupInfo {customData} = (\DirectoryGroupData {memberAcceptance = ma} -> ma) $ fromCustomData customData useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool useMemberFilter img_ = \case @@ -233,7 +275,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () +directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct @@ -265,12 +307,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName forM_ adminUsers $ \KnownContact {contactId} -> action contactId withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s - notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId + notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId - withGroupReg GroupInfo {groupId, localDisplayName} err action = do - getGroupReg st groupId >>= \case - Just gr -> action gr - Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId + withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () + withGroupReg GroupInfo {groupId, localDisplayName} err action = + getGroupReg cc groupId >>= \case + Right gr -> action gr + Left e -> do + let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e + notifyAdminUsers msg + logError msg groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr @@ -285,46 +331,31 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupAlreadyListed GroupInfo {groupProfile = p} = "The group " <> groupNameDescr p <> " is already listed in the directory, please choose another name." - getGroups :: Text -> IO (Maybe [GroupInfoSummary]) - getGroups = getGroups_ . Just - - getGroups_ :: Maybe Text -> IO (Maybe [GroupInfoSummary]) - getGroups_ search_ = - sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - Right CRGroupsList {groups} -> pure $ Just groups - _ -> pure Nothing - - getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) - getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = - getGroups fullName >>= mapM duplicateGroup + getDuplicateGroup :: GroupInfo -> IO (Either String DuplicateGroup) + getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = + duplicateGroup <$$> getDuplicateGroupRegs cc user displayName where - sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _) = - gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) - duplicateGroup [] = pure DGUnique - duplicateGroup groups = do - let gs = filter sameGroupNotRemoved groups - if null gs - then pure DGUnique - else do - (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) - let reserved = any (\(GIS GroupInfo {groupId = gId} _) -> gId `S.member` lgs || gId `S.member` rgs) gs - if reserved - then pure DGReserved - else do - removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs - pure $ if removed then DGUnique else DGRegistered - isGroupRemoved (GIS GroupInfo {groupId = gId} _) = - getGroupReg st gId >>= \case - Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus - Nothing -> pure True + duplicateGroup [] = DGUnique + duplicateGroup ((GroupInfo {groupId = gId, membership}, GroupReg {groupRegStatus = status}) : groups) + | gId == groupId || memberRemoved membership = duplicateGroup groups + | otherwise = case grDirectoryStatus status of + DSListed -> DGReserved + DSReserved -> DGReserved + DSRegistered -> case duplicateGroup groups of + DGReserved -> DGReserved + _ -> DGRegistered + DSRemoved -> duplicateGroup groups - processInvitation :: Contact -> GroupInfo -> IO () - processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do - void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId MFNone - sendMessage cc ct $ case r of - Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" - _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" + processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO () + processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case + Nothing -> addGroupReg notifyAdminUsers st cc ct g GRSProposed joinGroup + Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup + where + joinGroup _ = do + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone + sendMessage cc ct $ case r of + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () deContactConnected ct = when (contactDirect ct) $ do @@ -343,15 +374,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just msg -> sendMessage cc ct msg Nothing -> getDuplicateGroup g >>= \case - Just DGUnique -> processInvitation ct g - Just DGRegistered -> askConfirmation - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Right DGUnique -> processInvitation ct g Nothing + Right DGRegistered -> askConfirmation + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e where - askConfirmation = do - ugrId <- addGroupReg st ct g GRSPendingConfirmation - sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> tshow ugrId <> ":" <> viewName displayName + askConfirmation = + addGroupReg notifyAdminUsers st cc ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do + sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" + sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName badRolesMsg :: GroupRolesStatus -> Maybe Text badRolesMsg = \case @@ -360,9 +391,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GRSContactNotOwner -> Just "You must have a group *owner* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" - getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus) - getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr = - rStatus <$$> getGroupMember gr + getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Either String GroupRolesStatus) + getGroupRolesStatus GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} gr = + rStatus <$$> getOwnerGroupMember groupId gr where rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole @@ -373,50 +404,52 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (GROwner, _) -> GRSServiceNotAdmin _ -> GRSBadRoles - getGroupMember :: GroupReg -> IO (Maybe GroupMember) - getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} = - readTVarIO dbOwnerMemberId - $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) - where - resp = \case - Right CRGroupMemberInfo {member} -> Just member - _ -> Nothing + getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) + getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () - deServiceJoinedGroup ctId g owner = do + deServiceJoinedGroup ctId g@GroupInfo {groupId} owner = do logInfo $ "service joined group " <> viewGroupName g withGroupReg g "joined group" $ \gr -> when (ctId `isOwner` gr) $ do - setGroupRegOwner st gr owner - let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner - gr - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + let GroupInfo {groupProfile = GroupProfile {displayName}} = g + setGroupRegOwner cc groupId owner >>= \case + Left e -> do + let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e + logError msg + notifyOwner gr msg + Right () -> do + logGUpdateOwner st groupId $ groupMemberId' owner + notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> + setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner + gr' + "Created the public link to join the group via this directory service that is always online.\n\n\ + \Please add it to the group welcome message.\n\ + \For example, add:" + notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink + Left (ChatError e) -> case e of + CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" + CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + _ -> notifyOwner gr $ unexpectedError "can't create group link" deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do - withGroupReg toGroup "group updated" $ \gr -> do + withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr toGroup byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. - readTVarIO (groupRegStatus gr) >>= \case + case groupRegStatus of GRSPendingConfirmation -> pure () GRSProposed -> pure () GRSPendingUpdate -> @@ -431,7 +464,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GPServiceLinkError -> do notifyOwner gr $ ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) - <> " after profile was updated" <> byMember <> ". Please report the error to the developers." + <> " after profile was updated" + <> byMember + <> ". Please report the error to the developers." logError $ "Error: no group link for " <> userGroupRef GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 GRSActive -> processProfileChange gr byMember True 1 @@ -445,35 +480,32 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') - groupLinkAdded gr byMember = do + groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case - Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> do - let gaId = 1 - setGroupStatus st gr $ GRSPendingApproval gaId - notifyOwner gr $ - ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup + _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do + notifyOwner gr' $ + ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr gaId + checkRolesSendToApprove gr' gaId + where + gaId = 1 processProfileChange gr byMember isActive n' = do let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case - GPNoServiceLink -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group link is added to " <> userGroupRef <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." @@ -484,13 +516,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + | otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr n' + checkRolesSendToApprove gr' n' where onlyLinkChanged GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} @@ -518,12 +549,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> GPServiceLinkError checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case - Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers." - Just (Just msg) -> notifyOwner gr msg - Just Nothing -> sendToApprove toGroup gr gaId + Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e + Right (Just msg) -> notifyOwner gr msg + Right Nothing -> sendToApprove toGroup gr gaId dePendingMember :: GroupInfo -> GroupMember -> IO () - dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m + dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 | otherwise = approvePendingMember a g m where @@ -553,7 +584,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do - gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) + gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case @@ -600,37 +631,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName useMemberFilter image $ passCaptcha a sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () - sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do + sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image'}, groupSummary} GroupReg {dbContactId, promoted} gaId = do ct_ <- getContact' cc user dbContactId - gr_ <- getGroupAndSummary cc user dbGroupId - let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ + let membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_\n" text = - maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + either (\_ -> "The group ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow groupId <> ": ") ct_ <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow dbGroupId <> ":" <> viewName displayName <> " " <> tshow gaId + sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole - withGroupReg g "contact role changed" $ \gr -> do + withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ do - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole - GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do + when (ctId `isOwner` gr) $ + case groupRegStatus of + GRSSuspendedBadRoles | rStatus == GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole + GRSPendingApproval gaId | rStatus == GRSOk -> do sendToApprove g gr gaId notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (rStatus /= GRSOk) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole + GRSActive | rStatus /= GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole _ -> pure () where rStatus = groupRolesStatus contactRole serviceRole @@ -639,65 +669,64 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName suCtRole = "(user role is set to " <> ctRole <> ")." deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () - deServiceRoleChanged g serviceRole = do + deServiceRoleChanged g@GroupInfo {groupId} serviceRole = do logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole - withGroupReg g "service role changed" $ \gr -> do + withGroupReg g "service role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ - whenContactIsOwner gr $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole - GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ + case groupRegStatus of + GRSSuspendedBadRoles | serviceRole == GRAdmin -> + whenContactIsOwner gr $ + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole + GRSPendingApproval gaId | serviceRole == GRAdmin -> whenContactIsOwner gr $ do sendToApprove g gr gaId notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (serviceRole /= GRAdmin) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole + GRSActive | serviceRole /= GRAdmin -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole _ -> pure () where groupRef = groupReference g srvRole = "*" <> strEncodeTxt serviceRole <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." whenContactIsOwner gr action = - getGroupMember gr + getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g = do + deContactRemovedFromGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You are removed from the group " <> userGroupReference gr' g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g = do + deContactLeftGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g - withGroupReg g "contact left" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." + -- TODO combine + withGroupReg g "contact left" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." deServiceRemovedFromGroup :: GroupInfo -> IO () - deServiceRemovedFromGroup g = do + deServiceRemovedFromGroup g@GroupInfo {groupId} = do logInfo $ "service removed from group " <> viewGroupName g - withGroupReg g "service removed" $ \gr -> do - setGroupStatus st gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." deGroupDeleted :: GroupInfo -> IO () - deGroupDeleted g = do + deGroupDeleted g@GroupInfo {groupId} = do logInfo $ "group removed " <> viewGroupName g - withGroupReg g "group removed" $ \gr -> do - setGroupStatus st gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." @@ -722,57 +751,59 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName \`/link ` - view and upgrade group link.\n\ \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchGroup s -> + sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> -- $ sendSearchResults s + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case - Just search@SearchRequest {searchType, searchTime} -> do + Just SearchRequest {searchType, searchTime, lastGroup} -> do currentTime <- getCurrentTime if diffUTCTime currentTime searchTime > 300 -- 5 minutes then do atomically $ TM.delete (contactId' ct) searchRequests showAllGroups - else case searchType of - STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search - STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search - STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + else + sendFoundListedGroups searchType (Just lastGroup) "No more groups" $ \gs _ -> + "Sending " <> tshow (length gs) <> " more group(s)." Nothing -> showAllGroups where showAllGroups = deUserCommand ct ciId DCAllGroups - DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll - DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCAllGroups -> sendFoundListedGroups STAll Nothing "No groups listed" $ allGroupsReply "top" + DCRecentGroups -> sendFoundListedGroups STRecent Nothing "No groups listed" $ allGroupsReply "the most recent" DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> - withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingConfirmation -> - getDuplicateGroup g >>= \case - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - _ -> processInvitation ct g - _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." + withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr@GroupReg {groupRegStatus} -> case groupRegStatus of + GRSPendingConfirmation -> + getDuplicateGroup g >>= \case + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g $ Just gr + _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - getUserGroupRegs st (contactId' ct) >>= \grs -> do - sendReply $ tshow (length grs) <> " registered group(s)" - void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {dbGroupId, userGroupRegId} -> - let useGroupId = if isAdmin then dbGroupId else userGroupRegId - in sendGroupInfo ct gr useGroupId Nothing + getUserGroupRegs cc user (contactId' ct) >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId isAdmin (gs, length gs) DCDeleteGroup gId gName -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do - delGroupReg st gr - sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} GroupReg {dbGroupId} -> do + delGroupReg cc dbGroupId >>= \case + Right () -> do + logGDelete st dbGroupId + sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + Left e -> sendReply $ "Error deleting group " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> getGroupLink' cc user g >>= \case - Just GroupLink {connLinkContact = gLink, acceptMemberRole} -> do + Right GroupLink {connLinkContact = gLink, acceptMemberRole} -> do let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n acceptMemberRole <> ("Send /'role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink @@ -788,8 +819,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just a' | a /= a' -> do let d = toCustomData $ DirectoryGroupData a' withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case - Just () -> sendSettigns n a' " set to" - Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + Right () -> sendSettigns n a' " set to" + Left e -> sendReply $ "Error changing spam filter settings for group " <> n <> ": " <> T.pack e _ -> sendSettigns n a "" where sendSettigns n a setTo = @@ -817,12 +848,13 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet - sendReply $ T.unlines $ - [ "The link to join the group " <> groupRef <> ":", - groupLinkText gLink, - "New member role: " <> strEncodeTxt acceptMemberRole - ] - <> ["The link is being upgraded..." | shouldBeUpgraded] + sendReply $ + T.unlines $ + [ "The link to join the group " <> groupRef <> ":", + groupLinkText gLink, + "New member role: " <> strEncodeTxt acceptMemberRole + ] + <> ["The link is being upgraded..." | shouldBeUpgraded] when shouldBeUpgraded $ do let send = sendComposedMessage cc ct Nothing . MCText . T.unlines withGroupLinkResult groupRef (sendChatCmd cc $ APIAddGroupShortLink groupId) $ @@ -830,13 +862,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (Just _, Just _) -> send ["The group link is upgraded for: " <> groupRef, "No changes to group needed."] (Nothing, Just sLnk) -> - sendComposedMessages cc (SRDirect $ contactId' ct) - [ MCText $ T.unlines - [ "Please replace the old link in welcome message of your group " <> groupRef, - "If this is the only change, the group will remain listed in directory without re-approval.", - "", - "The new link:" - ], + sendComposedMessages + cc + (SRDirect $ contactId' ct) + [ MCText $ + T.unlines + [ "Please replace the old link in welcome message of your group " <> groupRef, + "If this is the only change, the group will remain listed in directory without re-approval.", + "", + "The new link:" + ], MCText $ strEncodeTxt sLnk ] (_, Nothing) -> @@ -862,59 +897,37 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just withUserGroupReg_ ugrId gName_ action = - getUserGroupReg st (contactId' ct) ugrId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just gr@GroupReg {dbGroupId} -> do - getGroup cc user dbGroupId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | maybe True (displayName ==) gName_ -> action g gr - | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName + getUserGroupReg cc user (contactId' ct) ugrId >>= \case + -- TODO differentiate group not found error + Left e -> sendReply $ "Group ID " <> tshow ugrId <> " error:" <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) + | maybe True (displayName ==) gName_ -> action g gr + | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId - withFoundListedGroups s_ action = - getGroups_ s_ >>= \case - Just groups -> filterListedGroups st groups >>= action - Nothing -> sendReply "Error: getGroups. Please notify the developers." - sendSearchResults s = \case - [] -> sendReply "No groups found" - gs -> do - let gs' = takeTop searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." - updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendAllGroups takeFirst sortName searchType = \case - [] -> sendReply "No groups listed" - gs -> do - let gs' = takeFirst searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - reply = tshow (length gs) <> " group(s) listed" <> more <> "." - updateSearchRequest searchType $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case - [] -> do - sendReply "Sorry, no more groups" - atomically $ TM.delete (contactId' ct) searchRequests - gs -> do - let gs' = takeFirst searchResults $ filterNotSent sentGroups gs - sentGroups' = sentGroups <> groupIds gs' - moreGroups = length gs - S.size sentGroups' - reply = "Sending " <> tshow (length gs') <> " more group(s)." - updateSearchRequest searchType sentGroups' - sendFoundGroups reply gs' moreGroups - updateSearchRequest :: SearchType -> Set GroupId -> IO () - updateSearchRequest searchType sentGroups = do + sendFoundListedGroups searchType lastGroup_ notFound replyStr = + searchListedGroups cc user searchType lastGroup_ searchResults >>= \case + Right ([], _) -> do + atomically $ TM.delete (contactId' ct) searchRequests + sendReply notFound + Right (gs, n) -> do + let moreGroups = n - length gs + updateSearchRequest searchType $ last gs + sendFoundGroups (replyStr gs n) gs moreGroups + Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e + allGroupsReply sortName gs n = + let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" + in tshow n <> " group(s) listed" <> more <> "." + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () + updateSearchRequest searchType (GroupInfo {groupId}, _) = do searchTime <- getCurrentTime - let search = SearchRequest {searchType, searchTime, sentGroups} + let search = SearchRequest {searchType, searchTime, lastGroup = groupId} atomically $ TM.insert (contactId' ct) search searchRequests sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers}) = + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary = GroupSummary {currentMembers}}, _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" text = showId <> groupInfoText p <> "\n" <> membersStr @@ -924,18 +937,20 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of - DCApproveGroup {groupId, displayName = n, groupApprovalId} -> - withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId} -> - readTVarIO (groupRegStatus gr) >>= \case + DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> + withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> + case groupRegStatus gr of GRSPendingApproval gaId - | gaId == groupApprovalId -> do + | gaId == groupApprovalId -> getDuplicateGroup g >>= \case - Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." - _ -> do - getGroupRolesStatus g gr >>= \case - Just GRSOk -> do - setGroupStatus st gr GRSActive + Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + _ -> getGroupRolesStatus g gr >>= \case + Right GRSOk -> do + let grPromoted' + | promoted || knownCt `elem` superUsers = fromMaybe promoted promote + | otherwise = False + setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ (approved <> " and listed in directory - please moderate it!\n") @@ -951,12 +966,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner <- groupOwnerInfo groupRef $ dbContactId gr pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName Left err -> pure err - sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited + sendReply $ "Group approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited - Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin - Just GRSContactNotOwner -> replyNotApproved "user is not an owner." - Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin - Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." + Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Right GRSContactNotOwner -> replyNotApproved "user is not an owner." + Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Left e -> sendReply $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e where replyNotApproved reason = sendReply $ "Group is not approved: " <> reason serviceNotAdmin = serviceName <> " is not an admin." @@ -968,32 +983,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSActive -> do - setGroupStatus st gr GRSSuspended + case groupRegStatus gr of + GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" - notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." + notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspended -> do - setGroupStatus st gr GRSActive + case groupRegStatus gr of + GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do let groupStr = "The group " <> userGroupReference' gr gName - notifyOwner gr $ groupStr <> " is listed in the directory again!" + notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." - DCListLastGroups count -> listGroups count False - DCListPendingGroups count -> listGroups count True + DCListLastGroups count -> + listLastGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs + DCListPendingGroups count -> + listPendingGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName - withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do notifyOwner gr msg - owner <- groupOwnerInfo groupRef dbContactId + owner <- groupOwnerInfo groupRef ctId sendReply $ "Forwarded to " <> owner DCInviteOwnerToGroup groupId gName -> case ownersGroup of Just og@KnownGroup {localDisplayName = ogName} -> @@ -1002,7 +1021,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Right () -> do let groupRef = groupReference' groupId gName owner <- groupOwnerInfo groupRef ctId - let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited sendReply $ "you" <> invited Left err -> sendReply err @@ -1015,17 +1034,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName knownCt = knownContact ct sendReply = mkSendReply ct ciId notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s - listGroups count pending = - readTVarIO (groupRegs st) >>= \groups -> do - grs <- - if pending - then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups - else pure groups - sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") - void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact' cc user dbContactId - let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ - sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case @@ -1039,7 +1047,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName r -> contErr r r -> contErr r where - alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + alreadyMember = isJust . find ((Just ctId ==) . memberContactId) contErr r = do let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r putStrLn $ T.unpack err @@ -1048,11 +1056,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - pure $ maybe "" ownerName owner_ <> ownerInfo + pure $ either (const "") ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of + DCPromoteGroup groupId gName promote' -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do + let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.") + if promote' /= promoted + then setGroupPromoted sendReply st env cc gr promote' notify + else notify DCExecuteCommand cmdStr -> sendChatCmdStr cc cmdStr >>= \case Right r -> do @@ -1077,58 +1091,90 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroup cc user gId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} + getGroupAndReg cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) | maybe False (displayName ==) gName_ -> - getGroupReg st gId >>= \case - Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" - Just gr -> action g gr + action g gr | otherwise -> sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName - sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () - sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do - grStatus <- readTVarIO $ groupRegStatus gr - let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc user dbGroupId >>= \case - Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" + getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] + getOwnersInfo gs = + fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + + sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () + sendGroupsInfo ct ciId isAdmin (gs, n) = do + let more = if n > length gs then ", showing the last " <> tshow (length gs) else "" + replyMsg = (Just ciId, MCText $ tshow n <> " registered group(s)" <> more) + gs' <- if isAdmin then getOwnersInfo gs else pure $ map (,Nothing) gs + sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' + where + groupMessage ((g, gr), ct_) = + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary} = g + GroupReg {userGroupRegId, groupRegStatus} = gr + useGroupId = if isAdmin then groupId else userGroupRegId + statusStr = "Status: " <> groupRegStatusText groupRegStatus + membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_" cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr, cmds] + ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr, cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> do - let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] - sendComposedMessage cc ct Nothing $ MCText text + in (Nothing, msg) -getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId +setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO () +setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $ + listingsUpdated env cc + logGUpdateStatus st gId grStatus' + logGUpdatePromotion st gId grPromoted' + continue -getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) -getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId +addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +addGroupReg sendMsg st cc ct g@GroupInfo {groupId} grStatus continue = + addGroupRegStore cc ct g grStatus >>= \case + Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e + Right gr -> do + logGCreate st gr + continue gr -withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) -withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a +setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +setGroupStatus sendMsg st env cc gId grStatus' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusStore cc gId grStatus' >>= \case + Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (grStatus, gr) -> do + let status = grDirectoryStatus grStatus + when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc + logGUpdateStatus st gId grStatus' + continue gr -withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) -withDB cxt ChatController {chatStore} action = do - r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors - case r_ of - Right r -> pure $ Just r - Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) +setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO () +setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue = + setGroupPromotedStore cc gId grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc + logGUpdatePromotion st gId grPromoted' + continue -getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc user gId = - withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) +updateGroupListingFiles :: ChatController -> User -> FilePath -> IO () +updateGroupListingFiles cc u dir = + getAllListedGroups cc u >>= \case + Right gs -> generateListing dir gs + Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e -vr :: ChatController -> VersionRangeChat -vr ChatController {config = ChatConfig {chatVRange}} = chatVRange -{-# INLINE vr #-} +getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId -getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Maybe GroupLink) +getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = - withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo + withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 9498fedf95..b78b446821 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -1,28 +1,48 @@ {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Store - ( DirectoryStore (..), + ( DirectoryLog (..), GroupReg (..), GroupRegStatus (..), UserGroupRegId, GroupApprovalId, DirectoryGroupData (..), DirectoryMemberAcceptance (..), + DirectoryStatus (..), ProfileCondition (..), - restoreDirectoryStore, - addGroupReg, + DirectoryLogRecord (..), + openDirectoryLog, + readDirectoryLogData, + addGroupRegStore, + insertGroupReg, delGroupReg, - setGroupStatus, + deleteGroupReg, + setGroupStatusStore, + setGroupStatusPromoStore, + setGroupPromotedStore, + grDirectoryStatus, setGroupRegOwner, - getGroupReg, getUserGroupReg, getUserGroupRegs, - filterListedGroups, + getAllGroupRegs_, + getDuplicateGroupRegs, + getGroupReg, + getGroupAndReg, + listLastGroups, + listPendingGroups, + getAllListedGroups, + getAllListedGroups_, + searchListedGroups, groupRegStatusText, pendingApproval, groupRemoved, @@ -31,13 +51,21 @@ module Directory.Store noJoinFilter, basicJoinFilter, moderateJoinFilter, - strongJoinFilter + strongJoinFilter, + groupDBError, + logGCreate, + logGDelete, + logGUpdateOwner, + logGUpdateStatus, + logGUpdatePromotion, ) where -import Control.Concurrent.STM +import Control.Applicative ((<|>)) import Control.Monad -import Data.Aeson ((.=), (.:)) +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Aeson ((.:), (.=)) import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT @@ -45,41 +73,51 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) -import Data.List (find, foldl', sortOn) +import Data.List (sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock.System (systemEpochDay) +import Directory.Search +import Directory.Util +import Simplex.Chat.Controller +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Store +import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) -import Simplex.Messaging.Util (ifM) -import System.Directory (doesFileExist, renameFile) +import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) -data DirectoryStore = DirectoryStore - { groupRegs :: TVar [GroupReg], - listedGroups :: TVar (Set GroupId), - reservedGroups :: TVar (Set GroupId), - directoryLogFile :: Maybe Handle +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data DirectoryLog = DirectoryLog + { directoryLogFile :: Maybe Handle } data GroupReg = GroupReg { dbGroupId :: GroupId, userGroupRegId :: UserGroupRegId, dbContactId :: ContactId, - dbOwnerMemberId :: TVar (Maybe GroupMemberId), - groupRegStatus :: TVar GroupRegStatus - } - -data GroupRegData = GroupRegData - { dbGroupId_ :: GroupId, - userGroupRegId_ :: UserGroupRegId, - dbContactId_ :: ContactId, - dbOwnerMemberId_ :: Maybe GroupMemberId, - groupRegStatus_ :: GroupRegStatus + dbOwnerMemberId :: Maybe GroupMemberId, + groupRegStatus :: GroupRegStatus, + promoted :: Bool, + createdAt :: UTCTime } data DirectoryGroupData = DirectoryGroupData @@ -140,7 +178,7 @@ data GroupRegStatus | GRSSuspended | GRSSuspendedBadRoles | GRSRemoved - deriving (Show) + deriving (Eq, Show) pendingApproval :: GroupRegStatus -> Bool pendingApproval = \case @@ -153,6 +191,7 @@ groupRemoved = \case _ -> False data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved + deriving (Eq) groupRegStatusText :: GroupRegStatus -> Text groupRegStatusText = \case @@ -188,105 +227,249 @@ toCustomData :: DirectoryGroupData -> CustomData toCustomData DirectoryGroupData {memberAcceptance} = CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] -addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId -addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- addGroupReg_ - logGCreate st grData - pure $ userGroupRegId_ grData +addGroupRegStore :: ChatController -> Contact -> GroupInfo -> GroupRegStatus -> IO (Either String GroupReg) +addGroupRegStore cc Contact {contactId = dbContactId} GroupInfo {groupId = dbGroupId} groupRegStatus = + withDB' "addGroupRegStore" cc $ \db -> do + createdAt <- getCurrentTime + maxUgrId <- + maybeFirstRow' 0 (fromMaybe 0 . fromOnly) $ + DB.query db "SELECT MAX(user_group_reg_id) FROM sx_directory_group_regs WHERE contact_id = ?" (Only dbContactId) + let gr = GroupReg {dbGroupId, userGroupRegId = maxUgrId + 1, dbContactId, dbOwnerMemberId = Nothing, groupRegStatus, promoted = False, createdAt} + insertGroupReg db gr + pure gr + +insertGroupReg :: DB.Connection -> GroupReg -> IO () +insertGroupReg db GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} = do + DB.execute + db + [sql| + INSERT INTO sx_directory_group_regs + (group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt, createdAt) + +delGroupReg :: ChatController -> GroupId -> IO (Either String ()) +delGroupReg cc gId = withDB' "delGroupReg" cc (`deleteGroupReg` gId) + +deleteGroupReg :: DB.Connection -> GroupId -> IO () +deleteGroupReg db gId = DB.execute db "DELETE FROM sx_directory_group_regs WHERE group_id = ?" (Only gId) + +setGroupStatusStore :: ChatController -> GroupId -> GroupRegStatus -> IO (Either String (GroupRegStatus, GroupReg)) +setGroupStatusStore cc gId grStatus' = + withDB "setGroupStatusStore" cc $ \db -> do + gr <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, updated_at = ? WHERE group_id = ?" (grStatus', ts, gId) + pure (groupRegStatus gr, gr {groupRegStatus = grStatus'}) + +setGroupStatusPromoStore :: ChatController -> GroupId -> GroupRegStatus -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupStatusPromoStore cc gId grStatus' grPromoted' = + withDB "setGroupStatusPromoStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, group_promoted = ?, updated_at = ? WHERE group_id = ?" (grStatus', BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +setGroupPromotedStore :: ChatController -> GroupId -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupPromotedStore cc gId grPromoted' = + withDB "setGroupPromotedStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_promoted = ?, updated_at = ? WHERE group_id = ?" (BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +groupDBError :: StoreError -> String +groupDBError = \case + SEGroupNotFound _ -> "group not found" + e -> show e + +setGroupRegOwner :: ChatController -> GroupId -> GroupMember -> IO (Either String ()) +setGroupRegOwner cc gId owner = do + ts <- getCurrentTime + withDB' "setGroupRegOwner" cc $ \db -> + DB.execute + db + [sql| + UPDATE sx_directory_group_regs + SET owner_member_id = ?, updated_at = ? + WHERE group_id = ? + |] + (groupMemberId' owner, ts, gId) + +getGroupReg :: ChatController -> GroupId -> IO (Either String GroupReg) +getGroupReg cc gId = withDB "getGroupReg" cc (`getGroupReg_` gId) + +getGroupReg_ :: DB.Connection -> GroupId -> ExceptT String IO GroupReg +getGroupReg_ db gId = + ExceptT $ firstRow rowToGroupReg "group registration not found" $ + DB.query + db + [sql| + SELECT group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at + FROM sx_directory_group_regs + WHERE group_id = ? + |] + (Only gId) + +getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) +getGroupAndReg cc user@User {userId, userContactId} gId = + withDB "getGroupAndReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + +getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) +getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = + withDB "getUserGroupReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) + +getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) +getUserGroupRegs cc user@User {userId, userContactId} ctId = + withDB' "getUserGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) + +getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user + +getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db vr' user@User {userId, userContactId} = + DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) + >>= mapM (withGroupLink . toGroupInfoReg vr' user) where - addGroupReg_ = do - let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} - gr <- dataToGroupReg grData - atomically $ stateTVar (groupRegs st) $ \grs -> - let ugrId = 1 + foldl' maxUgrId 0 grs - grData' = grData {userGroupRegId_ = ugrId} - gr' = gr {userGroupRegId = ugrId} - in (grData', gr' : grs) - ctId = contactId' ct - maxUgrId mx GroupReg {dbContactId, userGroupRegId} - | dbContactId == ctId && userGroupRegId > mx = userGroupRegId - | otherwise = mx + withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) -delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do - logGDelete st gId - atomically $ writeTVar groupRegStatus GRSRemoved - atomically $ unlistGroup st gId - atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) - -setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () -setGroupStatus st gr grStatus = do - logGUpdateStatus st (dbGroupId gr) grStatus - atomically $ do - writeTVar (groupRegStatus gr) grStatus - updateListing st $ dbGroupId gr +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = + withDB' "searchListedGroups" cc $ \db -> + case searchType of + STAll -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " + STRecent -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY r.created_at DESC " + STSearch search -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond <> orderBy) (GRSActive, gId, s, s, s, s) + pure (gs, n) + where + s = T.toLower search + countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " where - updateListing = case grDirectoryStatus grStatus of - DSListed -> listGroup - DSReserved -> reserveGroup - DSRegistered -> unlistGroup - DSRemoved -> unlistGroup + groups = (map (toGroupInfoReg (vr cc) user) <$>) + count = maybeFirstRow' 0 fromOnly + listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " + countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " + searchCond = + [sql| + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] -setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () -setGroupRegOwner st gr owner = do - let memberId = groupMemberId' owner - logGUpdateOwner st (dbGroupId gr) memberId - atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) +getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db user@User {userId, userContactId} = + map (toGroupInfoReg supportedChatVRange user) + <$> DB.query db groupReqQuery (userId, userContactId) -getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) +getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) +getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = + withDB' "getDuplicateGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) +listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listLastGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" + pure (gs, n) -getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) +listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listPendingGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" + pure (gs, n) -filterListedGroups :: DirectoryStore -> [GroupInfoSummary] -> IO [GroupInfoSummary] -filterListedGroups st gs = do - lgs <- readTVarIO $ listedGroups st - pure $ filter (\(GIS GroupInfo {groupId} _) -> groupId `S.member` lgs) gs +toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = + (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) -listGroup :: DirectoryStore -> GroupId -> STM () -listGroup st gId = do - modifyTVar' (listedGroups st) $ S.insert gId - modifyTVar' (reservedGroups st) $ S.delete gId +type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) -reserveGroup :: DirectoryStore -> GroupId -> STM () -reserveGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.insert gId +rowToGroupReg :: GroupRegRow -> GroupReg +rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = + GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} -unlistGroup :: DirectoryStore -> GroupId -> STM () -unlistGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.delete gId +groupReqQuery :: Query +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond + where + groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " data DirectoryLogRecord - = GRCreate GroupRegData + = GRCreate GroupReg | GRDelete GroupId | GRUpdateStatus GroupId GroupRegStatus + | GRUpdatePromotion GroupId Bool | GRUpdateOwner GroupId GroupMemberId data DLRTag = GRCreate_ | GRDelete_ | GRUpdateStatus_ + | GRUpdatePromotion_ | GRUpdateOwner_ -logDLR :: DirectoryStore -> DirectoryLogRecord -> IO () +logDLR :: DirectoryLog -> DirectoryLogRecord -> IO () logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) -logGCreate :: DirectoryStore -> GroupRegData -> IO () +logGCreate :: DirectoryLog -> GroupReg -> IO () logGCreate st = logDLR st . GRCreate -logGDelete :: DirectoryStore -> GroupId -> IO () +logGDelete :: DirectoryLog -> GroupId -> IO () logGDelete st = logDLR st . GRDelete -logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO () logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId -logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () +logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO () +logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId + +logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO () logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where @@ -294,12 +477,14 @@ instance StrEncoding DLRTag where GRCreate_ -> "GCREATE" GRDelete_ -> "GDELETE" GRUpdateStatus_ -> "GSTATUS" + GRUpdatePromotion_ -> "GPROMOTE" GRUpdateOwner_ -> "GOWNER" strP = A.takeTill (== ' ') >>= \case "GCREATE" -> pure GRCreate_ "GDELETE" -> pure GRDelete_ "GSTATUS" -> pure GRUpdateStatus_ + "GPROMOTE" -> pure GRUpdatePromotion_ "GOWNER" -> pure GRUpdateOwner_ _ -> fail "invalid DLRTag" @@ -308,30 +493,35 @@ instance StrEncoding DirectoryLogRecord where GRCreate gr -> strEncode (GRCreate_, gr) GRDelete gId -> strEncode (GRDelete_, gId) GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus) + GRUpdatePromotion gId promoted -> strEncode (GRUpdatePromotion_, gId, promoted) GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId) strP = strP_ >>= \case GRCreate_ -> GRCreate <$> strP GRDelete_ -> GRDelete <$> strP GRUpdateStatus_ -> GRUpdateStatus <$> A.decimal <*> _strP + GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal -instance StrEncoding GroupRegData where - strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = - B.unwords - [ "group_id=" <> strEncode dbGroupId_, - "user_group_id=" <> strEncode userGroupRegId_, - "contact_id=" <> strEncode dbContactId_, - "owner_member_id=" <> strEncode dbOwnerMemberId_, - "status=" <> strEncode groupRegStatus_ +instance StrEncoding GroupReg where + strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} = + B.unwords $ + [ "group_id=" <> strEncode dbGroupId, + "user_group_id=" <> strEncode userGroupRegId, + "contact_id=" <> strEncode dbContactId, + "owner_member_id=" <> strEncode dbOwnerMemberId, + "status=" <> strEncode groupRegStatus ] + <> ["promoted=" <> strEncode promoted | promoted] strP = do - dbGroupId_ <- "group_id=" *> strP_ - userGroupRegId_ <- "user_group_id=" *> strP_ - dbContactId_ <- "contact_id=" *> strP_ - dbOwnerMemberId_ <- "owner_member_id=" *> strP_ - groupRegStatus_ <- "status=" *> strP - pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} + dbGroupId <- "group_id=" *> strP_ + userGroupRegId <- "user_group_id=" *> strP_ + dbContactId <- "contact_id=" *> strP_ + dbOwnerMemberId <- "owner_member_id=" *> strP_ + groupRegStatus <- "status=" *> strP + promoted <- (" promoted=" *> strP) <|> pure False + let createdAt = UTCTime systemEpochDay 0 + pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} instance StrEncoding GroupRegStatus where strEncode = \case @@ -355,70 +545,30 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> IO GroupReg -dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do - dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ - groupRegStatus <- newTVarIO groupRegStatus_ - pure - GroupReg - { dbGroupId = dbGroupId_, - userGroupRegId = userGroupRegId_, - dbContactId = dbContactId_, - dbOwnerMemberId, - groupRegStatus - } +instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode -restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore -restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) - Nothing -> newDirectoryStore Nothing +instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +openDirectoryLog :: Maybe FilePath -> IO DirectoryLog +openDirectoryLog = \case + Just f -> DirectoryLog . Just <$> openLogFile f + Nothing -> pure $ DirectoryLog Nothing where - newFile f = do - h <- openFile f WriteMode + openLogFile f = do + h <- openFile f AppendMode hSetBuffering h LineBuffering pure h - restore f = do - grs <- readDirectoryData f - renameFile f (f <> ".bak") - h <- writeDirectoryData f grs -- compact - mkDirectoryStore h grs -emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) -emptyStoreData = ([], S.empty, S.empty) - -newDirectoryStore :: Maybe Handle -> IO DirectoryStore -newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) - -mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore -mkDirectoryStore h groups = - foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) - where - addGroupRegData (!grs, !listed, !reserved) gr@GroupRegData {dbGroupId_ = gId} = do - gr' <- dataToGroupReg gr - let grs' = gr' : grs - pure $ case grDirectoryStatus $ groupRegStatus_ gr of - DSListed -> (grs', S.insert gId listed, reserved) - DSReserved -> (grs', listed, S.insert gId reserved) - DSRegistered -> (grs', listed, reserved) - DSRemoved -> (grs, listed, reserved) - -mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore -mkDirectoryStore_ h (grs, listed, reserved) = do - groupRegs <- newTVarIO grs - listedGroups <- newTVarIO listed - reservedGroups <- newTVarIO reserved - pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} - -readDirectoryData :: FilePath -> IO [GroupRegData] -readDirectoryData f = - sortOn dbGroupId_ . M.elems +readDirectoryLogData :: FilePath -> IO [GroupReg] +readDirectoryLogData f = + sortOn dbGroupId . M.elems <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) where - processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData) + processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg) processDLR m l = case strDecode l of Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) Right r -> case r of - GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do + GRCreate gr@GroupReg {dbGroupId = gId} -> do when (isJust $ M.lookup gId m) $ putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced." @@ -426,16 +576,12 @@ readDirectoryData f = GRDelete gId -> case M.lookup gId m of Just _ -> pure $ M.delete gId m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.") - GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {groupRegStatus_} m + GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.") + GRUpdatePromotion gId promoted -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {promoted} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.") GRUpdateOwner gId grOwnerId -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.") - -writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle -writeDirectoryData f grs = do - h <- openFile f WriteMode - hSetBuffering h LineBuffering - forM_ grs $ B.hPutStrLn h . strEncode . GRCreate - pure h diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs new file mode 100644 index 0000000000..ad37eba6d7 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Migrate where + +import Control.Concurrent.STM +import Control.Monad +import Control.Monad.Except +import qualified Data.ByteString.Char8 as B +import Data.List (find) +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import Directory.Listing +import Directory.Options +import Directory.Store +import Simplex.Chat (createChatDatabase) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Store.Groups (getGroupInfo, getHostMember) +import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (whenM) +import System.Directory (doesFileExist, renamePath) +import System.Exit (exitFailure) +import System.IO (IOMode (..), withFile) + +#if defined(dbPostgres) +import Directory.Store.Postgres.Migrations +#else +import Directory.Store.SQLite.Migrations +#endif + +runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () +runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False) + (Just "sx_directory_migrations") + directorySchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("directory migrations " <>) . show) pure + where + DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +checkDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + mapM_ (verifyGroupRegistration db user) gs + putStrLn $ show (length gs) <> " group registrations OK" + +importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () +importDirectoryLogToDB opts cfg = do + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + ctRegs <- TM.emptyIO + withActiveUser st $ \user -> withTransaction st $ \db -> do + forM_ gs $ \gr -> + whenM (verifyGroupRegistration db user gr) $ do + putStrLn $ "importing group " <> show (dbGroupId gr) + insertGroupReg db =<< fixUserGroupRegId ctRegs gr + renamePath logFile (logFile ++ ".bak") + putStrLn $ show (length gs) <> " group registrations imported" + where + fixUserGroupRegId ctRegs gr@GroupReg {dbGroupId, dbContactId} = do + ugIds <- fromMaybe [] <$> TM.lookupIO dbContactId ctRegs + gr' <- + if userGroupRegId gr `elem` ugIds + then do + let ugId = maximum ugIds + 1 + putStrLn $ "Warning: updating userGroupRegId for group " <> show dbGroupId <> ", contact " <> show dbContactId + pure gr {userGroupRegId = ugId} + else pure gr + atomically $ TM.insert dbContactId (userGroupRegId gr' : ugIds) ctRegs + pure gr' + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure + +exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +exportDBToDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists" + runDirectoryMigrations opts cfg st + withActiveUser st $ \user -> do + gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do + gs <- getAllGroupRegs_ db user + forM_ gs $ \(_, gr) -> + whenM (verifyGroupRegistration db user gr) $ + B.hPutStrLn h $ strEncode $ GRCreate gr + pure gs + putStrLn $ show (length gs) <> " group registrations exported" + +saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () +saveGroupListingFiles opts _cfg = case webFolder opts of + Nothing -> exit "use --web-folder to generate listings" + Just dir -> + withChatStore opts $ \st -> withActiveUser st $ \user -> + withTransaction st $ \db -> + getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + +verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) + Right GroupInfo {localDisplayName} -> do + let groupRef = show gId <> " " <> T.unpack localDisplayName + runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) + Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of + Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) + Just mId + | mId /= mId' -> False <$ putStrLn ("Error: different host member ID of " <> groupRef <> " (skipping): " <> show mId') + | otherwise -> True <$ unless (Just ctId == ctId') (putStrLn $ "Warning: bad group " <> groupRef <> " contact ID: " <> show ctId') + +withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO () +withDirectoryLog DirectoryOpts {directoryLog} action = + maybe (exit "directory log file not specified") action directoryLog + +withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO () +withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action = + createChatDatabase dbOptions migrationConfig >>= \case + Left e -> exit $ show e + Right ChatDatabase {chatStore, agentStore} -> do + action chatStore + closeDBStore chatStore + closeDBStore agentStore + where + migrationConfig = MigrationConfig (if yesToUpMigrations then MCYesUp else MCConsole) migrationBackupPath + +withActiveUser :: DBStore -> (User -> IO ()) -> IO () +withActiveUser st action = withTransaction st getUsers >>= maybe (exit "no active user") action . find activeUser diff --git a/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..4a801fee74 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Postgres.Migrations where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Text +m20250924_directory_schema = + T.pack + [r| +CREATE TABLE sx_directory_group_regs( + group_reg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id BIGINT NOT NULL, + contact_id BIGINT NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id BIGINT REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Text +down_m20250924_directory_schema = + T.pack + [r| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..f35f9e250a --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.SQLite.Migrations (directorySchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Query +m20250924_directory_schema = + [sql| +CREATE TABLE sx_directory_group_regs( + group_reg_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id INTEGER REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Query +down_m20250924_directory_schema = + [sql| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs new file mode 100644 index 0000000000..a4b79a1bef --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Util where + +import Control.Logger.Simple +import Control.Monad.Except +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.Controller +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (catchAll) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a + +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a) +withDB cxt ChatController {chatStore} action = do + r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show) + case r_ of + Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e + Right _ -> pure () + pure r_ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index dd1dd256d0..f3bb915665 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1465,7 +1465,7 @@ Get groups. GroupsList: Groups. - type: "groupsList" - user: [User](./TYPES.md#user) -- groups: [[GroupInfoSummary](./TYPES.md#groupinfosummary)] +- groups: [[GroupInfo](./TYPES.md#groupinfo)] ChatCmdError: Command error. - type: "chatCmdError" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 8353359484..923fab14c2 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -47,6 +47,7 @@ This file is generated automatically. - [ChatType](#chattype) - [ChatWallpaper](#chatwallpaper) - [ChatWallpaperScale](#chatwallpaperscale) +- [ClientNotice](#clientnotice) - [Color](#color) - [CommandError](#commanderror) - [CommandErrorType](#commanderrortype) @@ -89,7 +90,6 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupInfoSummary](#groupinfosummary) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -137,7 +137,6 @@ This file is generated automatically. - [RcvConnEvent](#rcvconnevent) - [RcvDirectEvent](#rcvdirectevent) - [RcvFileDescr](#rcvfiledescr) -- [RcvFileInfo](#rcvfileinfo) - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) @@ -290,6 +289,12 @@ AGENT: - type: "AGENT" - agentErr: [SMPAgentError](#smpagenterror) +NOTICE: +- type: "NOTICE" +- server: string +- preset: bool +- expiresAt: UTCTime? + INTERNAL: - type: "INTERNAL" - internalErr: string @@ -317,6 +322,7 @@ INACTIVE: **Record type**: - reason: [BlockingReason](#blockingreason) +- notice: [ClientNotice](#clientnotice)? --- @@ -913,6 +919,7 @@ Error: ErrorAgent: - type: "errorAgent" - agentError: [AgentErrorType](#agenterrortype) +- agentConnId: string - connectionEntity_: [ConnectionEntity](#connectionentity)? ErrorStore: @@ -1103,11 +1110,6 @@ FileAlreadyExists: - type: "fileAlreadyExists" - filePath: string -FileRead: -- type: "fileRead" -- filePath: string -- message: string - FileWrite: - type: "fileWrite" - filePath: string @@ -1384,6 +1386,14 @@ self == 'direct' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' // J - "repeat" +--- + +## ClientNotice + +**Record type**: +- ttl: int64? + + --- ## Color @@ -1530,16 +1540,6 @@ RcvGroupMsgConnection: - groupInfo: [GroupInfo](#groupinfo) - groupMember: [GroupMember](#groupmember) -SndFileConnection: -- type: "sndFileConnection" -- entityConnection: [Connection](#connection) -- sndFileTransfer: [SndFileTransfer](#sndfiletransfer) - -RcvFileConnection: -- type: "rcvFileConnection" -- entityConnection: [Connection](#connection) -- rcvFileTransfer: [RcvFileTransfer](#rcvfiletransfer) - UserContactConnection: - type: "userContactConnection" - entityConnection: [Connection](#connection) @@ -1609,7 +1609,6 @@ Error: - localDisplayName: string - profile: [LocalProfile](#localprofile) - activeConn: [Connection](#connection)? -- viaGroup: int64? - contactUsed: bool - contactStatus: [ContactStatus](#contactstatus) - chatSettings: [ChatSettings](#chatsettings) @@ -2131,6 +2130,7 @@ MemberSupport: **Record type**: - groupId: int64 +- useRelays: bool - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2147,19 +2147,11 @@ MemberSupport: - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? +- groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? ---- - -## GroupInfoSummary - -**Record type**: -- groupInfo: [GroupInfo](#groupinfo) -- groupSummary: [GroupSummary](#groupsummary) - - --- ## GroupLink @@ -2350,7 +2342,7 @@ Known: ## GroupSummary **Record type**: -- currentMembers: int +- currentMembers: int64 --- @@ -2930,16 +2922,6 @@ GroupInvLinkReceived: - fileDescrComplete: bool ---- - -## RcvFileInfo - -**Record type**: -- filePath: string -- connId: int64? -- agentConnId: string? - - --- ## RcvFileStatus @@ -2951,19 +2933,19 @@ New: Accepted: - type: "accepted" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Connected: - type: "connected" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Complete: - type: "complete" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Cancelled: - type: "cancelled" -- fileInfo_: [RcvFileInfo](#rcvfileinfo)? +- filePath_: string? --- @@ -3581,6 +3563,26 @@ InvalidQuote: InvalidMention: - type: "invalidMention" +InvalidDeliveryTask: +- type: "invalidDeliveryTask" +- taskId: int64 + +DeliveryTaskNotFound: +- type: "deliveryTaskNotFound" +- taskId: int64 + +InvalidDeliveryJob: +- type: "invalidDeliveryJob" +- jobId: int64 + +DeliveryJobNotFound: +- type: "deliveryJobNotFound" +- jobId: int64 + +WorkItemError: +- type: "workItemError" +- errContext: string + --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 279a74480b..4cce44e588 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -349,7 +349,6 @@ undocumentedCommands = "APIGetContactCode", "APIGetGroupMemberCode", "APIGetNetworkConfig", - "APIGetNetworkStatuses", "APIGetNtfConns", "APIGetNtfToken", "APIGetReactionMembers", @@ -440,6 +439,7 @@ undocumentedCommands = "ReconnectAllServers", "ReconnectServer", "ResetAgentServersStats", + "ShowConnectionsDiff", "ResubscribeAllConnections", "SetAllContactReceipts", "SetChatItemTTL", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index df50a09a8f..4a49551a54 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -174,19 +174,13 @@ undocumentedEvents = "CEvtContactPQEnabled", "CEvtContactRatchetSync", "CEvtContactRequestAlreadyAccepted", - "CEvtContactsDisconnected", - "CEvtContactsMerged", - "CEvtContactsSubscribed", - "CEvtContactSubError", - "CEvtContactSubSummary", "CEvtContactSwitch", "CEvtCustomChatEvent", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtHostConnected", "CEvtHostDisconnected", - "CEvtNetworkStatus", - "CEvtNetworkStatuses", + "CEvtSubscriptionStatus", "CEvtNewRemoteHost", "CEvtNoMemberContactCreating", "CEvtNtfMessage", @@ -205,12 +199,12 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", "CEvtTimedAction", "CEvtUnknownMemberAnnounced", "CEvtUnknownMemberBlocked", "CEvtUnknownMemberCreated", - "CEvtUserAcceptedGroupSent", -- repeat group invitation after it was accepted by the user - "CEvtUserContactSubSummary" + "CEvtUserAcceptedGroupSent" -- repeat group invitation after it was accepted by the user ] diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 8d5cb9f348..154d44b6c2 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -130,6 +130,7 @@ undocumentedResponses = "CRChats", "CRChatStarted", "CRChatStopped", + "CRConnectionsDiff", "CRChatTags", "CRConnectionAliasUpdated", "CRConnectionIncognitoUpdated", @@ -166,7 +167,6 @@ undocumentedResponses = "CRMemberSupportChatDeleted", "CRMemberSupportChats", "CRNetworkConfig", - "CRNetworkStatuses", "CRNewMemberContact", "CRNewMemberContactSentInv", "CRMemberContactAccepted", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 83675798af..73e427fa03 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -29,7 +29,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol -import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -43,6 +42,7 @@ import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NetworkError (..), ProxyError (..)) +import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport import Simplex.RemoteControl.Types import System.Console.ANSI.Types (Color (..)) @@ -69,7 +69,7 @@ chatTypesDocs = sortOn docTypeName $! snd $! mapAccumL toCTDoc (S.empty, M.empty let (tds', td_) = toTypeDef tds sumTypeInfo in case td_ of Just typeDef -> (tds', CTDoc {typeDef, typeSyntax, typeDescr}) - Nothing -> error $ "Recursive type: " <> typeName + Nothing -> error $ "Recursive type: " <> typeName toTypeDef :: (S.Set String, M.Map String APITypeDef) -> (SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text) -> ((S.Set String, M.Map String APITypeDef), Maybe APITypeDef) toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, consPrefix, hideConstrs, _, _) = @@ -84,7 +84,7 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons let fields = fromMaybe (error $ "Record type without fields: " <> typeName) $ L.nonEmpty fieldInfos ((visited', typeDefs'), fields') = mapAccumL (toAPIField_ typeName) (S.insert typeName visited, typeDefs) fields td = APITypeDef typeName $ ATDRecord $ L.toList fields' - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) _ -> error $ "Record type with " <> show (length constrs) <> " constructors: " <> typeName STUnion -> if length constrs > 1 then toUnionType constrs else unionError constrs STUnion1 -> if length constrs == 1 then toUnionType constrs else unionError constrs @@ -98,16 +98,16 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons toUnionType constrs = let ((visited', typeDefs'), members) = mapAccumL toUnionMember (S.insert typeName visited, typeDefs) $ fromMaybe (unionError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDUnion members - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) toUnionMember tds RecordTypeInfo {consName, fieldInfos} = let memberTag = normalizeConsName consPrefix consName - in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos + in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos unionError constrs = error $ "Union type with " <> show (length constrs) <> " constructor(s): " <> typeName toEnumType = toEnumType_ $ normalizeConsName consPrefix toEnumType_ f constrs = let members = L.map toEnumMember $ fromMaybe (enumError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDEnum members - in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) + in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) where toEnumMember RecordTypeInfo {consName, fieldInfos} = case fieldInfos of [] -> f consName @@ -121,7 +121,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField toAPIType = \case TIType (ST name _) -> apiTypeForName name TIOptional tInfo -> second ATOptional $ toAPIType tInfo - TIArray {elemType, nonEmpty} -> second (`ATArray`nonEmpty) $ toAPIType elemType + TIArray {elemType, nonEmpty} -> second (`ATArray` nonEmpty) $ toAPIType elemType TIMap {keyType = ST name _, valueType} | name `elem` primitiveTypes -> second (ATMap (PT name)) $ toAPIType valueType | otherwise -> error $ "Non-primitive key type in " <> typeName <> ", " <> fieldName @@ -133,7 +133,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField Nothing -> case find (\(STI name' _, _, _, _, _, _) -> name == name') chatTypesDocsData of Just sumTypeInfo -> let (tds', td_) = toTypeDef tds sumTypeInfo -- recursion to outer function, loops are resolved via type defs map lookup - in case td_ of + in case td_ of Just td -> (tds', ATDef td) Nothing -> (tds', ATRef name) Nothing -> error $ "Undefined type: " <> name @@ -229,6 +229,7 @@ chatTypesDocsData = (sti @CIMentionMember, STRecord, "", [], "", ""), (sti @CIReactionCount, STRecord, "", [], "", ""), (sti @CITimed, STRecord, "", [], "", ""), + (sti @ClientNotice, STRecord, "", [], "", ""), (sti @Color, STEnum, "", [], "", ""), (sti @CommandError, STUnion, "", [], "", ""), (sti @CommandErrorType, STUnion, "", [], "", ""), @@ -239,7 +240,7 @@ chatTypesDocsData = (sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""), (sti @ConnectionPlan, STUnion, "CP", [], "", ""), (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""), - (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", ["ConnSndFile", "ConnRcvFile"], "", ""), + (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""), (sti @Contact, STRecord, "", [], "", ""), (sti @ContactAddressPlan, STUnion, "CAP", [], "", ""), (sti @ContactShortLinkData, STRecord, "", [], "", ""), @@ -267,7 +268,6 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupInfoSummary, STRecord, "", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), @@ -316,7 +316,6 @@ chatTypesDocsData = (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), (sti @RcvDirectEvent, STUnion, "RDE", [], "", ""), (sti @RcvFileDescr, STRecord, "", [], "", ""), - (sti @RcvFileInfo, STRecord, "", [], "", ""), (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), @@ -352,7 +351,6 @@ chatTypesDocsData = (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), (sti @XFTPSndFile, STRecord, "", [], "", "") - -- (sti @DatabaseError, STUnion, "DB", [], "", ""), -- (sti @ChatItemInfo, STRecord, "", [], "", ""), -- (sti @ChatItemVersion, STRecord, "", [], "", ""), @@ -371,7 +369,7 @@ chatTypesDocsData = -- (sti @SendRef, STRecord, "", [], "", ""), -- (sti @SndQueueInfo, STRecord, "", [], "", ""), -- (sti @SndSwitchStatus, STEnum, "", [], "", ""), -- incorrect - ] + ] data SimplePreference = SimplePreference {allow :: FeatureAllowed} deriving (Generic) @@ -417,6 +415,7 @@ deriving instance Generic CIMention deriving instance Generic CIMentionMember deriving instance Generic CIReactionCount deriving instance Generic CITimed +deriving instance Generic ClientNotice deriving instance Generic Color deriving instance Generic CommandError deriving instance Generic CommandErrorType @@ -455,7 +454,6 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupInfoSummary deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember @@ -510,7 +508,6 @@ deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent deriving instance Generic RcvDirectEvent deriving instance Generic RcvFileDescr -deriving instance Generic RcvFileInfo deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent diff --git a/cabal.project b/cabal.project index 8a861a1909..46d8a43e55 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 7e98b3103f4eb9a6a9a99604afb6f3a32ffc013d + tag: 1ae3e8d0be957aa5090e88f25e6dc42d4af1a334 source-repository-package type: git diff --git a/docs/ABOUT.md b/docs/ABOUT.md new file mode 100644 index 0000000000..44809b9c0c --- /dev/null +++ b/docs/ABOUT.md @@ -0,0 +1,18 @@ +--- +layout: layouts/jobs.html +permalink: /about/index.html +--- + +# About us + +SimpleX Chat Ltd is a company founded to develop SimpleX network and software. + +Our mission is to create a fully decentralized network, based on the same principles as open web, but the one that gives users full control and ownership of their identity, contacts and communities. + +## Contact us + +SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). + +Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply. + +You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 9ffeeef2d2..8659222280 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -1,15 +1,17 @@ --- -title: SimpleX Directory Service +title: SimpleX Directory revision: 18.08.2023 --- -# SimpleX Directory Service +# SimpleX Directory -You can use an experimental directory service to discover the groups created and registered by other users. +You can use SimpleX Directory to discover the groups created and registered by other users. ## Searching for groups -Connect to the directory service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. +SimpleX Directory is available at [simplex.chat/directory](https://simplex.chat/directory/) or via this [onion link](http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion/directory/). + +You can also connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. Please note that your search queries can be kept by the bot as the conversation history, but you can use incognito mode when connecting to the bot, to avoid correlation with any other communications. See [Privacy policy](../PRIVACY.md) for more details. diff --git a/docs/rfcs/2025-08-11-channels-forwarding.md b/docs/rfcs/2025-08-11-channels-forwarding.md new file mode 100644 index 0000000000..ba0711c57a --- /dev/null +++ b/docs/rfcs/2025-08-11-channels-forwarding.md @@ -0,0 +1,237 @@ +# Channels forwarding + +This expands on the previous [channels rfc](./2025-07-30-channels.md), specifically on message forwarding by chat relays. + +## Problem + +Current implementation of groups uses forwarding mechanism for improved message delivery between connecting members. From perspective of reusing it for channels it has following limitations: +- Messages are forwarded only for the duration of members establishing connection, until they report x.grp.mem.con to forwarding admin. +- For a given pair of members only a single admin forwards messages - inviting admin (host) forwards messages between its invitees and members introduced to them. It means: + - This admin can be a single point of failure and/or can cause arbitrary delays in message delivery based on its availability when forwarding messages between these member pairs. + - Member pairs fully trust their single forwarding admin in forwarded content. + +Not limitations of protocol, but other weak points of current implementation: +- Forward operations are synchronous to message reception (by forwarding admin), so they're not resumable on failure. +- Forward operations are carried out as "group sends" to all required members (invitees/introduced members in respect to sending member), number of which is expected to grow very large in channels and can potentially have a big load on the database. + +## Solution + +Chat relays will serve as forwarding agents instead of inviting admins, with following modifications to protocol and implementation. + +### Forwarding not limited to establishing connection + +- In channels members will not connect directly to other members and channel owners. +- Chat relays will not take into consideration status of introductions. +- Chat relays will forward messages between owners and all members (from members to owners, for example, for reactions, comments). + +### All chat relays forward all messages + +- Instead of inviting admins all chat relays will forward all messages. +- Members will deduplicate messages (already implemented). +- Members should highlight differences in deduplicated messages. This would solve trust issue. + - Question: If messages are to be signed by owners, what differences can chat relays introduce? Does this point in initial doc imply owner sending different versions of message to chat relays? Losses in delivery? Answer: Not all messages will be signed. + +#### Highlighting deduplicated messages differences + +Currently we simply ignore duplicate messages - see createNewRcvMessage. + +Some options on how to highlight difference in messages from chat relays: +1. Show only the fact that there is difference. + - Persist duplicate error (SEDuplicateGroupMessage) as flag on chat item to then display warning sign in UI. +2. Save message hashes as some additional entity linked to message or chat item for each chat relay. + - If any new hash differs, set flag on chat item for warning sign in UI. + - Add ability to load additional info (with other chat item info via APIGetChatItemInfo) showing which relays sent different hashes (e.g. 1 differs from 2 others - show 1 as warning, all differ - show all as warning). +3. As previous, but save content. Possibly save content additionally to hashes, for faster comparison. + - Saving content means converting duplicate messages to chat item content, or only saving text, but latter will fail to show difference in files. +4. Create new versions as "different version of another message" chat items. + - Timestamp would have to be made the same as on original chat item, as it's not guaranteed to be the same - each chat relay will forward a broker ts based on its own metadata of receiving message from sender. + - It could be done via special replies, to re-use reply machinery. However, this won't work for messages being replies themselves then. + - Alternatively, it could say "different message from X relay". + +Service events will not have content. We still can/should compare them and indicate difference, e.g. by creating a special chat item (similar to integrity violation). + +How to compute hashes. Problem is with file descriptions, as it is legitimate case that they will be different. Hash could be computed as hash of message, excluding file description from FileInvitation. File digest and names should be the same, the problem of different names needs to be fixed. + +Files can also cause problem with showing difference in content if file preview is the same, but file hash is different. So showing difference in content does not exclude necessity of showing difference in hashes as in option 2. + +As a note, this whole section discusses an edge case of chat relays maliciously changing messages, and countermeasures to prevent them doing so undetectably. May be not worth implementing overly complex solution as MVP (options 3, 4). + +For MVP from UI standpoint this should suffice: a marker on item, that would show in info which relays delivered it with a sign which relay delivered different hash. Possibly also special event item next to marked item (with same timestamp) - for service events that don't create a chat item, or in case we don't account it for some chat items. + +### Forwarding jobs + +- Received messages will be marked for forwarding and picked up by a forwarding worker to form forwarding jobs. +- Forwarding job can be split into smaller batches by members to make smaller group sends one at a time. + +**How forwarding works now:** + +Currently we build ad-hoc forwarding instruction for each message based on its scope (GroupForwardScope). The output of processing a batch of messages is `Map GroupForwardScope (NonEmpty (ChatMessage 'Json))`. Then for each scope message batches are sent separately to different member lists (to clarify, "sent" here means scheduled for sending in agent). All this is done synchronously in receive loop. + +**How forwarding will work:** + +Chat relay should be able to handle channels consisting of hundreds of thousands of members. Synchronous forwarding will be replaced by asynchronous forwarding jobs to reduce load in the receive loop, and make forwarding resumable on failure. Also loading all members in memory will not scale, so these jobs have to be split into smaller batches. + +Question: Should synchronous processing be changed to forwarding jobs for all types of scopes, or only for Main (GFSMain - for regular messages) and All (GFSAll - for all current members and for all pending members scopes), and not for Support scope (GFSMemberSupport)? + - Pros/cons of persisting jobs for all types of scopes: + - Possibly more uniform processing. However, for support scope group member id is required. Either encode scope as json or add field for scope group member id for persistence (see schema below). + - Requires forwarding worker to have more logic ("if"), or different worker types. + - Pros/cons of persisting jobs only for Main and All scopes: + - Forwarding worker and persistence are simpler, but logic lives in 2 places (synchronous as now + worker). + - As a note, support scopes have small number of members to forward to, so optimizing forward for them is not a necessity. + +Answer: All forwarding logic will become asynchronous. This will reduce load in the receive loop for inviting admins in regular groups as well. + +Forwarding jobs for different groups can be concurrent, inside group should be sequential to follow order of messages. One approach could be to create a dedicated forwarding worker for each group. Different scope types can also use dedicated workers (possibly: 1 worker for all + main scopes, 1 - for all support scopes), so heavy full group forwarding wouldn't slow down forwarding in much smaller support scopes. + +Forwarding workers will re-use worker abstraction from agent. + +Forwarding workers should use low priority db pool. + +We roughly need following data for a forwarding job: +- group id, +- forwarding scope (forward_scope), +- sending member - to include memberId in XGrpMsgForward, +- broker timestamp of received messages batch to include in XGrpMsgForward, +- "message from channel" flag from sending owner - see "Hiding sending owner id" section below (group_as_sender), +- list of messages (events). + +**How to build message batches for a forwarding job:** + +Currently for each message a full MsgBody is saved on `messages` table record, which is just a ByteString. Also if received messages were batched, full batch body is saved for each message record (it's a questionable design). For example, here is message body of batched deletion of 2 chat items, which is saved on 2 message records: + +``` +msg_body = [{"v":"1-16","msgId":"ZUd3bzVDTXFHWmc3Z29YSQ==","event":"x.msg.del","params":{"msgId":"QVVuN2RkaXg0aVJJdTZXSA=="}},{"v":"1-16","msgId":"amM5dzV2RkRjNmNYSDRLQQ==","event":"x.msg.del","params":{"msgId":"NzFlV3pNRStpQTRrQjRYUg=="}}] +``` + +On the other hand for forwarding operation we require ChatMessage 'Json (for XGrpMsgForward), which could be saved on message records instead. + +As a side note, it seems we can stop saving full batch body as msg_body for received messages, as it's not used for any purpose. This field is only used for retrieving and sending pending group messages (sent, not received). + +Alternatively, we could read full msg_body, and repeatedly parse list of ChatMessage 'Json. However, this conflicts with possibility that a single batch body will have messages of different scopes, which current processing logic allows, although there's no legitimate case as of now. This means we'd have make jobs forward to different lists of members, or have some supervisor to launch different jobs, which further complicates the processing. Also, this limits forwarding to the same batch sizes (in terms of number of messages) as those batches that received messages came in, and instead we could possibly fit more messages into batches if available. Overall, persisting ChatMessage' Json on each message record, and then retrieving available messages for the job, seems preferable. + +**How to split forwarding jobs into smaller delivery batches:** + +For simplicity, this will be done only for channels, and not for inviting admins in regular groups, at least initially. Inviting admins have more complex logic, involving multiple queries and filters to retrieve less (only necessary) members, as we're trying to limit load on them, as they can be mobile clients. They will be moved to asynchronous processing too, though. + +Forwarding job will read required members in loop using a cursor (e.g., use group_member_id as a cursor) to split into further delivery jobs (group sends) by members. As a reminder, group send in terms of chat logic is not a network operation, but a request to agent. We can consider this part a black box for now, and consider if groups sends should be made concurrently later. For now though, for simplicity we can consider that group sends are done in a synchronous loop with moving cursor for member retrieval. Cursor position can be remembered on the job record after each group send, for recovery. + +It's unclear if there's a need for a separate lower level abstraction for delivery jobs, that could be reused for feeds and other purposes, as they would require different logic of building delivery lists, and scheduling a delivery for agent already serves a similar purpose. For now, it seems this additional abstraction is unnecessary. + +**How a forwarding job will work overall:** + +0. There is some process on start that launches necessary workers for each group/scope client serves as a forwarding agent (for those that have messages that need forwarding). Also message receive loop "kicks" necessary workers. Below we start with a worker for some group/scope trying to retrieve next work item, that is being next message(s) to forward. +1. Worker retrieves next message from `messages` table that was marked for forwarding, that matches the worker's group/scope. + - We may need a separate field on message record to track "forward pending" state, to know what to forward. Setting forward_scope as a task with addition of forward_complete flag to mark forward completion may be enough (see schema below). +2. Worker checks, if this message is already attached to some forwarding job, and the state of the job. + 1. Worker gets job record. + - If message is not attached to any job yet, worker creates a new job record, sets job id on the message record (normal execution path). + - Otherwise worker gets existing job attached to the message (recovery path). + 2. Worker determines forwarding batch encoding for the job. + - If encoding is not saved on the job, it means it wasn't determined before (normal execution): + 1. Worker retrieves more messages that match its group/scope. + - Could be in loop or in bulk. + - We won't know how much we'd need. Some heuristic could be read 100, then mark those that are used with jobId, or load more if needed. + - Also message list has to preserve reception order. + 2. Worker prepares encoding (wraps messages in XGrpMsgForward events with metadata) and saves it on the job record. + - It's important to put encoding into job and mark all relevant messages in one transaction. + - Otherwise worker uses saved encoding (recovery). +3. With moving cursor on group member id for member retrieval, forward encoded batch. In loop: + 1. Retrieve members up to some limit and filtering according to a cursor. In normal execution path on first iteration job would not have a previously saved cursor yet. + - For Main scope all current members will be retrieved. + - For Support scopes - moderators and above + scope member. For support scope job can be done without a cursor, instead simply loading all its members and forwarding. + - For All scope - all current and pending members (In channels there is no need for member review. So possibly we don't have to account for All scope). + - Main difference from forwarding in regular groups here is that for inviting admins we retrieve only introduced and invited members for the message's sending member, that are not yet connected. For channels there will be no such filtering. + 2. Group send encoded batch to retrieved members. + 3. Update cursor on the job. +4. Marks all messages attached to the job as forwarded (forward_complete), deletes the job record. + - Possibly do in cleanup manager and delete after say a week. + +Schema draft: + +```sql +CREATE TABLE forwarding_jobs ( + forwarding_job_id INTEGER PRIMARY KEY, + msg_batch_encoding TEXT, + cursor_group_member_id INTEGER +) + +ALTER TABLE messages ADD COLUMN chat_message_json TEXT; +ALTER TABLE messages ADD COLUMN forward_scope TEXT; +ALTER TABLE messages ADD COLUMN group_as_sender INTEGER NOT NULL DEFAULT 0; -- for "message from channel" flag from owner +ALTER TABLE messages ADD COLUMN forward_complete INTEGER NOT NULL DEFAULT 0; +ALTER TABLE messages ADD COLUMN forwarding_job_id INTEGER REFERENCES forwarding_jobs ON DELETE SET NULL; + +-- indexes for fkey, search based on group/scope and order; +``` + +## Other considerations + +### Hiding sending owner id + +Initial doc mentions "messages sent from channel name" in minimal testable scope. This would be a feature allowing owners to send messages to channel without it being clear for members which owner sent it. For this, we'd also like to hide sending owner's member id from forwarding operation. + +This means MemberId in XGrpMsgForward should become optional. + +```haskell +XGrpMsgForward :: Maybe MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json +``` + +Receiving members would then save this message as received from group. We already have necessary machinery for chat items persistence - see ShowGroupAsSender. However, message processing logic would have to be reworked to allow for absence of group member. Also, sending owner would have to indicate it to chat relays in XMsgNew (TBC - other events?). + +```haskell +XMsgNew :: ShowGroupAsSender -> MsgContainer -> ChatMsgEvent 'Json +``` + +ShowGroupAsSender can be in MsgContainer, not separate. + +Chat relays should not hide sending owner from other owners: sending owner should be visible to them, but message shown as from channel. This means forwarding job has to be split into separate sends with different sets of XGrpMsgForward events (with and without sending owner's member id). + +### Connection deleting events + +Forwarding regular messages, reactions, updates and many other events is straightforward. However, naive processing of some events currently breaks forwarding logic, specifically member and group deletion. + +As it is now, forwarding operation follows event processing. So, processing of XGrpMemDel, XGrpDel first deletes member connection (or connections with all members in case of group deletion), then attempts to forward these events, which in reality never works as at this point connection is already deleted. + +Currently we ignore this problem (there are TODOs), as all members according to protocol connect with each other, and forwarding serves only as message delivery improvement/backup, and not a main route, and so members in at least supposedly most cases receive these events from the original sender. + +With chat relays, however, no messages are sent directly from owners to members, so if logic is kept as is these events would never be received. So, their processing should be special cased to delay until after forwarding operation. + +## Further considerations, ideas (Update) + +- Sending member profiles. + - Profiles of owners (and admins, moderators) should be sent to all members on joining. + - Profiles of other members should be sent on interaction with them. + - For MVP: we can show counts on reactions and avoid solving it, but we should have a design to solve this problem, as it would be necessary for comments and later for large groups. + - Solution draft - partition members based on join timestamp and sender last interaction time: + - Track last interaction timestamp on each member. + - When forwarding from this member partition members into two parts: include profile for those who joined after interaction, don't include for those who joined before interaction. + - This suggests that job would be divided into two parts. + - Protocol to request member profile as a fallback. + - This becomes more complex in case batch has multiple members - for n required profiles to send, recipients need to be partitioned into n + 1 parts. + - Better solution - schedule profile deliveries separately from batch on first post-join delivery per sender. + - Track last_profile_delivery_ts (for sender), join_ts (for recipient) on member records. + - On the sender's first overall interaction (last_profile_delivery_ts is null), first create a special task to deliver profile to all (in scope All). + - On following sends on batching, for senders whose last_profile_delivery_ts < any member's join_ts (i.e., some member missed the initial broadcast), create a profile delivery task for those specific senders. + - Message task should have a flag whether profile should be delivered to anyone (set to false on first profile delivery). Checking last_profile_delivery_ts is null seems to be sufficient. +- Don't partition for owners based on "message from channel" flag to simplify delivery - no need for two separate jobs/cursors. +- When chat relay receives group deletion event, or event removing it [chat relay itself] from the group: + - Chat relay should kill all forwarding workers for the group -> delete all jobs -> create one new job to forward group deletion. + - It could be a special type of job. +- Sending each reaction (and in future comment) won't scale well. + - Instead periodically send reaction and comment counts. + - Send reactions and comments themselves on request. + - This implies that instead of message records, special entity for forwarding tasks should be added, for worker to search for next work items - see more below. +- Connections with priority. + - Client could have 2 connections/queues with relay, and relay - 2 subscribers. + - Separation of responsibilities between connections/queues: + - Normal queue to be used for regular forwarding of messages, reactions, etc. + - High-priority queue to be used for serving client requests and sending important service events (e.g. ownership changes, group deletion). + - Possibly special case of connection redundancy. +- Design to be reworked to use special entity for forwarding tasks instead of relying on messages. + - Points for this: + - Batched reactions/comments counts. + - Special logic on group deletion/relay removal. + - Possibly special logic on sending member profiles, as it's not needed for all types of jobs. + - Sum type of task types. + - Some tasks may simply point to message records. + - Some tasks may be created for further updating their metadata / scheduling, e.g. "send reactions/comments count update", and the information itself may be taken from chat items. diff --git a/docs/rfcs/2025-10-23-vouchers.md b/docs/rfcs/2025-10-23-vouchers.md new file mode 100644 index 0000000000..9e11827282 --- /dev/null +++ b/docs/rfcs/2025-10-23-vouchers.md @@ -0,0 +1,208 @@ +# SimpleX Vouchers for Unlinkable Payments + +See [this doc](./2024-04-26-commercial-model.md) about commercial model that proposed the approach to making network sustainable and commercially attractive to the server operators. + +This document proposes the cryptographic design for the system of vouchers that can enable these payments. + +Big thank you to [Alain Brenzikofer](https://x.com/brenzi5), co-founder of [Integritee Network](https://x.com/integri_t_e_e), who contributed the draft of this design, which we then evolved collaboratively. + +## High-level diagram + +![Payments diagram](./diagrams/2025-10-23-vouchers-diagram.svg) + +### Coordination Layer (CL) + +Abstract component which allows all involved parties to come to consensus about voucher issuance and redemption + +* can be centralized trusted third party (TTP). +* can be a decentralized ledger with smart contracts, e.g. some L2 Ethereum blockchain with ZK-proofs support. + +### Issuing Operator (IO) + +* must be whitelisted by CL. +* CL defines voucher issuing limit. + +### Accepting Operator (AO) + +* delivers a service and accepts vouchers. + +### User + +* uses a service by an AO. +* seeks anonymity. + +### Voucher + +* token allowing limited number of transfers (0-2) to be redeemed for AO credits. +* comes in few fixed denominations around e.g. 1, 10, 100 operator credits, that would be initially set to USD 1, and adjusted for service costs that are likely to be reduced with scale and inflation. +* expected to be redeemed at low frequency: only every few days per user. + +### AO Credits + +* per-operator tokens for micropayments as-you-go. +* expected to be used to pay fractions of cents for every request to the service. +* balances maintained by the operator. + +Blind signatures to be used with operator issued credits: +- Client generates random token(s): `t[i]` +- Client sends a set of blinded tokens `blind(t[i])` when presenting a voucher. +- Operator's server signs them with operator's key and returns to the client. +- Client de-blinds them so they can be used. + +The signed tokens should include an approximate timestamp, e.g. rounded to a day (or more) - this would allow expiration of credits at the cost of acceptable reduction of anonymity set. + +These tokens would be fungible and would also have multiple denominations - the client would send new random blinded numbers to receive change on the resource provisioning requests. We can use token denominations representing powers of 2. + +When credit is presented it would be validated to prove that it is: + +1) properly signed. +2) not expired. +3) not used. + +The checks 1 and 2 are local, and can be done locally on the server. The check 3 requires verification across all operator's servers. The resource can be provisioned instantly, without waiting for the confirmation. Failed double-spend verification can result in resource cancellation. The "change" can be provided only after verification, as otherwise it may increase the number of issued credits (the provisioned resource can include "pending change" associated with it). + +Another approach would be allocating the registry for spent coins deterministically to different servers, and making these allocations known to the client, so while coins would be accepted by any operator's server, the change would be given faster if it's presented to the server with the coin registry. + +## Abstract Protocol + +Start with the most simple approach, then iterate to improve the anonymity properties. + +### v0.1: Chaumian eCash-style atomic, indivisible vouchers of single denomination + +not yet using ZK, not yet with expiry (see extension) + +``` +# user buys voucher at t1 +s = random(256 bits) +C = hash(S) +B = blind(C) +CoordinationLayer.checkIssuingLimit(issuer=I1) +App(issuer=I1).buyVoucher(ref=B) +# issuer I1 +ensure_payment() +σB = B.sign(K_I1) +# user publishes voucher at t2 +σC = σB.unblind() +CoordinationLayer.publish(σC) +# CoordinationLayer (global, trusted entity) +issuer = verify_signature(σC) +ensure_issuing_limit(issuer) +ensure_is_unknown(C) +store_unspent_voucher(C, issuer=I1) +# user redeems voucher at t3 +proof=encrypt(payload=[C, s], pubkey=CoordinationLayerKey) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +[C, s] = decrypt(proof) +ensure(C=hash(s)) +atomic_invalidate_unspent_voucher(C) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +### Unlinkability analysis + +* issuer can’t link the purchase to later redemption, not even if colluding with the ServiceProvider (assuming large number of users behaving indistinguishably). +* CoordinationLayer can trivially link timing and IP of publishing (t2) and redeeming C (t3). could collude with issuer to link redemption to purchase correlating timing and IP: + * the user can mask timing with random delays between t1-t2 to make collusion harder. + * the user can hide their IP from the CL if they use the issuer as a proxy through a TLS tunnel. That, in turn, will leak t2 to the issuer unless the user performs indistinguishable dummy requests to mask t2. + +### Adding Voucher Expiry + +Design choices for maximal anonymity set / unlinkability: + +* expiry is the same for all vouchers. +* expiry starts with the publishing step, not with the purchase. + +Extension of v0.1: + +* CoordinationLayer stores publishing date along with C. +* CoordinationLayer enforces expiry upon redemption. +* CoordinationLayer ensures issuers rotate keys every M days (to invalidate vouchers which have been issued but not published within 2xM days). + +*Alternative to allow expiry to start with purchase: blind signature with public metadata. Not trivial if issuer must verify public metadata and bind signature to ensure correctness of expiry*. + +## v0.2: Chaumian eCash-style atomic, Indivisible vouchers of single denomination plus ZK + +Avoid linkability of redemption by using a ZK set membership proof into merkle-mountain range (MMR). + +Change later steps as follows: + + +``` +... same as v0.1 +# CoordinationLayer (global) at ~t2 +... same as in v0.1, adding: +store_unspent_voucher(C, t=now, issuer=I1) +update_unspent_vouchers_mmr() +publish_mmr_root() +return [mmr_path] # to user +# user redeems the voucher at t3 +mmr_root = root of mmr_path # as received from CL upon publishing +N=hash2(s || "redeem") +proof=ZK( + secret_inputs: s, mmr_path + public_inputs: mmr_root, nullifier: N + assertions: hash(s) is leaf of mmr_path with mmr_root && N=hash2(s || "redeem") +) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +ensure_unknown(proof.N) +ensure(age(proof.mmr_root) < EXPIRY) +verify(proof) +store_nullifier(proof.N) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +(!) If the MMR is public (e.g. if the CL operating on a public ledger), the user can extend voucher expiry arbitrarily by updating their mmr_path to a newer merkle_root. Therefore, expiry can’t rely just on the age of mmr_root. For a mitigation, we need to extend the protocol and rotate MMRs. + +### Adding MMR Rotation + +1. Start a new MMR every T days +2. To mitigate the small anonymity set at the start of each new MMR, let them overlap and let the user choose which one they use. + +![MMR rotation](./diagrams/2025-10-23-vouchers-mmrs.svg) + +Upon publishing: + +* CL returns mmr1_path and mmr_2 path to the user + +Upon redemption: + +* user selects one of the two MMRs to generate the proof. Here, the user can trade off later expiry (mmr2_path, expiry2) against larger anonymity set (mmr1_path, expiry1). + +### Unlinkability Analysis + +* Generating a proof using mmr_root(t2) leaks t2. The CL could therefore still learn the exact time when the redeemed voucher was published + + * this can be mitigated by updated MMR peak-bagging before generating the proof. The user downloads the entire MMR and updates the mmr_path to a later root at e.g. t2' or t2'' (maybe partial download backward to t2 + a masking random bit further back is sufficient). If download size gets too big, reduce MMR duration T. + +* thanks to the ZK proof, now even the CoordinationLayer can’t directly link the publishing of C with the redemption, because the redemption just discloses that “one among all non-expired vouchers shall be redeemed“ (double-spending prevented through tracking nullifiers). +* the Coordination Layer still observes timing and IP address. + * users can wait until anonymity set is big enough for their requirements, but that only masks timing, not networking IP address. + * If we use the ServiceProvider as a proxy to forward the redemption proof, timing and IP leak to SP instead of CL, which is better because the SP learns the IP and timing (user behavior) anyway. trusting the SP with the proof is fine because it doesn’t disclose sensitive information and we trust them to provide their service after redemption anyway. + +### ZK Reasonings + +* to avoid trusted setup we could use STARK, not SNARK, but STARK has heavier proving complexity (expect >30s on mobile. should be evaluated with a PoC). +* we can accept a trusted setup with multiple independent parties contributing to it, with the benefit of much lighter proving. +* STARK friendly hash function: e.g. poseidon2 +* proving time (client-side) is probably still quite heavy for mobile, even if the proposed proof is pretty lean. But redeeming vouchers is only expected to happen infrequently +* verification time (CoordinationLayer side) expected to be light +* Nullifier set is bounded thanks to voucher expiry window M, so it won’t grow indefinitely. Downside: smaller anonymity set. + +Overall, SNARK seems more preferrable. + +### Possible Enhancements + +* Avoid centralized CoordinationLayer SPOF, replace with smart contract on distributed consortial ledger with non-collusion contractor validators: + * or even public permissionless blockchain. + * storing mmr_root and nullifiers onchain helps public auditability. +* publishing σC still leaks publicly observable timing because the CL has to update and publish the MMR. + * possible remedy: use TEE as a random-delay mixer proxy for the user to publish σC. +* optionally delegate heavy ZK proving to TEE for thin clients (s will be exposed to TEE trust assumptions). But then, we need to incentivize TEE-provers as they are service providers in their own right. diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg new file mode 100644 index 0000000000..b182521563 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg @@ -0,0 +1,4 @@ + + + +
Alice (identifiable)
buy voucher
Issuing Operator
Coordination Layer
issue voucher
enforce issuance limit
register voucher
unlink
Accepting Operator
Alice (incognito)
Coordination Layer
redeem voucher with proof
verify proof
issue credits
enforce expiry
prevent double-spend
clearing of voucher value IO->AO
use service
provide service
manage credit balance
transfer credits to Bob*
few $, non-xferrable
fractions of cts, xferrable within same AO
not involved
1
2
3
4
5
6
\ No newline at end of file diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg new file mode 100644 index 0000000000..b1ea917340 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg @@ -0,0 +1,4 @@ + + + +
MMR1
MMR2
MMR3
C1
C2
CN
validity
t2
t
t2'
t2''
CA
expiry1
expiry2
\ No newline at end of file diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 2ea67432f5..ea49478b15 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -244,7 +244,7 @@ export namespace CR { export interface GroupsList extends Interface { type: "groupsList" user: T.User - groups: T.GroupInfoSummary[] + groups: T.GroupInfo[] } export interface Invitation extends Interface { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 3a056293c5..b8095affdf 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -65,6 +65,7 @@ export type AgentErrorType = | AgentErrorType.RCP | AgentErrorType.BROKER | AgentErrorType.AGENT + | AgentErrorType.NOTICE | AgentErrorType.INTERNAL | AgentErrorType.CRITICAL | AgentErrorType.INACTIVE @@ -82,6 +83,7 @@ export namespace AgentErrorType { | "RCP" | "BROKER" | "AGENT" + | "NOTICE" | "INTERNAL" | "CRITICAL" | "INACTIVE" @@ -152,6 +154,13 @@ export namespace AgentErrorType { agentErr: SMPAgentError } + export interface NOTICE extends Interface { + type: "NOTICE" + server: string + preset: boolean + expiresAt?: string // ISO-8601 timestamp + } + export interface INTERNAL extends Interface { type: "INTERNAL" internalErr: string @@ -174,6 +183,7 @@ export interface AutoAccept { export interface BlockingInfo { reason: BlockingReason + notice?: ClientNotice } export enum BlockingReason { @@ -941,6 +951,7 @@ export namespace ChatError { export interface ErrorAgent extends Interface { type: "errorAgent" agentError: AgentErrorType + agentConnId: string connectionEntity_?: ConnectionEntity } @@ -997,7 +1008,6 @@ export type ChatErrorType = | ChatErrorType.FileCancelled | ChatErrorType.FileCancel | ChatErrorType.FileAlreadyExists - | ChatErrorType.FileRead | ChatErrorType.FileWrite | ChatErrorType.FileSend | ChatErrorType.FileRcvChunk @@ -1074,7 +1084,6 @@ export namespace ChatErrorType { | "fileCancelled" | "fileCancel" | "fileAlreadyExists" - | "fileRead" | "fileWrite" | "fileSend" | "fileRcvChunk" @@ -1330,12 +1339,6 @@ export namespace ChatErrorType { filePath: string } - export interface FileRead extends Interface { - type: "fileRead" - filePath: string - message: string - } - export interface FileWrite extends Interface { type: "fileWrite" filePath: string @@ -1594,6 +1597,10 @@ export enum ChatWallpaperScale { Repeat = "repeat", } +export interface ClientNotice { + ttl?: number // int64 +} + export enum Color { Black = "black", Red = "red", @@ -1734,17 +1741,10 @@ export interface Connection { export type ConnectionEntity = | ConnectionEntity.RcvDirectMsgConnection | ConnectionEntity.RcvGroupMsgConnection - | ConnectionEntity.SndFileConnection - | ConnectionEntity.RcvFileConnection | ConnectionEntity.UserContactConnection export namespace ConnectionEntity { - export type Tag = - | "rcvDirectMsgConnection" - | "rcvGroupMsgConnection" - | "sndFileConnection" - | "rcvFileConnection" - | "userContactConnection" + export type Tag = "rcvDirectMsgConnection" | "rcvGroupMsgConnection" | "userContactConnection" interface Interface { type: Tag @@ -1763,18 +1763,6 @@ export namespace ConnectionEntity { groupMember: GroupMember } - export interface SndFileConnection extends Interface { - type: "sndFileConnection" - entityConnection: Connection - sndFileTransfer: SndFileTransfer - } - - export interface RcvFileConnection extends Interface { - type: "rcvFileConnection" - entityConnection: Connection - rcvFileTransfer: RcvFileTransfer - } - export interface UserContactConnection extends Interface { type: "userContactConnection" entityConnection: Connection @@ -1861,7 +1849,6 @@ export interface Contact { localDisplayName: string profile: LocalProfile activeConn?: Connection - viaGroup?: number // int64 contactUsed: boolean contactStatus: ContactStatus chatSettings: ChatSettings @@ -2427,6 +2414,7 @@ export enum GroupFeatureEnabled { export interface GroupInfo { groupId: number // int64 + useRelays: boolean localDisplayName: string groupProfile: GroupProfile localAlias: string @@ -2443,15 +2431,11 @@ export interface GroupInfo { chatItemTTL?: number // int64 uiThemes?: UIThemeEntityOverrides customData?: object + groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string } -export interface GroupInfoSummary { - groupInfo: GroupInfo - groupSummary: GroupSummary -} - export interface GroupLink { userContactLinkId: number // int64 connLinkContact: CreatedConnLink @@ -2603,7 +2587,7 @@ export interface GroupShortLinkData { } export interface GroupSummary { - currentMembers: number // int + currentMembers: number // int64 } export interface GroupSupportChat { @@ -3277,12 +3261,6 @@ export interface RcvFileDescr { fileDescrComplete: boolean } -export interface RcvFileInfo { - filePath: string - connId?: number // int64 - agentConnId?: string -} - export type RcvFileStatus = | RcvFileStatus.New | RcvFileStatus.Accepted @@ -3303,22 +3281,22 @@ export namespace RcvFileStatus { export interface Accepted extends Interface { type: "accepted" - fileInfo: RcvFileInfo + filePath: string } export interface Connected extends Interface { type: "connected" - fileInfo: RcvFileInfo + filePath: string } export interface Complete extends Interface { type: "complete" - fileInfo: RcvFileInfo + filePath: string } export interface Cancelled extends Interface { type: "cancelled" - fileInfo_?: RcvFileInfo + filePath_?: string } } @@ -3812,6 +3790,11 @@ export type StoreError = | StoreError.UsageConditionsNotFound | StoreError.InvalidQuote | StoreError.InvalidMention + | StoreError.InvalidDeliveryTask + | StoreError.DeliveryTaskNotFound + | StoreError.InvalidDeliveryJob + | StoreError.DeliveryJobNotFound + | StoreError.WorkItemError export namespace StoreError { export type Tag = @@ -3892,6 +3875,11 @@ export namespace StoreError { | "usageConditionsNotFound" | "invalidQuote" | "invalidMention" + | "invalidDeliveryTask" + | "deliveryTaskNotFound" + | "invalidDeliveryJob" + | "deliveryJobNotFound" + | "workItemError" interface Interface { type: Tag @@ -4269,6 +4257,31 @@ export namespace StoreError { export interface InvalidMention extends Interface { type: "invalidMention" } + + export interface InvalidDeliveryTask extends Interface { + type: "invalidDeliveryTask" + taskId: number // int64 + } + + export interface DeliveryTaskNotFound extends Interface { + type: "deliveryTaskNotFound" + taskId: number // int64 + } + + export interface InvalidDeliveryJob extends Interface { + type: "invalidDeliveryJob" + jobId: number // int64 + } + + export interface DeliveryJobNotFound extends Interface { + type: "deliveryJobNotFound" + jobId: number // int64 + } + + export interface WorkItemError extends Interface { + type: "workItemError" + errContext: string + } } export enum SwitchPhase { diff --git a/packages/simplex-chat-client/typescript/tests/client.test.ts b/packages/simplex-chat-client/typescript/tests/client.test.ts index 77dca8fa5a..9cd0a365f1 100644 --- a/packages/simplex-chat-client/typescript/tests/client.test.ts +++ b/packages/simplex-chat-client/typescript/tests/client.test.ts @@ -8,8 +8,7 @@ import {ChatEvent, CEvt, T} from "@simplex-chat/types" describe.skip("ChatClient (expects SimpleX Chat server with a user, without contacts, on localhost:5225)", () => { test("connect, send message to themselves, delete contact", async () => { const c = await ChatClient.create("ws://localhost:5225") - assert.strictEqual((await c.msgQ.dequeue()).type, "contactSubSummary") - assert.strictEqual((await c.msgQ.dequeue()).type, "userContactSubSummary") + assert.strictEqual((await c.msgQ.dequeue()).type, "connSubSummary") assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") const user = await c.apiGetActiveUser() diff --git a/scripts/db/README.md b/scripts/db/README.md index 68d4f74ac7..c204e036c4 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -1,96 +1,238 @@ -# Transfer data from SQLite to Postgres database +## Transfer data from SQLite to Postgres database -1. \* Decrypt SQLite database if it is encrypted. +1. Decrypt SQLite database if it is encrypted. - ```sh - sqlcipher encrypted_simplex_v1_agent.db - ``` + 1. Agent: - ```sql - PRAGMA key = 'password'; - ATTACH DATABASE 'simplex_v1_agent.db' AS plaintext KEY ''; - SELECT sqlcipher_export('plaintext'); - DETACH DATABASE plaintext; - ``` + 1. Open sqlite db: - Repeat for `simplex_v1_chat.db`. + ```sh + sqlcipher simplex_v1_agent.db + ``` + + 2. Run in sqlcipher: + + ```sql + PRAGMA key = ''; -- Set your db password + SELECT count(*) FROM sqlite_master; -- Check if db was successfully decrypted + ATTACH DATABASE 'simplex_v1_agent_plaintext.db' AS plaintext KEY ''; -- Attach new empty db + SELECT sqlcipher_export('plaintext'); -- Export opened db to attached db as plaintext + DETACH DATABASE plaintext; + ``` + + 2. Chat: + + 1. Open sqlite db: + + ```sh + sqlcipher simplex_v1_chat.db + ``` + + 2. Run in sqlcipher: + + ```sql + PRAGMA key = ''; + SELECT count(*) FROM sqlite_master; + ATTACH DATABASE 'simplex_v1_chat_plaintext.db' AS plaintext KEY ''; + SELECT sqlcipher_export('plaintext'); + DETACH DATABASE plaintext; + ``` 2. Prepare Postgres database. - - Create Postgres database. In shell: + 1. Connect to PostgreSQL databse: ```sh - createdb -O simplex simplex_v1 + psql -U postgres -h localhost ``` - Or via query. + 2. Run in psql: - - Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new chat database. + ```sql + CREATE USER simplex WITH ENCRYPTED PASSWORD '123123'; -- Create user with password + -- or + -- CREATE USER simplex; + CREATE DATABASE simplex_v1; -- Create database + GRANT ALL PRIVILEGES ON DATABASE simplex_v1 TO simplex; -- Assign permissions + ``` - This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. +3. Prepare database: + + You should build the CLI binary from the same `TAG` as the desktop. + + 1. Build CLI with PostgreSQL support: + + ```sh + cabal build -fclient_postgres exe:simplex-chat + ``` + + And rename it to: + + ```sh + mv simplex-chat simplex-chat-pg + ``` + + 2. Execute CLI: + + ```sh + ./simplex-chat-pg -d "postgresql://simplex:123123@localhost:5432/simplex_v1" --create-schema + ``` + + Press `Ctrl+C` when CLI ask for a display name. + + This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. 3. Load data from decrypted SQLite databases to Postgres database via pgloader. Install pgloader and add it to PATH. Run in shell (substitute paths): ```sh - SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load + export POSTGRES_CONN='postgresql://simplex:123123@localhost:5432/simplex_v1' + ``` - SQLITE_DBPATH='simplex_v1_chat.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_chat_schema' pgloader --on-error-stop sqlite.load + And then: + + ```sh + SQLITE_DBPATH='simplex_v1_agent_plaintext.db' \ + POSTGRES_SCHEMA='simplex_v1_agent_schema' \ + CPU_CORES=$(nproc) WORKERS=$((CPU_CORES - 1)) pgloader --dynamic-space-size 262144 --on-error-stop sqlite.load + + SQLITE_DBPATH='simplex_v1_chat_plaintext.db' \ + POSTGRES_SCHEMA='simplex_v1_chat_schema' \ + CPU_CORES=$(nproc) WORKERS=$((CPU_CORES - 1)) pgloader --dynamic-space-size 262144 --on-error-stop sqlite.load ``` 4. Update sequences for Postgres tables. - ```sql - DO $$ - DECLARE - rec RECORD; - BEGIN - EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; + Connect to db: - FOR rec IN - SELECT - table_name, - column_name, - pg_get_serial_sequence(table_name, column_name) AS seq_name - FROM - information_schema.columns - WHERE - table_schema = 'simplex_v1_agent_schema' - AND identity_generation = 'ALWAYS' - LOOP - EXECUTE format( - 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', - rec.seq_name, rec.column_name, rec.table_name - ); - END LOOP; - END $$; + ```sh + PGPASSWORD=123123 psql -h localhost -U simplex -d simplex_v1 ``` - Repeat for `simplex_v1_chat_schema`. + Execute the following: -5. \* Compare number of rows between Postgres and SQLite tables. + 1. For `agent`: - To check number of rows for all tables in Postgres database schema run: + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; - ```sql - WITH tbl AS ( - SELECT table_schema, table_name - FROM information_schema.Tables - WHERE table_name NOT LIKE 'pg_%' - AND table_schema IN ('simplex_v1_agent_schema') - ) - SELECT - table_schema AS schema_name, - table_name, - (xpath('/row/c/text()', query_to_xml( - format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' - )))[1]::text::int AS records_count - FROM tbl - ORDER BY records_count DESC; - ``` + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_agent_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` - Repeat for `simplex_v1_chat_schema`. + 2. For `chat`: + + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_chat_schema'; + + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_chat_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` + +5. Compare number of rows between Postgres and SQLite tables. + + **PostgreSQL**: + + 1. For `agent`: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_agent_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + 2. For `chat`: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_chat_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + **SQLite**: + + 1. For `agent`: + + ```sh + db="simplex_v1_agent_plaintext.db" + sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table';" | + while read table; do + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM \"$table\";") + echo "$table: $count" + done | sort -k2 -nr | less + ``` + + 2. For `chat`: + + ```sh + db="simplex_v1_chat_plaintext.db" + sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table';" | + while read table; do + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM \"$table\";") + echo "$table: $count" + done | sort -k2 -nr | less + ``` 6. Build and run desktop app with Postgres backend. @@ -103,3 +245,47 @@ # or ./gradlew packageDmg -Pdatabase.backend=postgres ``` + +## Transfer data from Postgres to SQLite database + +1. Prepare sqlite db: + + 1. Download simplex-chat CLI: + + You should download the CLI binary from the same `TAG` as the desktop. + + ```sh + export TAG='v6.4.3.1' + curl -L "https://github.com/simplex-chat/simplex-chat/releases/download/${TAG}/simplex-chat-ubuntu-22_04-x86_64" -o 'simplex-chat' + ``` + + 2. Run the CLI: + + ```sh + ./simplex-chat + ``` + + Press `Ctrl+C` when CLI ask for a display name. + + 3. Move database: + + ```sh + mv ~/.simplex/simplex_v1_* ~/.local/share/simplex/ + ``` + +2. Transfer data: + + ```sh + ./pg2sqlite.py --verbose 'postgresql://simplex:123123@localhost:5432/simplex_v1' ~/.local/share/simplex/ + ``` + +4. Update BLOBs: + + ```sh + sqlite3 simplex_v1_chat.db + ``` + + ```sh + UPDATE group_members SET member_role = CAST(member_role as BLOB); + UPDATE user_contact_links SET group_link_member_role = CAST(group_link_member_role AS BLOB) WHERE group_link_member_role is not null; + ``` diff --git a/scripts/db/pg2sqlite.py b/scripts/db/pg2sqlite.py new file mode 100755 index 0000000000..533b15d143 --- /dev/null +++ b/scripts/db/pg2sqlite.py @@ -0,0 +1,899 @@ +#!/usr/bin/env python3 +""" +PostgreSQL -> per-schema SQLite migration with colored, clean logging. + +Usage example: + python db_migrate.py 'postgresql://user:pass@host:5432/db' /path/to/sqlite/dir --dry-run + +Note: color output will be disabled automatically if stdout is not a TTY or if --no-color is passed. +""" + +from __future__ import annotations +import argparse +import logging +import os +import sqlite3 +import sys +import re +import datetime +from pathlib import Path +from typing import Set, List, Tuple, Dict, Optional, NamedTuple + +import psycopg +from psycopg import sql + +# ---------------------- +# Small utilities +# ---------------------- + +try: + sqlite3.register_adapter( + datetime.datetime, + lambda v: v.isoformat(sep=" ", timespec="microseconds"), + ) + sqlite3.register_adapter(datetime.date, lambda v: v.isoformat()) + sqlite3.register_adapter( + datetime.time, lambda v: v.isoformat(timespec="microseconds") + ) +except ValueError: + pass # already registered + +ANSI = { + "reset": "\x1b[0m", + "bold": "\x1b[1m", + "dim": "\x1b[2m", + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "blue": "\x1b[34m", + "magenta": "\x1b[35m", + "cyan": "\x1b[36m", + "gray": "\x1b[90m", +} + +DEFAULT_BATCH_SIZE = 10000 + +TYPE_COMPATIBILITY = { + "bytea": ["BLOB", "CHAR", "CLOB", "TEXT", "JSON"], + "int": ["INT", "NUMERIC"], + "serial": ["INT", "NUMERIC"], + "numeric": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "decimal": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "real": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "double": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "float": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "money": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "bool": ["BOOL", "INT", "NUMERIC"], + "varchar": ["CHAR", "CLOB", "TEXT"], + "char": ["CHAR", "CLOB", "TEXT"], + "text": ["CHAR", "CLOB", "TEXT"], + "citext": ["CHAR", "CLOB", "TEXT"], + "timestamp": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "time": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "date": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "uuid": ["CHAR", "TEXT", "UUID", "CLOB"], + "json": ["JSON", "TEXT", "CHAR", "CLOB"], + "jsonb": ["JSON", "TEXT", "CHAR", "CLOB"], +} + + +def _sanitize_cursor_name(s: str) -> str: + return re.sub(r"[^A-Za-z0-9_]+", "_", s) + + +def supports_color(force_no: bool) -> bool: + """Return True when we should emit ANSI colors.""" + if force_no: + return False + if os.getenv("NO_COLOR"): + return False + term = os.getenv("TERM", "") + if term == "" or term.lower() == "dumb": + return False + try: + isatty = sys.stdout.isatty() + except Exception: + isatty = False + return isatty + + +class ColoredFormatter(logging.Formatter): + LEVEL_COLORS = { + logging.DEBUG: ANSI["gray"], + logging.INFO: ANSI["green"], + logging.WARNING: ANSI["yellow"], + logging.ERROR: ANSI["red"], + logging.CRITICAL: ANSI["red"] + ANSI["bold"], + } + + TAG_COLORS = { + "SKIP": ANSI["yellow"], + "SCHEMA": ANSI["blue"], + "OK": ANSI["magenta"], + } + + def __init__(self, use_color: bool = True): + super().__init__(fmt="%(message)s") + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + msg = super().format(record) + parts = msg.split(" ", 1) + tag = parts[0] + rest = parts[1] if len(parts) > 1 else "" + plain_label = f"[{tag}]" + + if not self.use_color: + return f"{plain_label}{(' ' + rest) if rest else ''}" + + color = self.TAG_COLORS.get( + tag.upper(), self.LEVEL_COLORS.get(record.levelno, "") + ) + reset = ANSI["reset"] + return f"{color}{plain_label}{reset}{(' ' + rest) if rest else ''}" + + +def setup_logger(verbose: bool, no_color: bool) -> logging.Logger: + use_color = supports_color(no_color) + logger = logging.getLogger("db_migrate") + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG if verbose else logging.INFO) + handler.setFormatter(ColoredFormatter(use_color=use_color)) + logger.handlers.clear() + logger.addHandler(handler) + logging.getLogger("psycopg").setLevel(logging.WARNING) + + if verbose: + logger.debug(f"color_support: {use_color}") + try: + isatty = sys.stdout.isatty() + except Exception: + isatty = False + logger.debug( + f"TERM={os.getenv('TERM', '')!r} " + f"NO_COLOR={os.getenv('NO_COLOR')!r} " + f"isatty={isatty}" + ) + return logger + + +def quote_sqlite_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def quote_pg_identifier(name: str) -> str: + """Simple PG identifier quoting for building safe SQL strings.""" + return '"' + name.replace('"', '""') + '"' + + +def sqlite_decl_satisfies(pg_type: str, sqlite_decl: str) -> bool: + # Treat empty/blank SQLite declarations more permissively based on PG type. + decl_raw = sqlite_decl or "" + if not decl_raw.strip(): + pg = (pg_type or "").lower() + # arrays -> textual affinity + if pg.endswith("[]"): + return True + # integer-like + if re.search(r"\b(?:int|serial|bigint)\b", pg): + return True + # numeric/float + if re.search(r"\b(?:numeric|decimal|real|double|float|money)\b", pg): + return True + # boolean + if re.search(r"\b(?:bool|boolean)\b", pg): + return True + # binary + if re.search(r"\b(?:bytea)\b", pg): + return True + # textual/json/uuid/timestamps/dates/times + if re.search( + r"\b(?:varchar|char|text|citext|jsonb|json|uuid|timestamp|time|date)\b", pg + ): + return True + # conservative fallback: accept empty decl as permissive + return True + + decl = decl_raw.upper() + pg = (pg_type or "").lower() + + # array type + if pg.endswith("[]"): + return any(tok in decl for tok in ("TEXT", "CHAR", "CLOB", "JSON")) + + for key, allowed_types in TYPE_COMPATIBILITY.items(): + if re.search(r"\b" + re.escape(key) + r"\b", pg): + return any(tok in decl for tok in allowed_types) + + return any( + tok in decl for tok in ("TEXT", "CHAR", "CLOB", "NUMERIC", "BLOB", "INT") + ) + + +# ---------------------- +# Postgres helpers +# ---------------------- + + +def list_user_schemas(pg_cursor) -> List[str]: + pg_cursor.execute( + """ + SELECT nspname + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname != 'information_schema' + AND nspname != 'public' + ORDER BY nspname; + """ + ) + return [r[0] for r in pg_cursor.fetchall()] + + +def list_tables_in_schema(pg_cursor, schema: str) -> List[str]: + pg_cursor.execute( + sql.SQL( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = %s + ORDER BY table_name; + """ + ), + (schema,), + ) + return [r[0] for r in pg_cursor.fetchall()] + + +def get_pg_columns(pg_cursor, schema: str, table: str) -> List[Tuple[str, str]]: + pg_cursor.execute( + sql.SQL( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position; + """ + ), + (schema, table), + ) + return [(r[0], r[1]) for r in pg_cursor.fetchall()] + + +# ---------------------- +# SQLite helpers +# ---------------------- + + +class SQLiteCol(NamedTuple): + cid: int + name: str + type: str + notnull: int + dflt_value: str + pk: int + + +def get_sqlite_table_info(sqlite_cursor, table_name: str) -> list[SQLiteCol]: + qname = quote_sqlite_identifier(table_name) + sqlite_cursor.execute(f"PRAGMA table_info({qname});") + return [SQLiteCol(*row) for row in sqlite_cursor.fetchall()] + + +def sqlite_table_has_autoincrement(sqlite_cursor, table_name: str) -> bool: + sqlite_cursor.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name = ?;", (table_name,) + ) + row = sqlite_cursor.fetchone() + if not row or not row[0]: + return False + return "AUTOINCREMENT" in row[0].upper() + + +def sqlite_sequence_table_exists(sqlite_cursor) -> bool: + sqlite_cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_sequence';" + ) + exists = sqlite_cursor.fetchone() is not None + if not exists: + logging.getLogger("db_migrate").debug("sqlite_sequence table not present") + return exists + + +# ---------------------- +# Core migration logic +# ---------------------- + + +def build_select_for_sqlite_columns( + schema: str, + table: str, + sqlite_cols: list[SQLiteCol], + pg_columns_map: Dict[str, Tuple[str, str]], +) -> str: + """Return a plain SQL string (no trailing semicolon) selecting PG columns in the order of sqlite_cols. + Uses simple identifier quoting to avoid psycopg.sql objects which may vary by driver version. + """ + select_parts = [] + for col in sqlite_cols: + lc = col.name.lower() + if lc in pg_columns_map: + pg_name, pg_type = pg_columns_map[lc] + pg_type_l = (pg_type or "").lower() + if any(tok in pg_type_l for tok in ("timestamp", "time")): + expr = f"{quote_pg_identifier(pg_name)}::text" + else: + expr = f"{quote_pg_identifier(pg_name)}" + select_parts.append(expr) + else: + select_parts.append("NULL") + select_list = ", ".join(select_parts) + # Quote schema and table names simply (this is not comprehensive for every corner case but avoids psycopg.sql use) + return f"SELECT {select_list} FROM {quote_pg_identifier(schema)}.{quote_pg_identifier(table)}" + + +def validate_column_compatibility( + sqlite_cols: list[SQLiteCol], + pg_columns_map: Dict[str, Tuple[str, str]], + schema: str, + table: str, +) -> None: + for col in sqlite_cols: + lc = col.name.lower() + if lc not in pg_columns_map: + continue + pg_type = pg_columns_map[lc][1] + if not sqlite_decl_satisfies(pg_type, col.type): + raise ValueError( + f"Type mismatch for {schema}.{table}.{col.name}: " + f"PG='{pg_type}' vs SQLite='{col.type}'" + ) + + +def process_row_for_sqlite(row: Tuple, sqlite_cols: list[SQLiteCol]) -> Tuple: + out = [] + for i, val in enumerate(row): + col = sqlite_cols[i] + decl_raw = col.type or "" + decl = decl_raw.upper() + decl_is_empty = decl_raw.strip() == "" + + if isinstance(val, memoryview): + b = val.tobytes() + if "BLOB" in decl or decl_is_empty: + out.append(sqlite3.Binary(b)) + else: + try: + out.append(b.decode("utf-8")) + except UnicodeDecodeError as e: + raise ValueError( + f"UTF-8 decode failed for column '{col.name}': {e}" + ) + elif isinstance(val, (bytes, bytearray)): + b = bytes(val) + if "BLOB" in decl or decl_is_empty: + out.append(sqlite3.Binary(b)) + else: + try: + out.append(b.decode("utf-8")) + except UnicodeDecodeError as e: + raise ValueError( + f"UTF-8 decode failed for column '{col.name}': {e}" + ) + else: + out.append(val) + return tuple(out) + + +def fetch_and_validate_row_batches( + pg_cursor, + select_sql: str, + sqlite_cols: list[SQLiteCol], + batch_size: int = DEFAULT_BATCH_SIZE, +): + pg_cursor.execute(select_sql) + total_rows_seen = 0 + while True: + rows = pg_cursor.fetchmany(batch_size) + if not rows: + break + validated_batch = [] + for row in rows: + total_rows_seen += 1 + try: + validated_batch.append(process_row_for_sqlite(row, sqlite_cols)) + except ValueError as e: + raise ValueError(f"Row {total_rows_seen} validation error: {e}") + yield validated_batch + + +def _get_postgres_sequence_info( + pg_cursor, schema: str, table: str, pk_col: str +) -> Optional[tuple[str, int]]: + """Get PostgreSQL sequence name and last_value for a table primary key, if any. + + Returns (sequence_name_text, last_value) or None. + """ + pg_cursor.execute( + """ + SELECT data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s AND column_name = %s; + """, + (schema, table, pk_col), + ) + r = pg_cursor.fetchone() + if not r: + return None + + pg_type = (r[0] or "").lower() + # Match whole words to avoid matching 'interval' etc. + if not re.search(r"\b(?:int|serial|bigint)\b", pg_type): + return None + + # Get sequence name text (NULL if none) + pg_cursor.execute( + "SELECT pg_get_serial_sequence(%s, %s);", + (f"{schema}.{table}", pk_col), + ) + row = pg_cursor.fetchone() + seq_name = row[0] if row else None + if not seq_name: + return None + + try: + # Read last_value by casting the pg_get_serial_sequence result to regclass. + pg_cursor.execute( + "SELECT last_value FROM pg_get_serial_sequence(%s, %s)::regclass;", + (f"{schema}.{table}", pk_col), + ) + rr = pg_cursor.fetchone() + if rr and rr[0] is not None: + return (seq_name, int(rr[0])) + except Exception: + # Swallow errors (best-effort); callers treat missing info as absent + pass + + return None + + +def _get_sqlite_max_pk_value(sqlite_cursor, table: str, pk_col: str) -> Optional[int]: + """Get the maximum primary key value from SQLite table.""" + logger = logging.getLogger("db_migrate") + try: + sqlite_cursor.execute( + f"SELECT MAX({quote_sqlite_identifier(pk_col)}) " + f"FROM {quote_sqlite_identifier(table)};" + ) + r3 = sqlite_cursor.fetchone() + if r3 and r3[0] is not None: + return int(r3[0]) + except Exception: + logger.debug("Failed to read sqlite max pk", exc_info=True) + return None + + +def _update_sqlite_sequence( + sqlite_conn: sqlite3.Connection, table: str, sequence_value: int +) -> bool: + """Update SQLite sqlite_sequence with new value. + + IMPORTANT: This function does not commit; caller must commit/rollback. + """ + s_cur = sqlite_conn.cursor() + s_cur.execute( + "UPDATE sqlite_sequence SET seq = ? WHERE name = ?;", + (sequence_value, table), + ) + if s_cur.rowcount == 0: + s_cur.execute( + "INSERT INTO sqlite_sequence(name, seq) VALUES (?, ?);", + (table, sequence_value), + ) + return True + + +def try_update_sqlite_sequence( + pg_cursor, + sqlite_conn: sqlite3.Connection, + schema: str, + pg_table: str, # PostgreSQL table name + sqlite_table: str, # SQLite table name + sqlite_cols: list[SQLiteCol], +) -> bool: + """Update SQLite sequence table based on PostgreSQL sequence values.""" + logger = logging.getLogger("db_migrate") + # Check if table has a single primary key + pks = [c for c in sqlite_cols if c.pk] + if len(pks) != 1: + return False + pk_col = pks[0].name + + # Check if SQLite has AUTOINCREMENT (use sqlite_table) + s_cur = sqlite_conn.cursor() + if not sqlite_table_has_autoincrement(s_cur, sqlite_table): + return False + if not sqlite_sequence_table_exists(s_cur): + return False + + # Get PostgreSQL sequence info (use pg_table) + seq_info = _get_postgres_sequence_info(pg_cursor, schema, pg_table, pk_col) + pg_last_value = seq_info[1] if seq_info else None + + # Get SQLite max PK value (use sqlite_table) + sqlite_max = _get_sqlite_max_pk_value(s_cur, sqlite_table, pk_col) + + # Determine the value to use + if pg_last_value is not None and sqlite_max is not None: + candidate = max(pg_last_value, sqlite_max) + elif pg_last_value is not None: + candidate = pg_last_value + elif sqlite_max is not None: + candidate = sqlite_max + else: + return False + + updated = _update_sqlite_sequence(sqlite_conn, sqlite_table, candidate) + if updated: + try: + sqlite_conn.commit() + except Exception: + logger.debug("Failed to commit sqlite_sequence update", exc_info=True) + # Let caller proceed; treat as best-effort + return updated + + +# ---------------------- +# Flow: per-schema migration +# ---------------------- + + +def migrate_schema( + pg_conn, + sqlite_dir: str, + schema: str, + skipped_tables: Set[str], + logger: logging.Logger, + dry_run: bool = False, + batch_size: int = DEFAULT_BATCH_SIZE, +) -> Tuple[int, int]: + """Migrate a single schema. Returns (tables_migrated, rows_inserted_total).""" + processed_schema = schema[:-7] if schema.endswith("_schema") else schema + sqlite_db_path = Path(sqlite_dir) / f"{processed_schema}.db" + if not sqlite_db_path.is_file(): + logger.error(f"Missing SQLite DB: {sqlite_db_path}") + return 0, 0 + + tables_migrated = 0 + rows_inserted = 0 + + sqlite_path = sqlite_db_path.resolve() + if dry_run: + uri = sqlite_path.as_uri() + "?mode=ro" + conn_args = {"database": uri, "uri": True} + else: + conn_args = {"database": str(sqlite_path)} + + with sqlite3.connect(**conn_args) as sqlite_conn: + sqlite_cur = sqlite_conn.cursor() + sqlite_cur.execute("SELECT name FROM sqlite_master WHERE type='table';") + sqlite_tables = [r[0] for r in sqlite_cur.fetchall()] + # O(1) lookup map for matching by lowercase name + sqlite_table_map = {t.lower(): t for t in sqlite_tables} + + with pg_conn.cursor() as pg_cur: + # Ensure we start in a clean state for this connection + try: + pg_conn.rollback() + except Exception: + # Ignore rollback failure; connection is newly opened most likely + logger.debug( + "pg_conn.rollback() at schema start ignored", exc_info=True + ) + + # Get table list for this schema, but catch/rollback on failure + try: + tables = list_tables_in_schema(pg_cur, schema) + except Exception as e: + logger.error("ERROR %s (list tables): %s", schema, e) + logger.debug("Traceback (list tables):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after list tables error", + exc_info=True, + ) + return 0, 0 + + for table in tables: + # Defensive: ensure connection is in a clean state before any new PG work. + # A prior error can leave the connection in an aborted transaction; calling + # rollback() clears that and allows subsequent SELECTs to run. + try: + pg_conn.rollback() + except Exception: + # ignore: best-effort cleanup + logger.debug( + "pg_conn.rollback() ignored at start of table loop", + exc_info=True, + ) + + if table.lower() in skipped_tables: + logger.info("SKIP %s.%s (explicit)", schema, table) + continue + if table.lower() not in sqlite_table_map: + logger.info("SKIP %s.%s (no target table)", schema, table) + continue + + matched_table = sqlite_table_map[table.lower()] + logger.info("OK %s.%s -> %s", schema, table, matched_table) + + # Fetch PG columns, but defend against aborted transaction here + try: + pg_cols = get_pg_columns(pg_cur, schema, table) + except Exception as e: + logger.error("ERROR %s.%s (get columns): %s", schema, table, e) + logger.debug("Traceback (get columns):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after get columns error", + exc_info=True, + ) + continue + + pg_map: Dict[str, Tuple[str, str]] = { + name.lower(): (name, dtype) for name, dtype in pg_cols + } + + sqlite_info = get_sqlite_table_info(sqlite_cur, matched_table) + if not sqlite_info: + logger.warning(f"SKIP {schema}.{table} (no sqlite info)") + continue + + # Warn once about schema drift (extra columns) + pg_col_names = {name.lower() for name, _ in pg_cols} + extra_in_sqlite = { + c.name for c in sqlite_info if c.name.lower() not in pg_col_names + } + if extra_in_sqlite: + logger.warning( + f"Schema drift: {schema}.{table} has extra SQLite columns {extra_in_sqlite}" + ) + + try: + validate_column_compatibility(sqlite_info, pg_map, schema, table) + except ValueError as e: + logger.error(f"ERROR {schema}.{table} (type mismatch): {e}") + continue + + select_sql = build_select_for_sqlite_columns( + schema, table, sqlite_info, pg_map + ) + + csr_name = _sanitize_cursor_name(f"csr_{schema}_{table}") + try: + with pg_conn.cursor(name=csr_name) as data_cur: + batch_gen = fetch_and_validate_row_batches( + data_cur, select_sql, sqlite_info, batch_size=batch_size + ) + first_batch = next(batch_gen, None) + + if not first_batch: + logger.info(f"SKIP {schema}.{table} (no rows)") + # data_cur will be closed automatically on leaving the 'with' + continue + + if dry_run: + count = len(first_batch) + for batch in batch_gen: + count += len(batch) + inserted = 0 + logger.info( + f"DRY {schema}.{table} rows_validated={count}" + ) + else: + cur = sqlite_conn.cursor() + quoted_table = quote_sqlite_identifier(matched_table) + col_names = [c.name for c in sqlite_info] + quoted_cols = ", ".join( + quote_sqlite_identifier(c) for c in col_names + ) + placeholders = ", ".join(["?"] * len(col_names)) + + cur.execute("BEGIN;") + try: + cur.execute(f"DELETE FROM {quoted_table};") + inserted = 0 + if first_batch: + cur.executemany( + f"INSERT INTO {quoted_table} ({quoted_cols}) VALUES ({placeholders});", + first_batch, + ) + inserted += len(first_batch) + for batch in batch_gen: + if not batch: + continue + cur.executemany( + f"INSERT INTO {quoted_table} ({quoted_cols}) VALUES ({placeholders});", + batch, + ) + inserted += len(batch) + except Exception: + cur.execute("ROLLBACK;") + raise + else: + cur.execute("COMMIT;") + except ValueError as e: + logger.error("ERROR %s.%s (row validation): %s", schema, table, e) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after row validation error", + exc_info=True, + ) + continue + except Exception as e: + logger.error("ERROR %s.%s (select failed): %s", schema, table, e) + logger.debug("Traceback (select failed):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after select failed", + exc_info=True, + ) + continue + + rows_inserted += inserted + tables_migrated += 1 + logger.info(f"DONE {schema}.{table} rows={inserted}") + + if not dry_run: + try: + if try_update_sqlite_sequence( + pg_cur, + sqlite_conn, + schema, + table, + matched_table, + sqlite_info, + ): + logger.info( + "SEQ %s.%s sqlite_sequence updated", schema, table + ) + except Exception as e: + logger.warning( + "SEQ %s.%s update failed (ignored): %s", schema, table, e + ) + + return tables_migrated, rows_inserted + + +def migrate_data( + pg_conn_str: str, + sqlite_dir: str, + schema_filter: Optional[str], + dry_run: bool, + skip_tables: str, + logger: logging.Logger, + batch_size: int, +) -> None: + skipped_tables = {t.strip().lower() for t in skip_tables.split(",") if t.strip()} + total_tables = 0 + total_rows = 0 + total_errors = 0 + + # List schemas once with a short-lived connection + with psycopg.connect(pg_conn_str) as tmp_conn: + with tmp_conn.cursor() as cur: + schemas = list_user_schemas(cur) + + if schema_filter: + schemas = [s for s in schemas if s == schema_filter] + if not schemas: + logger.error("No schemas to process") + return + + for schema in schemas: + logger.info("SCHEMA %s", schema) + if dry_run: + logger.info("(dry-run) validating only — no writes will be performed") + + # Use a fresh connection per-schema to isolate failures/aborted transactions + try: + with psycopg.connect(pg_conn_str) as pg_conn: + migrated_tables, inserted_rows = migrate_schema( + pg_conn, + sqlite_dir, + schema, + skipped_tables, + logger, + dry_run=dry_run, + batch_size=batch_size, + ) + except Exception as e: + logger.error("Schema %s failed: %s", schema, e) + logger.debug("Traceback (schema failure):", exc_info=True) + total_errors += 1 + continue + + total_tables += migrated_tables + total_rows += inserted_rows + + logger.info("---") + logger.info( + f"SUMMARY: schemas={len(schemas)} " + f"tables_migrated={total_tables} " + f"rows_inserted={total_rows} " + f"errors={total_errors}" + ) + + +# ---------------------- +# CLI +# ---------------------- + + +def parse_args(argv): + p = argparse.ArgumentParser( + description="Migrate Postgres data into per-schema SQLite DBs (non-invasive)." + ) + p.add_argument("pg_conn", help="Postgres connection string") + p.add_argument( + "sqlite_dir", help="Directory containing per-schema sqlite .db files" + ) + p.add_argument( + "--schema", help="Only migrate this schema (exact match)", default=None + ) + p.add_argument( + "--dry-run", + help="Validate and report only; do not write to sqlite", + action="store_true", + ) + p.add_argument("--verbose", help="Verbose logging (debug)", action="store_true") + p.add_argument("--no-color", help="Disable ANSI color output", action="store_true") + p.add_argument( + "--batch-size", + type=int, + default=DEFAULT_BATCH_SIZE, + help="Batch size for data fetching", + ) + p.add_argument( + "--skip-tables", + help="Comma-separated list of tables to skip", + default="migrations,servers_stats", + ) + + return p.parse_args(argv[1:]) + + +def main(argv): + args = parse_args(argv) + logger = setup_logger(args.verbose, args.no_color) + + sqlite_dir_path = Path(args.sqlite_dir) + if not sqlite_dir_path.is_dir(): + logger.error("SQLite directory does not exist or is not a directory.") + raise SystemExit(1) + + try: + migrate_data( + args.pg_conn, + str(sqlite_dir_path), + args.schema, + args.dry_run, + args.skip_tables, + logger, + args.batch_size, + ) + except Exception as e: + logger.error(f"Fatal error: {e}") + raise SystemExit(2) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/db/sqlite.load b/scripts/db/sqlite.load index 105e8c39d2..7f60e72c2f 100644 --- a/scripts/db/sqlite.load +++ b/scripts/db/sqlite.load @@ -10,7 +10,8 @@ LOAD DATABASE -- pgloader implementation doesn't find "GENERATED ALWAYS AS IDENTITY" sequences, -- instead we reset sequences manually via custom query after load reset no sequences, - data only + data only, + workers = {{WORKERS}} EXCLUDING TABLE NAMES LIKE 'migrations', 'sqlite_sequence' diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index f234dfac25..97f0760cc9 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -27,7 +27,7 @@ rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then echo "Building with postgres backend..." - cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' + cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' else echo "Building with sqlite backend..." cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' diff --git a/scripts/desktop/make-deb-linux.sh b/scripts/desktop/make-deb-linux.sh index c9c4d5a81c..3226c22709 100755 --- a/scripts/desktop/make-deb-linux.sh +++ b/scripts/desktop/make-deb-linux.sh @@ -37,8 +37,12 @@ export SOURCE_DATE_EPOCH=1704067200 dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted +# Source the distribution variables (VERSION_CODENAME) +. /etc/os-release + rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux* sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg +sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} + dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 630b178aaf..082f8a7dc9 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,48 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.6:

+
    +
  • fixed opening SimpleX links from outside of the app.
  • +
+

New in v6.4-6.4.5:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
+ + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.5:

+
    +
  • fixes for chats with admins.
  • +
  • better information about network errors.
  • +
+

New in v6.4-6.4.4:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index a5f2790418..927ea5c30b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7e98b3103f4eb9a6a9a99604afb6f3a32ffc013d" = "0g6lm65hs2kp2rsk9lqzj42nq51i5xynxrf16axma80cq0jqzxzl"; + "https://github.com/simplex-chat/simplexmq.git"."1ae3e8d0be957aa5090e88f25e6dc42d4af1a334" = "1cwahakq63jk7g0bbkdgpnnwa8i0i8s8j7azdpjral4d6cj4q4q0"; "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"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 88a92e6bb4..3d3cf75713 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.4.5.1 +version: 6.5.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -40,6 +40,7 @@ library Simplex.Chat.AppSettings Simplex.Chat.Call Simplex.Chat.Controller + Simplex.Chat.Delivery Simplex.Chat.Files Simplex.Chat.Library.Commands Simplex.Chat.Library.Internal @@ -72,6 +73,7 @@ library Simplex.Chat.Store.AppSettings Simplex.Chat.Store.Connections Simplex.Chat.Store.ContactRequest + Simplex.Chat.Store.Delivery Simplex.Chat.Store.Direct Simplex.Chat.Store.Files Simplex.Chat.Store.Groups @@ -85,7 +87,6 @@ library Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme - Simplex.Chat.Types.Util Simplex.Chat.Util if !flag(client_library) exposed-modules: @@ -115,6 +116,11 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type + Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary + Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections + Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync + Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade else exposed-modules: Simplex.Chat.Archive @@ -255,6 +261,11 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type + Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary + Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections + Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync + Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade other-modules: Paths_simplex_chat hs-source-dirs: @@ -453,10 +464,13 @@ executable simplex-directory-service Directory.BlockedWords Directory.Captcha Directory.Events + Directory.Listing Directory.Options Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate + Directory.Util Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: @@ -464,9 +478,13 @@ executable simplex-directory-service , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 + , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* , containers ==0.6.* + , crypton ==0.34.* , directory ==1.3.* + , filepath ==1.4.* + , memory ==0.18.* , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 , process >=1.6 && <1.6.18 @@ -479,7 +497,17 @@ executable simplex-directory-service , unicode-transforms ==0.4.* default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations + build-depends: + postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres + else + other-modules: + Directory.Store.SQLite.Migrations + build-depends: + sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -535,10 +563,13 @@ test-suite simplex-chat-test Directory.BlockedWords Directory.Captcha Directory.Events + Directory.Listing Directory.Options Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate + Directory.Util Paths_simplex_chat if flag(client_postgres) other-modules: @@ -591,10 +622,15 @@ test-suite simplex-chat-test , uri-bytestring >=0.3.3.1 && <0.4 default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations build-depends: postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres else + other-modules: + Directory.Store.SQLite.Migrations build-depends: sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e14275b75c..9b711c2b50 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,13 +39,13 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Entity -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) @@ -106,8 +106,9 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes - coreApi = False, highlyAvailable = False, + deliveryWorkerDelay = 0, + deliveryBucketSize = 10000, deviceNameForRemote = "", chatHooks = defaultChatHooks } @@ -115,10 +116,10 @@ defaultChatConfig = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase chatDbOpts confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) confirmMigrations - agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) confirmMigrations +createChatDatabase :: ChatDbOpts -> MigrationConfig -> IO (Either MigrationError ChatDatabase) +createChatDatabase chatDbOpts migrationConfig = runExceptT $ do + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) migrationConfig + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) migrationConfig pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController @@ -148,7 +149,6 @@ newChatController eventSeq <- newTVarIO 0 inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize - connNetworkStatuses <- TM.emptyIO subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO entityLocks <- TM.emptyIO @@ -163,6 +163,8 @@ newChatController remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False + deliveryTaskWorkers <- TM.emptyIO + deliveryJobWorkers <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO cleanupManagerAsync <- newTVarIO Nothing @@ -188,7 +190,6 @@ newChatController eventSeq, inputQ, outputQ, - connNetworkStatuses, subscriptionMode, chatLock, entityLocks, @@ -203,6 +204,8 @@ newChatController remoteCtrlSession, config, filesFolder, + deliveryTaskWorkers, + deliveryJobWorkers, expireCIThreads, expireCIFlags, cleanupManagerAsync, @@ -247,7 +250,7 @@ newChatController ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains} + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains, presetServers = L.toList allPresetServers} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d8a6f7a30a..8003f66324 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -54,6 +54,7 @@ import Numeric.Natural import qualified Paths_simplex_chat as SC import Simplex.Chat.AppSettings import Simplex.Chat.Call +import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -61,16 +62,16 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLink, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) -import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) +import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg, Worker) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) @@ -154,7 +155,8 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds - coreApi :: Bool, + deliveryWorkerDelay :: Int64, -- microseconds + deliveryBucketSize :: Int, highlyAvailable :: Bool, deviceNameForRemote :: Text, chatHooks :: ChatHooks @@ -229,7 +231,6 @@ data ChatController = ChatController eventSeq :: TVar Int, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe RemoteHostId, Either ChatError ChatEvent), - connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, entityLocks :: TMap ChatLockEntity Lock, @@ -244,6 +245,8 @@ data ChatController = ChatController remoteCtrlSession :: TVar (Maybe (SessionSeq, RemoteCtrlSession)), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, + deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, + deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -288,6 +291,7 @@ data ChatCommand | APIStopChat | APIActivateChat {restoreChat :: Bool} | APISuspendChat {suspendTimeout :: Int} + | ShowConnectionsDiff Bool | ResubscribeAllConnections | SetTempFolder FilePath | SetFilesFolder FilePath @@ -310,8 +314,8 @@ data ChatCommand | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe String} - | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe String} + | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe Text} + | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text} | APIGetChatItemInfo {chatRef :: ChatRef, chatItemId :: ChatItemId} | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData @@ -349,7 +353,6 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus - | APIGetNetworkStatuses | APIUpdateProfile {userId :: UserId, profile :: Profile} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} @@ -508,8 +511,8 @@ data ChatCommand | ClearGroup GroupName | ListMembers GroupName | ListMemberSupportChats GroupName - | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe String} - | ListGroups (Maybe ContactName) (Maybe String) + | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe Text} + | ListGroups (Maybe ContactName) (Maybe Text) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -521,7 +524,7 @@ data ChatCommand | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text} | ClearNoteFolder | LastChats (Maybe Int) -- UserId (not used in UI) - | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI) + | LastMessages (Maybe ChatName) Int (Maybe Text) -- UserId (not used in UI) | LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI) | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text @@ -629,6 +632,7 @@ data ChatResponse | CRChatStarted | CRChatRunning | CRChatStopped + | CRConnectionsDiff {showIds :: Bool, userIds :: DatabaseDiff AgentUserId, connIds :: DatabaseDiff AgentConnId} | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} @@ -644,7 +648,7 @@ data ChatResponse | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} - | CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary} + | CRGroupInfo {user :: User, groupInfo :: GroupInfo} | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRQueueInfo {user :: User, rcvMsgInfo :: Maybe RcvMsgInfo, queueInfo :: ServerQueueInfo} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} @@ -679,7 +683,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} - | CRGroupsList {user :: User, groups :: [GroupInfoSummary]} + | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem @@ -723,7 +727,6 @@ data ChatResponse | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -791,7 +794,6 @@ data ChatEvent | CEvtSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} -- there is the same command response | CEvtContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} - | CEvtContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CEvtContactDeletedByContact {user :: User, contact :: Contact} | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response @@ -822,14 +824,9 @@ data ChatEvent | CEvtContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} | CEvtContactSndReady {user :: User, contact :: Contact} | CEvtContactAnotherClient {user :: User, contact :: Contact} + | CEvtConnectionsDiff {userIds :: DatabaseDiff AgentUserId, connIds :: DatabaseDiff AgentConnId} | CEvtSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} - | CEvtContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} - | CEvtContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} - | CEvtContactSubError {user :: User, contact :: Contact, chatError :: ChatError} - | CEvtContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} - | CEvtUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} - | CEvtNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} - | CEvtNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} -- there is the same command response + | CEvtSubscriptionStatus {server :: SMPServer, subscriptionStatus :: SubscriptionStatus, connections :: [AgentConnId]} | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} @@ -885,14 +882,6 @@ data TerminalEvent | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | TEContactVerificationReset {user :: User, contact :: Contact} | TEGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | TEGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} - | TEMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | TEPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} - | TESndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | TERcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue @@ -917,9 +906,7 @@ allowRemoteEvent = \case logEventToFile :: ChatEvent -> Bool logEventToFile = \case - CEvtContactsDisconnected {} -> True - CEvtContactsSubscribed {} -> True - CEvtContactSubError {} -> True + CEvtSubscriptionStatus {} -> True CEvtHostConnected {} -> True CEvtHostDisconnected {} -> True CEvtConnectionDisabled {} -> True @@ -928,11 +915,6 @@ logEventToFile = \case CEvtAgentUserDeleted {} -> True -- CRChatCmdError {} -> True -- TODO this should be separately logged to file as command error CEvtMessageError {} -> True - CEvtTerminalEvent te -> case te of - TEMemberSubError {} -> True - TESndFileSubError {} -> True - TERcvFileSubError {} -> True - _ -> False _ -> False data SendRef @@ -1108,27 +1090,9 @@ defaultSimpleNetCfg = logTLSErrors = False } -data ContactSubStatus = ContactSubStatus - { contact :: Contact, - contactError :: Maybe ChatError - } - deriving (Show) - -data MemberSubStatus = MemberSubStatus - { member :: ShortGroupMember, - memberError :: Maybe ChatError - } - deriving (Show) - -data UserContactSubStatus = UserContactSubStatus - { userContact :: UserContact, - userContactError :: Maybe ChatError - } - deriving (Show) - -data PendingSubStatus = PendingSubStatus - { connection :: PendingContactConnection, - connError :: Maybe ChatError +data ConnSubResult = ConnSubResult + { agentConnId :: AgentConnId, + connSubError :: Maybe ChatError } deriving (Show) @@ -1271,7 +1235,7 @@ data SlowSQLQuery = SlowSQLQuery data ChatError = ChatError {errorType :: ChatErrorType} - | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} + | ChatErrorAgent {agentError :: AgentErrorType, agentConnId :: AgentConnId, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} @@ -1327,7 +1291,6 @@ data ChatErrorType | CEFileCancelled {message :: String} | CEFileCancel {fileId :: FileTransferId, message :: String} | CEFileAlreadyExists {filePath :: FilePath} - | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} | CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType} | CEFileRcvChunk {message :: String} @@ -1506,10 +1469,6 @@ chatModifyVar' :: (ChatController -> TVar a) -> (a -> a) -> CM' () chatModifyVar' f newValue = asks f >>= atomically . (`modifyTVar'` newValue) {-# INLINE chatModifyVar' #-} -setContactNetworkStatus :: Contact -> NetworkStatus -> CM' () -setContactNetworkStatus Contact {activeConn = Nothing} _ = pure () -setContactNetworkStatus Contact {activeConn = Just Connection {agentConnId}} status = chatModifyVar' connNetworkStatuses $ M.insert agentConnId status - onChatError :: CM a -> CM b -> CM a a `onChatError` onErr = a `catchAllErrors` \e -> onErr >> throwError e {-# INLINE onChatError #-} @@ -1606,7 +1565,7 @@ withAgent :: (AgentClient -> ExceptT AgentErrorType IO a) -> CM a withAgent action = asks smpAgent >>= liftIO . runExceptT . action - >>= liftEither . first (`ChatErrorAgent` Nothing) + >>= liftEither . first (\e -> ChatErrorAgent e (AgentConnId "") Nothing) withAgent' :: (AgentClient -> IO a) -> CM' a withAgent' action = asks smpAgent >>= liftIO . action @@ -1639,13 +1598,7 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) -$(JQ.deriveJSON defaultJSON ''ContactSubStatus) - -$(JQ.deriveJSON defaultJSON ''MemberSubStatus) - -$(JQ.deriveJSON defaultJSON ''UserContactSubStatus) - -$(JQ.deriveJSON defaultJSON ''PendingSubStatus) +$(JQ.deriveJSON defaultJSON ''ConnSubResult) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "AE") ''ArchiveError) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index fabc45a04c..131b420bd9 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -30,7 +30,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.Types.Preferences (FeatureAllowed (..), FilesPreference (..), Preferences (..), emptyChatPrefs) import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) import System.IO (hFlush, stdout) @@ -38,15 +38,15 @@ import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}, createBot, maintenance} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath}, createBot, maintenance} chat = case logAgent of Just level -> do setLogLevel level withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbOptions confirm' >>= either exit run - confirm' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + initRun = createChatDatabase dbOptions migrationConfig >>= either exit run + migrationConfig = MigrationConfig (if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations) migrationBackupPath exit e = do putStrLn $ "Error opening database: " <> show e exitFailure diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs new file mode 100644 index 0000000000..d0a77514eb --- /dev/null +++ b/src/Simplex/Chat/Delivery.hs @@ -0,0 +1,188 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Delivery where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Maybe (fromMaybe) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Protocol +import Simplex.Chat.Types +import Simplex.Chat.Types.Shared +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding.String + +type DeliveryWorkerKey = (GroupId, DeliveryWorkerScope) + +data DeliveryWorkerScope + = DWSGroup + | DWSMemberSupport + -- | DWSMemberProfileUpdate + deriving (Eq, Ord, Show) + +instance FromField DeliveryWorkerScope where fromField = fromTextField_ textDecode + +instance ToField DeliveryWorkerScope where toField = toField . textEncode + +instance TextEncoding DeliveryWorkerScope where + textDecode = \case + "group" -> Just DWSGroup + "member_support" -> Just DWSMemberSupport + -- "member_profile_update" -> Just DWSMemberProfileUpdate + _ -> Nothing + textEncode = \case + DWSGroup -> "group" + DWSMemberSupport -> "member_support" + -- DWSMemberProfileUpdate -> "member_profile_update" + +data DeliveryJobScope + = DJSGroup {jobSpec :: DeliveryJobSpec} + | DJSMemberSupport {supportGMId :: GroupMemberId} + -- | DJSMemberProfileUpdate + deriving (Show) + +data DeliveryJobSpec + = DJDeliveryJob {includePending :: Bool} + | DJRelayRemoved + deriving (Show) + +data DeliveryJobSpecTag + = DJSTDeliveryJob + | DJSTRelayRemoved + deriving (Show) + +instance FromField DeliveryJobSpecTag where fromField = fromTextField_ textDecode + +instance ToField DeliveryJobSpecTag where toField = toField . textEncode + +instance TextEncoding DeliveryJobSpecTag where + textDecode = \case + "delivery_job" -> Just DJSTDeliveryJob + "relay_removed" -> Just DJSTRelayRemoved + _ -> Nothing + textEncode = \case + DJSTDeliveryJob -> "delivery_job" + DJSTRelayRemoved -> "relay_removed" + +toWorkerScope :: DeliveryJobScope -> DeliveryWorkerScope +toWorkerScope = \case + DJSGroup _ -> DWSGroup + DJSMemberSupport _ -> DWSMemberSupport + -- DJSMemberProfileUpdate -> DWSMemberProfileUpdate + +isRelayRemoved :: DeliveryJobScope -> Bool +isRelayRemoved = \case + DJSGroup {jobSpec} -> case jobSpec of + DJRelayRemoved -> True + _ -> False + _ -> False + +jobScopeImpliedSpec :: DeliveryJobScope -> DeliveryJobSpec +jobScopeImpliedSpec = \case + DJSGroup {jobSpec} -> jobSpec + DJSMemberSupport {} -> DJDeliveryJob {includePending = False} + +jobSpecImpliedPending :: DeliveryJobSpec -> Bool +jobSpecImpliedPending = \case + DJDeliveryJob {includePending} -> includePending + DJRelayRemoved -> True + +infoToDeliveryScope :: GroupInfo -> Maybe GroupChatScopeInfo -> DeliveryJobScope +infoToDeliveryScope GroupInfo {membership} = \case + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} + +memberEventDeliveryScope :: GroupMember -> Maybe DeliveryJobScope +memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DJSMemberSupport {supportGMId = groupMemberId' m} + | memberRole >= GRModerator = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} + | otherwise = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + jobScope :: DeliveryJobScope, + messageFromChannel :: MessageFromChannel + } + deriving (Show) + +data MessageDeliveryTask = MessageDeliveryTask + { taskId :: Int64, + jobScope :: DeliveryJobScope, + senderGMId :: GroupMemberId, + senderMemberId :: MemberId, + senderMemberName :: ContactName, + brokerTs :: UTCTime, + chatMessage :: ChatMessage 'Json, + messageFromChannel :: MessageFromChannel + } + deriving (Show) + +deliveryTaskId :: MessageDeliveryTask -> Int64 +deliveryTaskId = taskId + +data DeliveryTaskStatus + = DTSNew -- created for delivery task worker to pick up and convert into a delivery job + | DTSProcessed -- processed by delivery task worker, delivery job created, task can be deleted + | DTSError -- permanent error + deriving (Show) + +instance FromField DeliveryTaskStatus where fromField = fromTextField_ textDecode + +instance ToField DeliveryTaskStatus where toField = toField . textEncode + +instance TextEncoding DeliveryTaskStatus where + textDecode = \case + "new" -> Just DTSNew + "processed" -> Just DTSProcessed + "error" -> Just DTSError + _ -> Nothing + textEncode = \case + DTSNew -> "new" + DTSProcessed -> "processed" + DTSError -> "error" + +data MessageDeliveryJob = MessageDeliveryJob + { jobId :: Int64, + jobScope :: DeliveryJobScope, + singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries + body :: ByteString, + cursorGMId_ :: Maybe GroupMemberId + } + deriving (Show) + +deliveryJobId :: MessageDeliveryJob -> Int64 +deliveryJobId = jobId + +data DeliveryJobStatus + = DJSPending -- created for delivery job worker to pick up + | DJSComplete -- complete by delivery job worker, job can be deleted + | DJSError -- permanent error + deriving (Show) + +instance FromField DeliveryJobStatus where fromField = fromTextField_ textDecode + +instance ToField DeliveryJobStatus where toField = toField . textEncode + +instance TextEncoding DeliveryJobStatus where + textDecode = \case + "pending" -> Just DJSPending + "complete" -> Just DJSComplete + "error" -> Just DJSError + _ -> Nothing + textEncode = \case + DJSPending -> "pending" + DJSComplete -> "complete" + DJSError -> "error" + +-- data MemberProfileUpdateTask = undefined + +-- data MemberProfileUpdateJob = undefined diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b668a1d793..67953897cf 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -17,7 +17,6 @@ module Simplex.Chat.Library.Commands where -import qualified Codec.Compression.Zstd as Z1 import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) import Control.Logger.Simple @@ -74,6 +73,7 @@ import Simplex.Chat.Store import Simplex.Chat.Store.AppSettings import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Connections +import Simplex.Chat.Store.Delivery import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups @@ -87,7 +87,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Entity @@ -96,7 +96,6 @@ import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) -import Simplex.Messaging.Compression (compressionLevel) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -104,7 +103,6 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), patt import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) -import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth) @@ -166,10 +164,20 @@ startChatController mainApp enableSndFiles = do asks smpAgent >>= liftIO . resumeAgentClient unless mainApp $ chatWriteVar' subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withFastStore' getUsers) + runExceptT (syncConnections' users) >>= \case + Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e + Right _ -> pure () restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) where + syncConnections' users = + whenM (withFastStore' shouldSyncConnections) $ do + let aUserIds = map aUserId users + connIds <- concat <$> forM users getConnsToSub + (userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds) + withFastStore' setConnectionsSyncTs + toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) start s users = do a1 <- async agentSubscriber a2 <- @@ -181,6 +189,7 @@ startChatController mainApp enableSndFiles = do then do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users + startDeliveryWorkers startCleanupManager void $ forkIO $ mapM_ startExpireCIs users else when enableSndFiles $ startXFTP xftpStartSndWorkers @@ -190,6 +199,10 @@ startChatController mainApp enableSndFiles = do runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e Right _ -> pure () + startDeliveryWorkers = + runExceptT (startDeliveryTaskWorkers >> startDeliveryJobWorkers) >>= \case + Left e -> liftIO $ putStrLn $ "Error starting delivery workers: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -207,15 +220,19 @@ startChatController mainApp enableSndFiles = do ttlCount <- getChatTTLCount db user pure $ ttl > 0 || ttlCount > 0 +getConnsToSub :: User -> CM [ConnId] +getConnsToSub user = + withFastStore' $ \db -> do + ctConnIds <- getContactConnsToSub db user False + uclConnIds <- getUCLConnsToSub db user False + memberConnIds <- getMemberConnsToSub db user False + pendingConnIds <- getPendingConnsToSub db user False + pure $ ctConnIds <> uclConnIds <> memberConnIds <> pendingConnIds + subscribeUsers :: Bool -> [User] -> CM' () subscribeUsers onlyNeeded users = do - let (us, us') = partition activeUser users - vr <- chatVersionRange' - subscribe vr us - subscribe vr us' - where - subscribe :: VersionRangeChat -> [User] -> CM' () - subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections + let activeUserId_ = (\User {agentUserId = AgentUserId uId} -> uId) <$> find activeUser users + withAgent (\a -> subscribeAllConnections a onlyNeeded activeUserId_) `catchAllErrors'` eToView' startFilesToReceive :: [User] -> CM' () startFilesToReceive users = do @@ -445,6 +462,12 @@ processChatCommand vr nm = \case stopRemoteCtrl lift $ withAgent' (`suspendAgent` t) ok_ + ShowConnectionsDiff showIds -> do + users <- withFastStore' getUsers + let aUserIds = map aUserId users + connIds <- concat <$> forM users getConnsToSub + (userDiff, connDiff) <- withAgent (\a -> compareConnections a aUserIds connIds) + pure $ CRConnectionsDiff showIds (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) ResubscribeAllConnections -> withStore' getUsers >>= lift . subscribeUsers False >> ok_ -- has to be called before StartChat SetTempFolder tf -> do @@ -607,7 +630,6 @@ processChatCommand vr nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - -- TODO [knocking] reports sent to support scope may be wrong sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName @@ -652,7 +674,8 @@ processChatCommand vr nm = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do @@ -707,15 +730,14 @@ processChatCommand vr nm = \case CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deletions <- case mode of CIDMInternal -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items @@ -798,7 +820,8 @@ processChatCommand vr nm = \case (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId (g,) <$> getGroupCIWithReactions db user g itemId - (chatScopeInfo, recipients) <- getGroupRecipients vr user g scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user g chatScopeInfo groupKnockingVersion case ci of ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ @@ -1095,8 +1118,8 @@ processChatCommand vr nm = \case -- TODO [knocking] set support chat as unread? CTGroup | isNothing scope -> do withFastStore $ \db -> do - Group {groupInfo} <- getGroup db vr user chatId - liftIO $ updateGroupUnreadChat db user groupInfo unreadChat + gInfo <- getGroupInfo db vr user chatId + liftIO $ updateGroupUnreadChat db user gInfo unreadChat ok user CTLocal -> do withFastStore $ \db -> do @@ -1144,7 +1167,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner canDelete = isOwner || not (memberCurrent membership) @@ -1152,8 +1175,8 @@ processChatCommand vr nm = \case filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId $ do deleteCIFiles user filesInfo + (members, recipients) <- getRecipients gInfo let doSendDel = memberActive membership && isOwner - recipients = filter memberCurrentOrPending members when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -1163,6 +1186,14 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deleteGroupMembers db user gInfo withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo + where + getRecipients gInfo@GroupInfo {useRelays} + | isTrue useRelays = do + relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + pure (relays, relays) + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) _ -> throwCmdError "not supported" APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do @@ -1337,8 +1368,6 @@ processChatCommand vr nm = \case user <- getUserByContactId db contactId contact <- getContact db vr user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs} - APIGetNetworkStatuses -> withUser $ \_ -> - CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1568,12 +1597,17 @@ processChatCommand vr nm = \case ok user CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do - Group _ ms <- getGroup db vr user chatId + gInfo <- getGroupInfo db vr user chatId + ms <- liftIO $ getMembers db gInfo liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchAllErrors` eToView ok user + where + getMembers db gInfo@GroupInfo {useRelays} + | isTrue useRelays = getGroupRelays db vr user gInfo + | otherwise = getGroupMembers db vr user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do @@ -1596,9 +1630,8 @@ processChatCommand vr nm = \case case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError $ CEContactNotActive ct - APIGroupInfo gId -> withUser $ \user -> do - (g, s) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) - pure $ CRGroupInfo user g s + APIGroupInfo gId -> withUser $ \user -> + CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) @@ -1742,7 +1775,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMInvitation (Just userData) Nothing IKPQOn subMode + (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink -- TODO PQ pass minVersion from the current range conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' Nothing ConnNew incognitoProfile subMode initialChatVersion PQSupportOn @@ -1784,7 +1817,7 @@ processChatCommand vr nm = \case | short = Just $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing | otherwise = Nothing -- TODO [certs rcv] - (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True SCMInvitation userData_ Nothing IKPQOn subMode + (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do deleteConnectionRecord db user connId @@ -1976,7 +2009,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMContact (Just userData) Nothing IKPQOn subMode + (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode pure $ CRUserContactLinkCreated user ccLink' @@ -2083,7 +2116,7 @@ processChatCommand vr nm = \case case memberContactId m of Nothing -> do g <- withFastStore $ \db -> getGroupInfo db vr user gId - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" + unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" toView $ CEvtNoMemberContactCreating user g m processChatCommand vr nm (APICreateMemberContact gId mId) >>= \case CRNewMemberContact _ ct@Contact {contactId} _ _ -> do @@ -2196,7 +2229,7 @@ processChatCommand vr nm = \case gVar <- asks random subMode <- chatReadVar subscriptionMode -- TODO [certs rcv] - (agentConnId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode + (agentConnId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode member <- withFastStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -2309,6 +2342,7 @@ processChatCommand vr nm = \case pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2377,8 +2411,10 @@ processChatCommand vr nm = \case pure (m :: GroupMember) {memberRole = newRole} APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" + -- TODO [channels fwd] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" @@ -2423,6 +2459,7 @@ processChatCommand vr nm = \case in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds @@ -2504,14 +2541,14 @@ processChatCommand vr nm = \case void $ deleteOrUpdateMemberRecordIO db user gInfo m pure m {memberStatus = GSMemRemoved} deleteMessages user gInfo@GroupInfo {membership} ms - | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership + | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo - let recipients = filter memberCurrentOrPending members + (members, recipients) <- getRecipients user gInfo msg <- sendGroupMessage' user gInfo recipients XGrpLeave (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) @@ -2522,6 +2559,14 @@ processChatCommand vr nm = \case deleteMembersConnections' user members True withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} + where + getRecipients user gInfo@GroupInfo {useRelays} + | isTrue useRelays = do + relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + pure (relays, relays) + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) APIListMembers groupId -> withUser $ \user -> CRGroupMembers user <$> withFastStore (\db -> getGroup db vr user groupId) -- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection) @@ -2564,13 +2609,13 @@ processChatCommand vr nm = \case let memberSupportChats = filter (isJust . supportChat) members pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) + CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db vr user cName processChatCommand vr nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - g <- withFastStore $ \db -> getGroup db vr user groupId - runUpdateGroupProfile user g p' + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + runUpdateGroupProfile user gInfo p' UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} ShowGroupProfile gName -> withUser $ \user -> @@ -2588,7 +2633,7 @@ processChatCommand vr nm = \case let userData = encodeShortLinkData $ GroupShortLinkData groupProfile crClientData = encodeJSON $ CRDataGroup groupLinkId -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMContact (Just userData) (Just crClientData) IKPQOff subMode + (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) (Just crClientData) IKPQOff subMode ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink gVar <- asks random gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId mRole subMode @@ -2616,11 +2661,12 @@ processChatCommand vr nm = \case gInfo <- getGroupInfo db vr user groupId gLink <- getGroupLink db user gInfo pure (gInfo, gLink) - setGroupLinkData user gInfo gLink + gLink' <- setGroupLinkData nm user gInfo gLink + pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" + unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible @@ -2628,12 +2674,11 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode -- TODO PQ should negotitate contact connection with PQSupportOn? -- TODO [certs rcv] - (connId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode + (connId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) -- TODO not sure it is correct to set connections status here? - lift $ setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -2787,8 +2832,7 @@ processChatCommand vr nm = \case | not (null fts) && all fileCancelledOrCompleteSMP fts -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do - fileAgentConnIds <- cancelSndFile user ftm fts True - deleteAgentConnectionsAsync fileAgentConnIds + cancelSndFile user ftm fts True cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId case (cref_, aci_) of @@ -2800,7 +2844,8 @@ processChatCommand vr nm = \case pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - (_chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" @@ -2812,7 +2857,7 @@ processChatCommand vr nm = \case | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> case xftpRcvFile of Nothing -> do - cancelRcvFileTransfer user ftr >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ftr ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do @@ -2917,7 +2962,7 @@ processChatCommand vr nm = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' getCurrentMigrations + chatMigrations <- map upMigration <$> withFastStore' (getCurrentMigrations Nothing) agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do @@ -3080,25 +3125,25 @@ processChatCommand vr nm = \case Just (PCEContact ct@Contact {activeConn}) -> case activeConn of Nothing -> connect' Nothing Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn False + ConnPrepared -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct - Just (PCEGroup _gInfo GroupMember {activeConn}) -> case activeConn of + Just (PCEGroup gInfo GroupMember {activeConn}) -> case activeConn of Nothing -> connect' groupLinkId Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn $ isJust groupLinkId + ConnPrepared -> joinPreparedConn' xContactId conn $ Just (Just gInfo) _ -> connect' groupLinkId xContactId -- why not "already connected" for host member? Nothing -> withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of Nothing -> case activeConn of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn False + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct Just gLinkId -> -- allow repeat contact request -- TODO [short links] is this branch needed? it probably remained from the time we created host contact connect' (Just gLinkId) Nothing Left conn_ -> case conn_ of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ isJust groupLinkId + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ groupLinkId $> Nothing -- TODO [short links] this is executed on repeat request after success -- it probably should send the second message without creating the second connection? Just Connection {xContactId} -> connect' groupLinkId xContactId @@ -3107,12 +3152,12 @@ processChatCommand vr nm = \case cReqHash = ConnReqUriHash . C.sha256Hash . strEncode cReqHash1 = cReqHash $ CRContactUri crData {crScheme = SSSimplex} cReqHash2 = cReqHash $ CRContactUri crData {crScheme = simplexChat} - joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} inGroup = do + joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup PQSupportOn + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ = do let inGroup = isJust groupLinkId @@ -3124,7 +3169,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ (groupLinkId $> Nothing) pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3139,7 +3184,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing False pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of @@ -3148,7 +3193,7 @@ processChatCommand vr nm = \case xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing False PQSupportOn + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3165,12 +3210,14 @@ processChatCommand vr nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Bool -> PQSupport -> CM Connection - joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup = do - let profileToSend = - if inGroup - then userProfileInGroup user incognitoProfile - else userProfileDirect user incognitoProfile Nothing True + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection + joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do + -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile + let profileToSend = case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_) subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode @@ -3270,49 +3317,41 @@ processChatCommand vr nm = \case void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' - runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse - runUpdateGroupProfile user (Group g@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} ms) p'@GroupProfile {displayName = n'} = do - assertUserGroupRole g GROwner + runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse + runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n'} = do + assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' - g' <- withStore $ \db -> updateGroupProfile db user g p' + gInfo' <- withStore $ \db -> updateGroupProfile db user gInfo p' msg <- case businessChat of Just BusinessChatInfo {businessId} -> do + ms <- withStore' $ \db -> getGroupMembers db vr user gInfo' let (newMs, oldMs) = partition (\m -> maxVersion (memberChatVRange m) >= businessChatPrefsVersion) ms -- this is a fallback to send the members with the old version correct profile of the business when preferences change unless (null oldMs) $ do GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} <- - withStore $ \db -> getGroupMemberByMemberId db vr user g businessId + withStore $ \db -> getGroupMemberByMemberId db vr user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs - void $ sendGroupMessage user g' Nothing recipients (XGrpInfo p'') + void $ sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' recipients = filter memberCurrentOrPending newMs - sendGroupMessage user g' Nothing recipients $ XGrpPrefs ps' + sendGroupMessage user gInfo' Nothing recipients $ XGrpPrefs ps' Nothing -> do - setGroupLinkData' - let recipients = filter memberCurrentOrPending ms - sendGroupMessage user g' Nothing recipients (XGrpInfo p') + setGroupLinkData' nm user gInfo' + recipients <- getRecipients + sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where - setGroupLinkData' :: CM () - setGroupLinkData' = - withFastStore' (\db -> runExceptT $ getGroupLink db user g') >>= \case - Right gLink@GroupLink {shortLinkDataSet} - | shortLinkDataSet -> void $ setGroupLinkData user g' gLink - _ -> pure () - let cd = CDGroupSnd g' Nothing + getRecipients + | isTrue (useRelays gInfo') = withFastStore' $ \db -> getGroupRelays db vr user gInfo' + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' + pure $ filter memberCurrentOrPending ms + let cd = CDGroupSnd gInfo' Nothing unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g' Nothing) ci] - createGroupFeatureChangedItems user cd CISndGroupFeature g g' - pure $ CRGroupUpdated user g g' Nothing - setGroupLinkData :: User -> GroupInfo -> GroupLink -> CM ChatResponse - setGroupLinkData user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do - conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile - crClientData = encodeJSON $ CRDataGroup groupLinkId - sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userData (Just crClientData)) - gLink' <- withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk - pure $ CRGroupLink user gInfo gLink' + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' Nothing) ci] + createGroupFeatureChangedItems user cd CISndGroupFeature gInfo gInfo' + pure $ CRGroupUpdated user gInfo gInfo' Nothing checkValidName :: GroupName -> CM () checkValidName displayName = do when (T.null displayName) $ throwChatError CEInvalidDisplayName {displayName, validName = ""} @@ -3359,14 +3398,14 @@ processChatCommand vr nm = \case ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci membership deletedTs) unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted user gInfo ciIds True (Just membership) let m = if moderation then Just membership else Nothing - if groupFeatureMemberAllowed SGFFullDelete membership gInfo + if groupFeatureUserAllowed SGFFullDelete gInfo then deleteGroupCIs user gInfo chatScopeInfo items m deletedTs else markGroupCIsDeleted user gInfo chatScopeInfo items m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do - g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroup db vr user - runUpdateGroupProfile user g $ update p + gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db vr user + runUpdateGroupProfile user gInfo $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -3409,7 +3448,7 @@ processChatCommand vr nm = \case forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case - FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs + FTRcv RcvFileTransfer {fileStatus = RFSComplete filePath, cryptoArgs} -> forward filePath cryptoArgs FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where @@ -3487,7 +3526,7 @@ processChatCommand vr nm = \case deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) `catchAllErrors` \case - e@(ChatErrorAgent NO_USER _) -> eToView e + e@(ChatErrorAgent NO_USER _ _) -> eToView e e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -3682,41 +3721,11 @@ processChatCommand vr nm = \case business = maybe False businessAddress settings contactData = ContactShortLinkData p msg business in encodeShortLinkData contactData - encodeShortLinkData :: J.ToJSON a => a -> UserLinkData - encodeShortLinkData d = - let s = LB.toStrict $ J.encode d - -- 10kb size limit for compression to be used is based on 13784 limit for link data - -- and the space reserved for the other fields in ConnLinkData encoding (most of these fields are currently unused). - s' - | B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s - | otherwise = s - in UserLinkData s' - decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) - decodeShortLinkData cData - | B.null s = pure Nothing - | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of - Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) - Z1.Skip -> pure Nothing - Z1.Decompress s' -> decode s' - | otherwise = decode s - where - decode s' = case J.eitherDecodeStrict s' of - Right d -> pure $ Just d - Left e -> Nothing <$ logError ("Error decoding link data: " <> tshow e) - s = linkUserData' cData updatePCCShortLinkData :: PendingContactConnection -> Profile -> CM (Maybe ShortLinkInvitation) updatePCCShortLinkData conn@PendingContactConnection {connLinkInv} profile = forM (connShortLink =<< connLinkInv) $ \_ -> do let userData = contactShortLinkData profile Nothing shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userData Nothing) - shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) - shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) - shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) - shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk - createdGroupLink :: CreatedLinkContact -> CreatedLinkContact - createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) - toShortGroupLink :: ShortLinkContact -> ShortLinkContact - toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId @@ -3798,7 +3807,8 @@ processChatCommand vr nm = \case sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user gInfo scope live itemTTL cmrs = do assertMultiSendable live cmrs - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope modsCompatVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs @@ -3829,6 +3839,7 @@ processChatCommand vr nm = \case Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do + -- TODO [channels fwd] single description for all recipients (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -4127,7 +4138,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `E.catchAny` \e -> do - eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing + eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) (AgentConnId "") Nothing E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -4139,165 +4150,7 @@ agentSubscriber = do where run action = action `catchAllErrors'` (eToView') -type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType (Maybe ClientServiceId))) - --- TODO [certs rcv] -subscribeUserConnections :: VersionRangeChat -> Bool -> AgentBatchSubscribe -> User -> CM () -subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do - -- get user connections - ce <- asks $ subscriptionEvents . config - (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- - if onlyNeeded - then do - (conns, entities) <- withStore' (`getConnectionsToSubscribe` vr) - let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities - pure (conns, cts, ucs, [], ms, sfts, rfts, pcs) - else do - withStore' unsetConnectionToSubscribe - (ctConns, cts) <- getContactConns - (ucConns, ucs) <- getUserContactLinkConns - (gs, mConns, ms) <- getGroupMemberConns - (sftConns, sfts) <- getSndFileTransferConns - (rftConns, rfts) <- getRcvFileTransferConns - (pcConns, pcs) <- getPendingContactConns - let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]]) - pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) - -- subscribe using batched commands - rs <- withAgent $ \a -> agentBatchSubscribe a conns - -- send connection events to view - contactSubsToView rs cts ce - unlessM (asks $ coreApi . config) $ do - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs - where - addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case - RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) - RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (mkPendingContactConnection c Nothing) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) - SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) - RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) - UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) - addConn :: Connection -> a -> Map ConnId a -> Map ConnId a - addConn = M.insert . aConnId - toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = - ShortGroupMember - { groupMemberId, - groupId, - memberName = localDisplayName, - connId = agentConnId - } - getContactConns :: CM ([ConnId], Map ConnId Contact) - getContactConns = do - cts <- withStore_ (`getUserContacts` vr) - let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts - pure (map fst cts', M.fromList cts') - getUserContactLinkConns :: CM ([ConnId], Map ConnId UserContact) - getUserContactLinkConns = do - (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) - let connIds = map aConnId cs - pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) - getGroupMemberConns = do - gs <- withStore_ getUserGroupsToSubscribe - let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs - pure (gs, map fst mPairs, M.fromList mPairs) - where - shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId - getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) - getSndFileTransferConns = do - sfts <- withStore_ getLiveSndFileTransfers - let connIds = map sndFileTransferConnId sfts - pure (connIds, M.fromList $ zip connIds sfts) - getRcvFileTransferConns :: CM ([ConnId], Map ConnId RcvFileTransfer) - getRcvFileTransferConns = do - rfts <- withStore_ getLiveRcvFileTransfers - let rftPairs = mapMaybe (\ft -> (,ft) <$> liveRcvFileTransferConnId ft) rfts - pure (map fst rftPairs, M.fromList rftPairs) - getPendingContactConns :: CM ([ConnId], Map ConnId PendingContactConnection) - getPendingContactConns = do - pcs <- withStore_ getPendingContactConnections - let connIds = map aConnId' pcs - pure (connIds, M.fromList $ zip connIds pcs) - contactSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId Contact -> Bool -> CM () - contactSubsToView rs cts ce = do - chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) - ifM (asks $ coreApi . config) (notifyAPI statuses) notifyCLI - where - notifyCLI = do - let cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs - toView . CEvtContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CEvtContactSubError user)) cErrors - notifyAPI = toView . CEvtNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) - statuses = M.foldrWithKey' addStatus [] cts - where - addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] - addStatus _ Contact {activeConn = Nothing} nss = nss - addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss = - let ns = (agentConnId, netStatus $ resultErr connId rs) - in ns : nss - netStatus :: Maybe ChatError -> NetworkStatus - netStatus = maybe NSConnected $ NSError . errorNetworkStatus - errorNetworkStatus :: ChatError -> String - errorNetworkStatus = \case - ChatErrorAgent (BROKER _ (NETWORK _)) _ -> "network" - ChatErrorAgent (SMP _ SMP.AUTH) _ -> "contact deleted" - e -> show e - -- TODO possibly below could be replaced with less noisy events for API - contactLinkSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId UserContact -> CM () - contactLinkSubsToView rs = toView . CEvtUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () - groupSubsToView rs gs ms ce = do - mapM_ groupSub $ - sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs - toViewTE . TEMemberSubSummary user $ map (uncurry MemberSubStatus) mRs - where - mRs = resultsFor rs ms - groupSub :: ShortGroup -> CM () - groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do - when ce $ mapM_ (toViewTE . uncurry (TEMemberSubError user g)) mErrors - toViewTE groupEvent - where - mErrors :: [(ShortGroupMember, ChatError)] - mErrors = - sortOn (\(ShortGroupMember {memberName = n}, _) -> n) - . filterErrors - $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs - groupEvent :: TerminalEvent - groupEvent - | membershipStatus == GSMemInvited = TEGroupInvitation user g - | null members = TEGroupEmpty user g - | otherwise = TEGroupSubscribed user g - sndFileSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId SndFileTransfer -> CM () - sndFileSubsToView rs sfts = do - let sftRs = resultsFor rs sfts - forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toViewTE . TESndFileSubError user ft - void . forkIO $ do - threadDelay 1000000 - when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $ - sendFileChunk user ft - rcvFileSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId RcvFileTransfer -> CM () - rcvFileSubsToView rs = mapM_ (toViewTE . uncurry (TERcvFileSubError user)) . filterErrors . resultsFor rs - pendingConnSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId PendingContactConnection -> CM () - pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs - withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchAllErrors` \e -> eToView e $> [] - filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] - filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) - resultsFor :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId a -> [(a, Maybe ChatError)] - resultsFor rs = M.foldrWithKey' addResult [] - where - addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,resultErr connId rs) - resultErr :: ConnId -> Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Maybe ChatError - resultErr connId rs = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId +type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) cleanupManager :: CM () cleanupManager = do @@ -4314,6 +4167,8 @@ cleanupManager = do forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay cleanupMessages `catchAllErrors` eToView + cleanupDeliveryTasks `catchAllErrors` eToView + cleanupDeliveryJobs `catchAllErrors` eToView -- TODO possibly, also cleanup async commands cleanupProbes `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval @@ -4345,6 +4200,14 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts withStore' (`deleteOldMessages` cutoffTs) + cleanupDeliveryTasks = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-(7 * nominalDay)) ts + withStore' (`deleteDoneDeliveryTasks` cutoffTs) + cleanupDeliveryJobs = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-(7 * nominalDay)) ts + withStore' (`deleteDoneDeliveryJobs` cutoffTs) cleanupProbes = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts @@ -4452,6 +4315,7 @@ chatCommandP = "/_app activate restore=" *> (APIActivateChat <$> onOffP), "/_app activate" $> APIActivateChat True, "/_app suspend " *> (APISuspendChat <$> A.decimal), + "/_connections diff" *> (ShowConnectionsDiff <$> (" show_ids=" *> onOffP <|> pure False)), "/_resubscribe all" $> ResubscribeAllConnections, -- deprecated, use /set file paths "/_temp_folder " *> (SetTempFolder <$> filePath), @@ -4486,8 +4350,8 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), - "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> textP)), + "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> textP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), @@ -4525,7 +4389,6 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, - "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), @@ -4644,8 +4507,8 @@ chatCommandP = "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/member support chats #" *> (ListMemberSupportChats <$> displayNameP), - "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), + "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> textP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> textP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), @@ -4699,7 +4562,7 @@ chatCommandP = "/feed " *> (SendMessageBroadcast . MCText <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), - ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))), + ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> textP))), "/last_item_id" *> (LastChatItemId <$> optional (A.space *> chatNameP) <*> (A.space *> A.decimal <|> pure 0)), "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index e3885a3c9a..9ebdd00b81 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -18,6 +18,7 @@ module Simplex.Chat.Library.Internal where +import qualified Codec.Compression.Zstd as Z1 import Control.Applicative ((<|>)) import Control.Concurrent.STM (retry) import Control.Logger.Simple @@ -26,9 +27,11 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson as J import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isDigit) import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) @@ -77,19 +80,19 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescr import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Client (getFastNetworkConfig, ipAddressProtected, withLockMap) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode) +import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..)) +import Simplex.Messaging.Compression (compressionLevel) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (MsgBody, MsgFlags (..), ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer) import qualified Simplex.Messaging.Protocol as SMP @@ -97,10 +100,10 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util import Simplex.Messaging.Version import System.FilePath (takeFileName, ()) -import System.IO (Handle, IOMode (..), SeekMode (..), hFlush) +import System.IO (Handle, IOMode (..), hFlush) import UnliftIO.Concurrent (forkFinally, mkWeakThreadId) import UnliftIO.Directory -import UnliftIO.IO (hClose, hSeek, hTell, openFile) +import UnliftIO.IO (hClose, openFile) import UnliftIO.STM maxMsgReactions :: Int @@ -346,9 +349,9 @@ prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool prohibitedSimplexLinks gInfo m ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) && maybe False (any ftIsSimplexLink) ft - where - ftIsSimplexLink :: FormattedText -> Bool - ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format + +ftIsSimplexLink :: FormattedText -> Bool +ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format roundedFDCount :: Int -> Int roundedFDCount n @@ -392,10 +395,6 @@ cancelFilesInProgress user filesInfo = do xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs lift $ agentXFTPDeleteSndFilesRemote user xsfIds lift $ agentXFTPDeleteRcvFiles xrfIds - let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs - smpRFConnIds = mapMaybe smpRcvFileConnId rfs - deleteAgentConnectionsAsync smpSFConnIds - deleteAgentConnectionsAsync smpRFConnIds where fileEnded CIFileInfo {fileStatus} = case fileStatus of Just (AFS _ status) -> ciFileEnded status @@ -405,12 +404,7 @@ cancelFilesInProgress user filesInfo = do updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO () updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do updateFileCancelled db user fileId CIFSSndCancelled - forM_ sfts updateSndFTCancelled - where - updateSndFTCancelled :: SndFileTransfer -> IO () - updateSndFTCancelled ft = unless (sndFTEnded ft) $ do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft + forM_ sfts $ \sft -> unless (sndFTEnded sft) $ updateSndFileStatus db sft FSCancelled updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO () updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do updateFileCancelled db user fileId CIFSRcvCancelled @@ -423,14 +417,6 @@ cancelFilesInProgress user filesInfo = do FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs) FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs) _ -> (sfs, rfs) - smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId - smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline} - | isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId - | otherwise = Nothing - smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId - smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline} - | isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft - | otherwise = Nothing sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete deleteFilesLocally :: [CIFileInfo] -> CM () @@ -673,8 +659,8 @@ receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do rctFileCancelled :: ChatError -> Bool rctFileCancelled = \case - ChatErrorAgent (SMP _ SMP.AUTH) _ -> True - ChatErrorAgent (CONN DUPLICATE _) _ -> True + ChatErrorAgent (SMP _ SMP.AUTH) _ _ -> True + ChatErrorAgent (CONN DUPLICATE _) _ _ -> True _ -> False acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem @@ -684,13 +670,6 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI _ -> throwChatError $ CEFileAlreadyReceiving fName vr <- chatVersionRange case (xftpRcvFile, fileConnReq) of - -- direct file protocol - (Nothing, Just connReq) -> do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XFileAcpt fName - connIds <- joinAgentConnectionAsync user True connReq dm subMode - filePath <- getRcvFilePath fileId filePath_ fName True - withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode -- XFTP (Just XFTPRcvFile {userApprovedRelays = approvedBeforeReady}, _) -> do let userApproved = approvedBeforeReady || userApprovedRelays @@ -703,23 +682,24 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI pure (ci, rfd) receiveViaCompleteFD user fileId rfd userApproved cryptoArgs pure ci + (Nothing, Just _fileConnReq) -> throwChatError $ CEException "accepting file via a separate connection is deprecated" -- group & direct file protocol _ -> do chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId _, Nothing) -> do ct <- withStore $ \db -> getContact db vr user contactId - acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg + acceptFile $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId _, Just memId) -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId + acceptFile $ \msg -> void $ sendDirectMemberMessage conn msg groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where - acceptFile :: CommandFunction -> (ChatMsgEvent 'Json -> CM ()) -> CM AChatItem - acceptFile cmdFunction send = do + acceptFile :: (ChatMsgEvent 'Json -> CM ()) -> CM AChatItem + acceptFile send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline vr <- chatVersionRange @@ -731,11 +711,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI send $ XFileAcptInv sharedMsgId Nothing fName pure ci | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName - | otherwise -> do - -- accepting via a new connection - subMode <- chatReadVar subscriptionMode - connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode - withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnNew filePath subMode + | otherwise -> throwChatError $ CEException "accepting file via a separate connection is deprecated" receiveInline :: CM Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -951,7 +927,7 @@ acceptGroupJoinRequestAsync (groupMemberId, memberId) <- withStore $ \db -> createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let Profile {displayName} = userProfileInGroup user (fromIncognitoProfile <$> incognitoProfile) + let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkInv $ @@ -1077,7 +1053,7 @@ introduceToRemaining vr user gInfo m = do introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo m) msgScope + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope sendIntroductions introduceToMembers where sendIntroductions members = do @@ -1092,7 +1068,7 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju processIntro intro `catchAllErrors` eToView memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = - let mInfo = memberInfo reMember + let mInfo = memberInfo gInfo reMember mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] @@ -1108,6 +1084,34 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent +userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile +userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks +{-# INLINE userProfileInGroup #-} + +userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile +userProfileInGroup' User {profile = p} allowSimplexLinks incognitoProfile = + let p' = fromMaybe (fromLocalProfile p) incognitoProfile + in redactedMemberProfile allowSimplexLinks p' + +memberInfo :: GroupInfo -> GroupMember -> MemberInfo +memberInfo g m@GroupMember {memberId, memberRole, memberProfile, activeConn} = + MemberInfo + { memberId, + memberRole, + v = ChatVersionRange . peerChatVRange <$> activeConn, + profile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile memberProfile + } + where + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g + +redactedMemberProfile :: Bool -> Profile -> Profile +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} + where + removeSimplexLink s + | allowSimplexLinks = Just s + | otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s + sendHistory :: User -> GroupInfo -> GroupMember -> CM () sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = @@ -1216,6 +1220,58 @@ splitFileDescr partSize rfdText = splitParts 1 rfdText then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM () +setGroupLinkData' nm user gInfo = + withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case + Right gLink@GroupLink {shortLinkDataSet} + | shortLinkDataSet -> void $ setGroupLinkData nm user gInfo gLink + _ -> pure () + +setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink +setGroupLinkData nm user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do + vr <- chatVersionRange + conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo + let userData = encodeShortLinkData $ GroupShortLinkData groupProfile + crClientData = encodeJSON $ CRDataGroup groupLinkId + sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userData (Just crClientData)) + withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk + +encodeShortLinkData :: J.ToJSON a => a -> UserLinkData +encodeShortLinkData d = + let s = LB.toStrict $ J.encode d + -- 10kb size limit for compression to be used is based on 13784 limit for link data + -- and the space reserved for the other fields in ConnLinkData encoding (most of these fields are currently unused). + s' + | B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s + | otherwise = s + in UserLinkData s' + +decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) +decodeShortLinkData cData + | B.null s = pure Nothing + | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of + Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) + Z1.Skip -> pure Nothing + Z1.Decompress s' -> decode s' + | otherwise = decode s + where + decode s' = case J.eitherDecodeStrict s' of + Right d -> pure $ Just d + Left e -> Nothing <$ logError ("Error decoding link data: " <> tshow e) + s = linkUserData' cData + +shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) +shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) + +shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) +shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk + +createdGroupLink :: CreatedLinkContact -> CreatedLinkContact +createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) + +toShortGroupLink :: ShortLinkContact -> ShortLinkContact +toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -1393,31 +1449,30 @@ getChatScopeInfo vr user = \case supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId pure $ GCSIMemberSupport (Just supportMem) --- TODO [knocking] refactor to GroupChatScope -> "a" function, "a" is some new type? Or possibly split to get scope/get recipients steps -getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> VersionChat -> CM (Maybe GroupChatScopeInfo, [GroupMember]) -getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion = case scope of - Nothing -> do - unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - let recipients = filter memberCurrent ms - pure (Nothing, recipients) - Just (GCSMemberSupport Nothing) -> do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs - when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" - let scopeInfo = GCSIMemberSupport Nothing - pure (Just scopeInfo, rcpModMs') - Just (GCSMemberSupport (Just gmId)) -> do - unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId - unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" - let scopeInfo = GCSIMemberSupport (Just supportMem) - if memberStatus supportMem == GSMemPendingApproval - then pure (Just scopeInfo, [supportMem]) - else do +getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] +getGroupRecipients vr user gInfo@GroupInfo {useRelays, membership} scopeInfo modsCompatVersion + | isTrue useRelays && not (isMemberRelay membership) = do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + withFastStore' $ \db -> getGroupRelays db vr user gInfo + | otherwise = case scopeInfo of + Nothing -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure $ filter memberCurrent ms + Just (GCSIMemberSupport Nothing) -> do modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs - pure (Just scopeInfo, [supportMem] <> rcpModMs') + when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" + pure rcpModMs' + Just (GCSIMemberSupport (Just supportMem)) -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" + if memberStatus supportMem == GSMemPendingApproval + then pure [supportMem] + else do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + pure $ [supportMem] <> rcpModMs' where compatible GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion @@ -1441,21 +1496,26 @@ mkGroupChatScope gInfo@GroupInfo {membership} m | otherwise = pure (gInfo, m, Nothing) -mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) -mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = +mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope - (_, _, Nothing) -> case msgScope_ of - Nothing -> pure (gInfo, m, Nothing) - Just (MSMember mId) - | sameMemberId mId membership -> do - (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo - pure (gInfo', m, Just scopeInfo) - | otherwise -> do - referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId - -- TODO [knocking] return patched _referredMember' too? - (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember - pure (gInfo, m, Just scopeInfo) + (_, _, Nothing) + | isReport mc -> do + -- TODO [knocking] return patched _m'? + (_m', scopeInfo) <- mkMemberSupportChatInfo m -- only support scope member can send a report (m is sender) + pure (gInfo, m, Just scopeInfo) + | otherwise -> case msgScope_ of + Nothing -> pure (gInfo, m, Nothing) + Just (MSMember mId) + | sameMemberId mId membership -> do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | otherwise -> do + referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + -- TODO [knocking] return patched _referredMember'? + (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember + pure (gInfo, m, Just scopeInfo) mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo) mkGroupSupportChatInfo gInfo@GroupInfo {membership} = @@ -1483,49 +1543,13 @@ mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = let scopeInfo = GCSIMemberSupport {groupMember_ = Just m} in pure (m, scopeInfo) -sendFileChunk :: User -> SndFileTransfer -> CM () -sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = - unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do - vr <- chatVersionRange - withStore' (`createSndFileChunk` ft) >>= \case - Just chunkNo -> sendFileChunkNo ft chunkNo - Nothing -> do - ci <- withStore $ \db -> do - liftIO $ updateSndFileStatus db ft FSComplete - liftIO $ deleteSndFileChunks db ft - updateDirectCIFileStatus db vr user fileId CIFSSndComplete - toView $ CEvtSndFileComplete user ci ft - lift $ closeFileHandle fileId sndFiles - deleteAgentConnectionAsync acId - -sendFileChunkNo :: SndFileTransfer -> Integer -> CM () -sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do - chunkBytes <- readFileChunk ft chunkNo - (msgId, _) <- withAgent $ \a -> sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} - withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId - -readFileChunk :: SndFileTransfer -> Integer -> CM ByteString -readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do - fsFilePath <- lift $ toFSFilePath filePath - read_ fsFilePath `catchThrow` (ChatError . CEFileRead filePath . show) - where - read_ fsFilePath = do - h <- getFileHandle fileId fsFilePath sndFiles ReadMode - pos <- hTell h - let pos' = (chunkNo - 1) * chunkSize - when (pos /= pos') $ hSeek h AbsoluteSeek pos' - liftIO . B.hGet h $ fromInteger chunkSize - -parseFileChunk :: ByteString -> CM FileChunk -parseFileChunk = liftEither . first (ChatError . CEFileRcvChunk) . smpDecode - appendFileChunk :: RcvFileTransfer -> Integer -> ByteString -> Bool -> CM () appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitation = FileInvitation {fileName}} chunkNo chunk final = case fileStatus of - RFSConnected RcvFileInfo {filePath} -> append_ filePath + RFSConnected filePath -> append_ filePath -- sometimes update of file transfer status to FSConnected -- doesn't complete in time before MSG with first file chunk - RFSAccepted RcvFileInfo {filePath} -> append_ filePath + RFSAccepted filePath -> append_ filePath RFSCancelled _ -> pure () _ -> throwChatError $ CEFileInternal "receiving file transfer not in progress" where @@ -1567,9 +1591,9 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) -cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchAllErrors` (\e -> eToView e $> fileConnId) +cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM () +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile} = + cancel' `catchAllErrors` \e -> eToView e where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1581,40 +1605,31 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin Just XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId), agentRcvFileDeleted} -> unless agentRcvFileDeleted $ agentXFTPDeleteRcvFile aFileId fileId _ -> pure () - pure fileConnId - fileConnId = if isNothing xftpRcvFile && isNothing rcvFileInline then liveRcvFileTransferConnId ft else Nothing -cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] +cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM () cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) `catchAllErrors` eToView case xftpSndFile of Nothing -> - catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) + forM_ fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchAllErrors` eToView - pure [] --- TODO v6.0 remove -cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) -cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = - if fileStatus == FSCancelled || fileStatus == FSComplete - then pure Nothing - else cancel' `catchAllErrors` (\e -> eToView e $> fileConnId) +cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM () +cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, fileStatus, fileInline} sendCancel = + unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ + cancel' `catchAllErrors` \e -> eToView e where cancel' = do - withStore' $ \db -> do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft + withStore' $ \db -> updateSndFileStatus db ft FSCancelled when sendCancel $ case fileInline of Just _ -> do vr <- chatVersionRange (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId void $ sendDirectMessage_ conn (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) - _ -> withAgent $ \a -> void . sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel - pure fileConnId - fileConnId = if isNothing fileInline then Just acId else Nothing + _ -> throwChatError $ CEException "cancelSndFileTransfer: cancelling file via a separate connection is deprecated" closeFileHandle :: Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> CM' () closeFileHandle fileId files = do @@ -1827,7 +1842,7 @@ deliverMessagesB msgReqs = do Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError - prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing + prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae (AgentConnId "") Nothing createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption)) createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds @@ -1871,7 +1886,8 @@ sendGroupMessages user gInfo scope members events = do _ -> False sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members - profileUpdateEvent = XInfo $ redactedMemberProfile $ fromLocalProfile p + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' profileUpdateEvent currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -1972,13 +1988,22 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = case memberConn m of - Nothing -> pendingOrForwarded - Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing - | connInactive conn -> Just MSAPending - | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn - | otherwise -> pendingOrForwarded +memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember {memberRole, memberStatus} + -- groups with relays require newer version - we don't need to check member version for batching and forwarding support + | isTrue useRelays = + if + -- if user is chat relay, send to all non chat relay members + | isMemberRelay membership && not (isMemberRelay m) -> MSASendBatched . snd <$> readyMemberConn m + -- if user is not chat relay, send only to chat relays + | not (isMemberRelay membership) && isMemberRelay m -> MSASendBatched . snd <$> readyMemberConn m + | otherwise -> Nothing -- TODO [channels fwd] MSAForwarded to create GSSForwarded snd statuses? + | otherwise = case memberConn m of + Nothing -> pendingOrForwarded + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded where sendBatchedOrSeparate conn -- admin doesn't support batch forwarding - send messages separately so that admin can forward one by one @@ -1989,7 +2014,7 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = GCUserMember -> Nothing -- shouldn't happen GCInviteeMember -> Just MSAPending GCHostMember -> Just MSAPending - GCPreMember -> forwardSupportedOrPending (invitedByGroupMemberId $ membership gInfo) + GCPreMember -> forwardSupportedOrPending (invitedByGroupMemberId membership) GCPostMember -> forwardSupportedOrPending (invitedByGroupMemberId m) where forwardSupportedOrPending invitingMemberId_ @@ -2010,6 +2035,17 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = XGrpMsgForward {} -> True _ -> False +-- Should match memberSendAction logic +readyMemberConn :: GroupMember -> Maybe (GroupMemberId, Connection) +readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}, memberStatus} + | (connStatus == ConnReady || connStatus == ConnSndReady) + && not (connDisabled conn) + && not (connInactive conn) + && memberStatus /= GSMemRejected = + Just (groupMemberId, conn) + | otherwise = Nothing +readyMemberConn GroupMember {activeConn = Nothing} = Nothing + sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) @@ -2041,7 +2077,8 @@ saveDirectRcvMSG :: MsgEncodingI e => Connection -> MsgMeta -> MsgBody -> ChatMe saveDirectRcvMSG conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody} + brokerTs = metaBrokerTs agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) @@ -2050,7 +2087,8 @@ saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connectio saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody} + brokerTs = metaBrokerTs agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) @@ -2064,22 +2102,27 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta _ -> throwError e pure (am', conn', msg) -saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> CM RcvMessage -saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do - let newMsg = NewRcvMessage {chatMsgEvent, msgBody} +saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) +saveGroupFwdRcvMsg user GroupInfo {groupId, useRelays} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do + let newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember - withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) - `catchAllErrors` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do - vr <- chatVersionRange - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGroupMemberId - if sameMemberId refMemberId am - then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" - throwError e - _ -> throwError e + -- TODO [channels fwd] TBC highlighting difference between deduplicated messages (useRelays branch) + withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case + Right msg -> pure $ Just msg + Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} + | isTrue useRelays -> pure Nothing -- with chat relays, duplicates are expected + | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of + (Just authorGMId, Nothing) -> do + vr <- chatVersionRange + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId + if sameMemberId refMemberId am + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError $ ChatErrorStore e + _ -> throwError $ ChatErrorStore e + Left e -> throwError $ ChatErrorStore e saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9ecc09004e..75eb208962 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -22,12 +22,14 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (lefts, partitionEithers, rights) +import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (foldl') -import Data.List.NonEmpty (NonEmpty (..), (<|)) +import Data.List (find, foldl') +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -41,8 +43,10 @@ import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -51,6 +55,7 @@ import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Direct +import Simplex.Chat.Store.Delivery import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages @@ -64,11 +69,13 @@ import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (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 +import Simplex.Messaging.Agent.Client (getAgentWorker, waitForWork, withWork_, withWorkItems) +import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (ProxyClientError (..)) +import Simplex.Messaging.Client (ProxyClientError (..), NetworkRequestMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -82,6 +89,7 @@ import Simplex.Messaging.Util import Simplex.Messaging.Version import qualified System.FilePath as FP import Text.Read (readMaybe) +import UnliftIO.Concurrent (forkIO) import UnliftIO.Directory import UnliftIO.STM @@ -96,13 +104,13 @@ processAgentMessage _ _ (DEL_RCVQS delQs) = processAgentMessage _ _ (DEL_CONNS connIds) = toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - eToView $ ChatErrorAgent e Nothing + eToView $ ChatErrorAgent e (AgentConnId "") Nothing processAgentMessage corrId connId msg = do - lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) + lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here - critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case + critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) @@ -113,43 +121,28 @@ processAgentMessage corrId connId msg = do -- - without ACK the message delivery will be stuck, -- - with ACK message will be lost, as it failed to be saved. -- Full app restart is likely to resolve database condition and the message will be received and processed again. -critical :: CM a -> CM a -critical a = +critical :: ConnId -> CM a -> CM a +critical agentConnId a = a `catchAllErrors` \case - ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing + ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) (AgentConnId agentConnId) Nothing e -> throwError e processAgentMessageNoConn :: AEvent 'AENone -> CM () processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CEvtHostConnected p h DISCONNECT p h -> hostEvent $ CEvtHostDisconnected p h - DOWN srv conns -> serverEvent srv conns NSDisconnected CEvtContactsDisconnected - UP srv conns -> serverEvent srv conns NSConnected CEvtContactsSubscribed + DOWN srv conns -> serverEvent srv SSPending conns + UP srv conns -> serverEvent srv SSActive conns SUSPENDED -> toView CEvtChatSuspended DEL_USER agentUserId -> toView $ CEvtAgentUserDeleted agentUserId - ERRS cErrs -> errsEvent cErrs + ERRS cErrs -> errsEvent $ L.toList cErrs where hostEvent :: ChatEvent -> CM () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns nsStatus event = do - chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds - ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI - where - connIds = map AgentConnId conns - notifyAPI = toView . CEvtNetworkStatus nsStatus - notifyCLI = do - cs <- withStore' (`getConnectionsContacts` conns) - toView $ event srv cs + serverEvent :: SMPServer -> SubscriptionStatus -> [ConnId] -> CM () + serverEvent srv nsStatus conns = toView $ CEvtSubscriptionStatus srv nsStatus $ map AgentConnId conns errsEvent :: [(ConnId, AgentErrorType)] -> CM () - errsEvent cErrs = do - vr <- chatVersionRange - errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs) - toView $ CEvtChatErrors errs - where - getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError - getChatErr vr db (connId, err) = - let acId = AgentConnId connId - in ChatErrorAgent err <$> (getUserByAConnId db acId $>>= \user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId)) + errsEvent = toView . CEvtChatErrors . map (\(cId, e) -> ChatErrorAgent e (AgentConnId cId) Nothing) processAgentMsgSndFile :: ACorrId -> SndFileId -> AEvent 'AESndFile -> CM () processAgentMsgSndFile _corrId aFileId msg = do @@ -213,7 +206,8 @@ processAgentMsgSndFile _corrId aFileId msg = do Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do - ms <- withStore' $ \db -> getGroupMembers db vr user g + -- TODO [channels fwd] single description for all recipients + ms <- getRecipients let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) @@ -225,17 +219,14 @@ processAgentMsgSndFile _corrId aFileId msg = do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileCompleteXFTP user ci' ft where + getRecipients + | isTrue (useRelays g) = withStore' $ \db -> getGroupRelays db vr user g + | otherwise = withStore' $ \db -> getGroupMembers db vr user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') where - mConns' = mapMaybe useMember ms + mConns' = mapMaybe readyMemberConn ms sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts - -- Should match memberSendAction logic - useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} - | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) && not (connInactive conn) = - Just (groupMemberId, conn) - | otherwise = Nothing - useMember _ = Nothing _ -> pure () _ -> pure () -- TODO error? SFWARN e -> do @@ -359,7 +350,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- as in this case no need to ACK message - we can't process messages for this connection anyway. -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition -- that will be resolved with app restart. - entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -370,10 +361,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processDirectMessage agentMessage entity conn contact_ RcvGroupMsgConnection conn gInfo m -> processGroupMessage agentMessage entity conn gInfo m - RcvFileConnection conn ft -> - processRcvFileConn agentMessage entity conn ft - SndFileConnection conn ft -> - processSndFileConn agentMessage entity conn ft UserContactConnection conn uc -> processUserContactRequest agentMessage entity conn uc where @@ -414,12 +401,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CONF confId pqSupport _ connInfo -> do conn' <- processCONFpqSupport conn pqSupport -- [incognito] send saved profile - (conn'', inGroup) <- saveConnInfo conn' connInfo + (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = - if inGroup - then userProfileInGroup user (fromLocalProfile <$> incognitoProfile) - else userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + let profileToSend = case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -444,13 +430,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MWARN _ err -> processConnMWARN connEntity conn err MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS _ err -> do -- error cannot be AUTH error here - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -460,12 +446,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> case cReq of - directConnReq@(CRInvitationUri _ _) -> do - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContDirect db user ct - forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> - sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont + CRInvitationUri _ _ -> withStore' $ \db -> setConnConnReqInv db user connId cReq CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] @@ -551,7 +532,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user (fromLocalProfile <$> incognitoProfile) + let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -569,52 +550,42 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ processContactProfileUpdate ct profile prepared XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" - CON pqEnc -> - withStore' (\db -> getViaGroupMember db vr user ct) >>= \case - Nothing -> do - when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc - let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - -- [incognito] print incognito profile used for this contact - incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - lift $ setContactNetworkStatus ct' NSConnected - toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) - let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo $ Just pqEnc) Nothing - -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? - when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of - (Nothing, Nothing) -> do - unlessM (withStore' $ \db -> checkContactHasItems db user ct') $ - createInternalChatItem user (CDDirectSnd ct') CIChatBanner (Just epochStart) - createE2EItem - createFeatureEnabledItems user ct' - (Just PreparedContact {connLinkToConnect = ACCL _ (CCLink cReq _)}, _) -> - unless (Just pqEnc == connRequestPQEncryption cReq) createE2EItem - (_, Just connReqId) -> - withStore' (\db -> getContactRequest' db user connReqId) >>= \case - Just UserContactRequest {pqSupport} | CR.pqSupportToEnc pqSupport == pqEnc -> pure () - _ -> createE2EItem - when (contactConnInitiated conn') $ do - let Connection {groupLinkId} = conn' - doProbeContacts = isJust groupLinkId - probeMatchingContactsAndMembers ct' (contactConnIncognito ct') doProbeContacts - withStore' $ \db -> resetContactConnInitiated db user conn' - forM_ viaUserContactLink $ \userContactLinkId -> do - (ucl, gli_) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - -- let UserContactLink {addressSettings = AddressSettings {autoReply}} = ucl - when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only - -- TODO REMOVE LEGACY vvv - forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId - subMode <- chatReadVar subscriptionMode - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode + CON pqEnc -> do + when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc + let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + -- [incognito] print incognito profile used for this contact + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo $ Just pqEnc) Nothing + -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? + when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of + (Nothing, Nothing) -> do + unlessM (withStore' $ \db -> checkContactHasItems db user ct') $ + createInternalChatItem user (CDDirectSnd ct') CIChatBanner (Just epochStart) + createE2EItem + createFeatureEnabledItems user ct' + (Just PreparedContact {connLinkToConnect = ACCL _ (CCLink cReq _)}, _) -> + unless (Just pqEnc == connRequestPQEncryption cReq) createE2EItem + (_, Just connReqId) -> + withStore' (\db -> getContactRequest' db user connReqId) >>= \case + Just UserContactRequest {pqSupport} | CR.pqSupportToEnc pqSupport == pqEnc -> pure () + _ -> createE2EItem + when (contactConnInitiated conn') $ do + probeMatchingMembers ct' (contactConnIncognito ct') + withStore' $ \db -> resetContactConnInitiated db user conn' + forM_ viaUserContactLink $ \userContactLinkId -> do + (ucl, gli_) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + -- let UserContactLink {addressSettings = AddressSettings {autoReply}} = ucl + when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only + -- TODO REMOVE LEGACY vvv + forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do + groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId + subMode <- chatReadVar subscriptionMode + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + gVar <- asks random + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode -- TODO REMOVE LEGACY ^^^ - Just (gInfo, m@GroupMember {activeConn}) -> - when (maybe False ((== ConnReady) . connStatus) activeConn) $ do - notifyMemberConnected gInfo m $ Just ct - let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True SENT msgId proxy -> do void $ continueSending connEntity conn sentMsgDeliveryEvent conn msgId @@ -665,7 +636,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (directOrUsed ct && sqSecured) $ do - lift $ setContactNetworkStatus ct NSConnected toView $ CEvtContactSndReady user ct when (connChatVersion >= batchSend2Version) $ forM_ viaUserContactLink $ \userContactLinkId -> do (ucl, _) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId @@ -679,14 +649,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processConnMWARN connEntity conn err MERR msgId err -> do updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -766,7 +736,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user (fromLocalProfile <$> incognitoProfile) + let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do @@ -779,7 +749,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpMemInfo memId _memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -859,24 +830,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = userProfileInGroup user (fromIncognitoProfile <$> incognitoProfile) + profileToSend = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected + notifyMemberConnected gInfo m Nothing let memCategory = memberCategory m - withStore' (\db -> getViaGroupContact db vr user m) >>= \case - Nothing -> do - notifyMemberConnected gInfo m Nothing - let connectedIncognito = memberIncognito membership - when (memCategory == GCPreMember) $ - probeMatchingMemberContact m connectedIncognito - Just ct@Contact {activeConn} -> - forM_ activeConn $ \Connection {connStatus} -> - when (connStatus == ConnReady) $ do - notifyMemberConnected gInfo m $ Just ct - let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memCategory == GCPreMember && not (memberPending membership)) $ - probeMatchingContactsAndMembers ct connectedIncognito True + connectedIncognito = memberIncognito membership + when (memCategory == GCPreMember) $ + probeMatchingMemberContact m connectedIncognito sendXGrpMemCon memCategory where GroupMember {memberId} = m @@ -898,12 +860,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- possible improvement is to choose scope based on event (some events specify scope) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () - (fwdScopesMsgs, shouldDelConns) <- foldM (processAChatMsg gInfo' m' tags eInfo) (M.empty, False) aChatMsgs - when (isUserGrpFwdRelay gInfo') $ do - unless (blockedByAdmin m) $ - forM_ (M.assocs fwdScopesMsgs) $ \(groupForwardScope, fwdMsgs) -> - forwardMsgs groupForwardScope (L.reverse fwdMsgs) `catchAllErrors` eToView - when shouldDelConns $ deleteGroupConnections gInfo' True + newDeliveryTasks <- reverse <$> foldM (processAChatMsg gInfo' m' tags eInfo) [] aChatMsgs + shouldDelConns <- + if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) + then createDeliveryTasks gInfo' m' newDeliveryTasks + else pure False withRcpt <- checkSendRcpt $ rights aChatMsgs pure (withRcpt, shouldDelConns) where @@ -914,72 +875,69 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -> GroupMember -> TVar [Text] -> Text - -> (Map GroupForwardScope (NonEmpty (ChatMessage 'Json)), ShouldDeleteGroupConns) + -> [NewMessageDeliveryTask] -> Either String AChatMessage - -> CM (Map GroupForwardScope (NonEmpty (ChatMessage 'Json)), ShouldDeleteGroupConns) - processAChatMsg gInfo' m' tags eInfo (fwdScopeMap, shouldDelConns) = \case + -> CM [NewMessageDeliveryTask] + processAChatMsg gInfo' m' tags eInfo newDeliveryTasks = \case Right (ACMsg SJson chatMsg) -> do - (cmFwdScope_, cmShouldDelConns) <- - processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> (Nothing, False) - let fwdScopeMap' = - case cmFwdScope_ of - Nothing -> fwdScopeMap - Just cmFwdScope -> M.alter (Just . maybe [chatMsg] (chatMsg <|)) cmFwdScope fwdScopeMap - shouldDelConns' = shouldDelConns || cmShouldDelConns - pure (fwdScopeMap', shouldDelConns') + newTask_ <- processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> Nothing + pure $ maybe newDeliveryTasks (: newDeliveryTasks) newTask_ Right (ACMsg SBinary chatMsg) -> do void (processEvent gInfo' m' tags eInfo chatMsg) `catchAllErrors` \e -> eToView e - pure (fwdScopeMap, shouldDelConns) + pure newDeliveryTasks Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e eToView (ChatError . CEException $ "error parsing chat message: " <> e) - pure (fwdScopeMap, shouldDelConns) - processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> TVar [Text] -> Text -> ChatMessage e -> CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + pure newDeliveryTasks + processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> TVar [Text] -> Text -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do let tag = toCMEventTag chatMsgEvent atomically $ modifyTVar' tags (tshow tag :) logInfo $ "group msg=" <> tshow tag <> " " <> eInfo let body = chatMsgToBody chatMsg - (m'', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg - -- ! see isForwardedGroupMsg: processing functions should return GroupForwardScope for same events - case event of - XMsgNew mc -> memberCanSend m'' scope $ (,False) <$> newGroupContentMessage gInfo' m'' mc msg brokerTs False + (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg + -- ! see isForwardedGroupMsg: processing functions should return DeliveryJobScope for same events + deliveryJobScope_ <- case event of + XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False where ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> (,False) <$> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ (,False) <$> groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live - XMsgDel sharedMsgId memberId scope_ -> (,False) <$> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs - XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> (,False) <$> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live + XMsgDel sharedMsgId memberId scope_ -> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs + XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile - XFile fInv -> (Nothing, False) <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs - XFileCancel sharedMsgId -> (,False) <$> xFileCancelGroup gInfo' m'' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> (Nothing, False) <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName - XInfo p -> (,False) <$> xInfoMember gInfo' m'' p brokerTs - XGrpLinkMem p -> (Nothing, False) <$ xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt acceptance role memberId -> (Nothing, False) <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs - XGrpMemNew memInfo msgScope -> (,False) <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs - XGrpMemIntro memInfo memRestrictions_ -> (Nothing, False) <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ - XGrpMemInv memId introInv -> (Nothing, False) <$ xGrpMemInv gInfo' m'' memId introInv - XGrpMemFwd memInfo introInv -> (Nothing, False) <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> (,False) <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> (,False) <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs - XGrpMemCon memId -> (Nothing, False) <$ xGrpMemCon gInfo' m'' memId + XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo' m'' p brokerTs + XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p + XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ + XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv + XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages -> case encoding @e of SJson -> xGrpMemDel gInfo' m'' memId withMessages chatMsg msg brokerTs False - SBinary -> pure (Nothing, False) -- impossible - XGrpLeave -> (,False) <$> xGrpLeave gInfo' m'' msg brokerTs - XGrpDel -> (Just GFSAll, True) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> (,False) <$> xGrpInfo gInfo' m'' p' msg brokerTs - XGrpPrefs ps' -> (,False) <$> xGrpPrefs gInfo' m'' ps' + SBinary -> pure Nothing -- impossible + XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> Just (DJSGroup {jobSpec = DJRelayRemoved}) <$ xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' -- TODO [knocking] why don't we forward these messages? - XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ (Nothing, False) <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs - XGrpMsgForward memberId memberName msg' msgTs -> (Nothing, False) <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs - XInfoProbe probe -> (Nothing, False) <$ xInfoProbe (COMGroupMember m'') probe - XInfoProbeCheck probeHash -> (Nothing, False) <$ xInfoProbeCheck (COMGroupMember m'') probeHash - XInfoProbeOk probe -> (Nothing, False) <$ xInfoProbeOk (COMGroupMember m'') probe - BFileChunk sharedMsgId chunk -> (Nothing, False) <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta - _ -> (Nothing, False) <$ messageError ("unsupported message: " <> tshow event) + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId memberName msg' msgTs -> Nothing <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs brokerTs + XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe + XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash + XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta + _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) + forM deliveryJobScope_ $ \jobScope -> + -- TODO [channels fwd] XMsgNew to return messageFromChannel + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -991,58 +949,34 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = hasDeliveryReceipt (toCMEventTag chatMsgEvent) - -- TODO forwardMsgs member retrieval can be further optimized: - -- - move remaining filters to SQL (memberCurrentOrPending, memberCurrent) - -- - create new GroupForwardScope for reports to avoid post-filtering moderators in msgsForwardedToMember - -- as an additional step, instead initially retrieve only moderators - -- (reuse getForwardIntroducedModerators, getForwardInvitedModerators + filters) - -- - new GroupForwardScope for excluding members on XGrpMemRestrict - forwardMsgs :: GroupForwardScope -> NonEmpty (ChatMessage 'Json) -> CM () - forwardMsgs groupForwardScope fwdMsgs = do - ms <- buildMemberList - let GroupMember {memberId} = m - memberName = Just $ memberShortenedName m - events = L.map (\cm -> XGrpMsgForward memberId memberName cm brokerTs) fwdMsgs - unless (null ms) $ void $ sendGroupMessages_ user gInfo ms events + createDeliveryTasks :: GroupInfo -> GroupMember -> [NewMessageDeliveryTask] -> CM ShouldDeleteGroupConns + createDeliveryTasks gInfo'@GroupInfo {groupId = gId} m' newDeliveryTasks = do + let relayRemovedTask_ = find (\NewMessageDeliveryTask {jobScope} -> isRelayRemoved jobScope) newDeliveryTasks + createdDeliveryTasks <- case relayRemovedTask_ of + Nothing -> do + withStore' $ \db -> + forM_ newDeliveryTasks $ \newTask -> + createMsgDeliveryTask db gInfo' m' newTask + pure newDeliveryTasks + Just relayRemovedTask -> do + -- if relay is removed, delete all other tasks and jobs + withStore' $ \db -> do + deleteGroupDeliveryTasks db gInfo' + deleteGroupDeliveryJobs db gInfo' + createMsgDeliveryTask db gInfo' m' relayRemovedTask + pure [relayRemovedTask] + lift $ forM_ (uniqueWorkerScopes createdDeliveryTasks) $ \workerScope -> + getDeliveryTaskWorker True (gId, workerScope) + pure $ isJust relayRemovedTask_ where - buildMemberList = case groupForwardScope of - GFSAll -> do - ms <- getAllIntroducedAndInvited - pure $ filter (\mem -> memberCurrentOrPending mem && msgsForwardedToMember fwdMsgs mem) ms - GFSMain -> do - ms <- getAllIntroducedAndInvited - pure $ filter (\mem -> memberCurrent mem && msgsForwardedToMember fwdMsgs mem) ms - GFSMemberSupport scopeGMId -> do - -- moderators introduced to this invited member - introducedModMs <- - if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedModerators db vr user m - else pure [] - -- invited moderators to which this member was introduced - invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user m - let modMs = introducedModMs <> invitedModMs - moderatorFilter mem = - memberCurrent mem - && maxVersion (memberChatVRange mem) >= groupKnockingVersion - && msgsForwardedToMember fwdMsgs mem - modMs' = filter moderatorFilter modMs - if scopeGMId == groupMemberId' m - then pure modMs' - else - withStore' (\db -> getForwardScopeMember db vr user m scopeGMId) >>= \case - Just scopeMem | msgsForwardedToMember fwdMsgs scopeMem -> pure $ scopeMem : modMs' - _ -> pure modMs' + uniqueWorkerScopes :: [NewMessageDeliveryTask] -> [DeliveryWorkerScope] + uniqueWorkerScopes createdDeliveryTasks = + let workerScopes = map (\NewMessageDeliveryTask {jobScope} -> toWorkerScope jobScope) createdDeliveryTasks + in foldr' addWorkerScope [] workerScopes where - getAllIntroducedAndInvited = do - ChatConfig {highlyAvailable} <- asks config - -- members introduced to this invited member - introducedMembers <- - if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db vr user m highlyAvailable - else pure [] - -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user m highlyAvailable - pure $ introducedMembers <> invitedMembers + addWorkerScope workerScope acc + | workerScope `elem` acc = acc + | otherwise = workerScope : acc RCVD msgMeta msgRcpt -> withAckMessage' "group rcvd" agentConnId msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -1107,16 +1041,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MERR msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSError $ agentSndError err) -- group errors are silenced to reduce load on UI event log - -- eToView (ChatErrorAgent err $ Just connEntity) + -- eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do let newStatus = GSSError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1165,117 +1099,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - processSndFileConn :: AEvent e -> ConnectionEntity -> Connection -> SndFileTransfer -> CM () - processSndFileConn agentMsg connEntity conn ft@SndFileTransfer {fileId, fileName, fileStatus} = - case agentMsg of - -- SMP CONF for SndFileConnection happens for direct file protocol - -- when recipient of the file "joins" connection created by the sender - CONF confId _pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange - case chatMsgEvent of - -- TODO save XFileAcpt message - XFileAcpt name - | name == fileName -> do - withStore' $ \db -> updateSndFileStatus db ft FSAccepted - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId XOk - | otherwise -> messageError "x.file.acpt: fileName is different from expected" - _ -> messageError "CONF from file connection must have x.file.acpt" - CON _ -> do - ci <- withStore $ \db -> do - liftIO $ updateSndFileStatus db ft FSConnected - updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 - toView $ CEvtSndFileStart user ci ft - sendFileChunk user ft - SENT msgId _proxy -> do - withStore' $ \db -> updateSndFileChunkSent db ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk user ft - MERR _ err -> do - cancelSndFileTransfer user ft True >>= mapM_ deleteAgentConnectionAsync - case err of - SMP _ SMP.AUTH -> unless (fileStatus == FSCancelled) $ do - ci <- withStore $ \db -> do - liftIO (lookupChatRefByFileId db user fileId) >>= \case - Just (ChatRef CTDirect _ _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled - _ -> pure () - lookupChatItemByFileId db vr user fileId - toView $ CEvtSndFileRcvCancelled user ci ft - _ -> throwChatError $ CEFileSend fileId err - MSG meta _ _ -> - withAckMessage' "file msg" agentConnId meta $ pure () - OK -> - -- [async agent commands] continuation on receiving OK - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED _ _serviceId-> - -- [async agent commands] continuation on receiving JOINED - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO add debugging output - _ -> pure () - - processRcvFileConn :: AEvent e -> ConnectionEntity -> Connection -> RcvFileTransfer -> CM () - processRcvFileConn agentMsg connEntity conn ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}, grpMemberId} = - case agentMsg of - -- TODO [certs rcv] - INV (ACR _ cReq) _serviceId -> - withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> - case cReq of - fileInvConnReq@(CRInvitationUri _ _) -> case cmdFunction of - -- [async agent commands] direct XFileAcptInv continuation on receiving INV - CFCreateConnFileInvDirect -> do - ct <- withStore $ \db -> getContactByFileId db vr user fileId - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectContactMessage user ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) - -- [async agent commands] group XFileAcptInv continuation on receiving INV - CFCreateConnFileInvGroup -> case grpMemberId of - Just gMemberId -> do - GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db vr user gMemberId - case activeConn of - Just gMemberConn -> do - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMemberMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) groupId - _ -> throwChatError $ CECommandError "no GroupMember activeConn" - _ -> throwChatError $ CECommandError "no grpMemberId" - _ -> throwChatError $ CECommandError "unexpected cmdFunction" - CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - -- SMP CONF for RcvFileConnection happens for group file protocol - -- when sender of the file "joins" connection created by the recipient - -- (sender doesn't create connections for all group members) - CONF confId _pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange - case chatMsgEvent of - XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability - _ -> pure () - CON _ -> startReceivingFile user fileId - MSG meta _ msgBody -> do - -- XXX: not all branches do ACK - parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta - OK -> - -- [async agent commands] continuation on receiving OK - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED _ _serviceId -> - -- [async agent commands] continuation on receiving JOINED - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) - processConnMERR connEntity conn err - ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO add debugging output - _ -> pure () - receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do @@ -1315,10 +1143,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1484,13 +1312,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberCanSend :: GroupMember -> Maybe MsgScope -> - CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) -> - CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + CM (Maybe DeliveryJobScope) -> + CM (Maybe DeliveryJobScope) memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of Just MSMember {} -> a Nothing | memberRole > GRObserver || memberPending m -> a - | otherwise -> messageError "member is not allowed to send messages" $> (Nothing, False) + | otherwise -> messageError "member is not allowed to send messages" $> Nothing processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () processConnMERR connEntity conn err = do @@ -1564,7 +1392,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless shouldDelConns $ withLog (eInfo <> " ok") $ ackMsg msgMeta $ if withRcpt then Just "" else Nothing -- If showCritical is True, then these errors don't result in ACK and show user visible alert -- This prevents losing the message that failed to be processed. - Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing + Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) (AgentConnId "") Nothing Left e -> do withLog (eInfo <> " error: " <> tshow e) $ ackMsg msgMeta Nothing throwError e @@ -1610,7 +1438,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = badRcvFileChunk :: RcvFileTransfer -> String -> CM () badRcvFileChunk ft err = unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft throwChatError $ CEFileRcvChunk err memberConnectedChatItem :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> CM () @@ -1622,11 +1450,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected gInfo m ct_ = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' - lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CEvtConnectedToGroupMember user gInfo' m' ct_ - probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> CM () - probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do + probeMatchingMembers :: Contact -> IncognitoEnabled -> CM () + probeMatchingMembers ct connectedIncognito = do gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito @@ -1637,12 +1464,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- - if doProbeContacts - then map COMContact <$> withStore' (\db -> getMatchingContacts db vr user ct) - else pure [] ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) - sendProbeHashes (cs <> ms) probe probeId + sendProbeHashes ms probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> CM () @@ -1725,7 +1548,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (fileId, aci) processFDMessage fileId aci fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe GroupForwardScope) + groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryJobScope) groupMessageFileDescription g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId @@ -1739,7 +1562,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- for example failure on not approved relays (CEFileNotApproved). -- we catch error, so that even if processFDMessage fails, message can still be forwarded. processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () - pure $ Just $ toGroupForwardScope g scopeInfo + pure $ Just $ infoToDeliveryScope g scopeInfo else messageError "x.msg.file.descr: file of another member" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing @@ -1860,7 +1683,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing mapM_ toView cEvt_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) groupMsgReaction g m@GroupMember {memberRole} sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs | groupFeatureAllowed SGFReactions g = do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False @@ -1871,11 +1694,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | memberRole >= GRModerator || scopeMemberId == memberId' m -> withStore $ \db -> do liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - Just . GFSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId + Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId | otherwise -> pure Nothing Nothing -> do withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} else pure Nothing | otherwise = pure Nothing where @@ -1892,7 +1715,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction toView $ CEvtChatItemReaction user add r - pure $ Just $ toGroupForwardScope g scopeInfo + pure $ Just $ infoToDeliveryScope g scopeInfo else pure Nothing reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool @@ -1904,9 +1727,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe GroupForwardScope) + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ if blockedByAdmin m' then createBlockedByAdmin gInfo' m' scopeInfo $> Nothing else @@ -1920,7 +1743,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure Nothing Nothing -> do createContentItem gInfo' m' scopeInfo - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL @@ -1966,7 +1789,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ groupMsgToView cInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe GroupForwardScope) + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe DeliveryJobScope) groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ | prohibitedSimplexLinks gInfo m ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing @@ -1977,14 +1800,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateGroupChatItem db user groupId ci content True live Nothing ci'' <- blockedMemberCI gInfo' m' ci' toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc @@ -2009,14 +1832,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupCIMentions db gInfo ci' ciMentions toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo else do toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing else messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing _ -> messageError "x.msg.update: group member attempted invalid message update" $> Nothing - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ scope_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case @@ -2045,32 +1868,32 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (MSMember scopeMemberId) -> withStore $ \db -> do liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - Just . GFSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId + Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId Nothing -> do withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} where - moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe GroupForwardScope) + moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryJobScope) moderate mem cci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ do - groupForwardScope <- delete cci (Just m) + jobScope <- delete cci (Just m) archiveMessageReports cci m - pure $ Just groupForwardScope + pure $ Just jobScope | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" $> Nothing _ -> messageError "x.msg.del: message of another member without memberId" $> Nothing checkRole GroupMember {memberRole} a | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a - delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM GroupForwardScope + delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM DeliveryJobScope delete cci byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs toView $ CEvtChatItemsDeleted user deletions False False - pure $ toGroupForwardScope gInfo scopeInfo + pure $ infoToDeliveryScope gInfo scopeInfo archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs @@ -2127,7 +1950,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft @@ -2140,14 +1963,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability if fName == fileName then unless cancelled $ case fileConnReq_ of - -- receiving via a separate connection - Just fileConnReq -> do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo XOk - connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndDirectFTConnection db vr user fileId connIds subMode -- receiving inline - _ -> do + Nothing -> do event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft @@ -2157,6 +1974,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (allowSendInline fileSize fileInline) (sendDirectFileInline user ct ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") + Just _fileConnReq -> messageError "x.file.acpt.inv: receiving file via a separate connection is deprecated" else messageError "x.file.acpt.inv: fileName is different from expected" assertSMPAcceptNotProhibited :: ChatItem c d -> CM () @@ -2176,7 +1994,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do liftIO $ updateSndFileStatus db sft FSComplete - liftIO $ deleteSndFileChunks db sft updateDirectCIFileStatus db vr user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do @@ -2211,7 +2028,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe GroupForwardScope) + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe DeliveryJobScope) xFileCancelGroup g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId @@ -2223,9 +2040,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft toView $ CEvtRcvFileSndCancelled user aci ft - pure $ Just $ toGroupForwardScope g scopeInfo + pure $ Just $ infoToDeliveryScope g scopeInfo else -- shouldn't happen now that query includes group member id messageError "x.file.cancel: group member attempted to cancel file of another member" $> Nothing @@ -2240,14 +2057,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) if fName == fileName then unless cancelled $ case (fileConnReq_, activeConn) of - (Just fileConnReq, _) -> do - subMode <- chatReadVar subscriptionMode - -- receiving via a separate connection - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - dm <- encodeConnInfo XOk - connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndGroupFileTransferConnection db vr user fileId connIds m subMode - (_, Just conn) -> do + (Nothing, Just conn) -> do -- receiving inline event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 @@ -2258,6 +2068,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (allowSendInline fileSize fileInline) (sendMemberFileInline m conn ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") + (Just _fileConnReq, _) -> messageError "x.file.acpt.inv: receiving file via a separate connection is deprecated" _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" @@ -2368,10 +2179,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p Profile {displayName = n', fullName = fn', shortDescr = sd', image = i', contactLink = cl'} = p' - xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe GroupForwardScope) + xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe DeliveryJobScope) xInfoMember gInfo m p' brokerTs = do void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) - pure $ memberEventForwardScope m + pure $ memberEventDeliveryScope m xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do @@ -2391,7 +2202,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do - (referencedMember', gInfo') <- withFastStore' $ \db -> do + (referencedMember', gInfo') <- withStore' $ \db -> do referencedMember' <- updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' pure (referencedMember', gInfo') @@ -2444,7 +2255,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ - | redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' = do + | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do updateBusinessChatProfile gInfo case memberContactId of Nothing -> do @@ -2471,6 +2282,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' @@ -2514,25 +2326,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> CM (Maybe ContactOrMember) probeMatch cgm1 cgm2 probe = case cgm1 of - COMContact c1@Contact {contactId = cId1, profile = p1} -> + COMContact c1@Contact {profile = p1} -> case cgm2 of - COMContact c2@Contact {contactId = cId2, profile = p2} - | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage user c1 $ XInfoProbeOk probe - COMContact <$$> mergeContacts c1 c2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} | isNothing memberContactId && profilesMatch p1 p2 -> do void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> associateMemberAndContact c1 m2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing - COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing - COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> + COMContact _ -> messageWarning "probeMatch ignored: contacts are not merged" >> pure Nothing + COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId} -> case cgm2 of COMContact c2@Contact {profile = p2} - | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId - COMContact <$$> associateMemberAndContact c2 m1 + | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> + case memberConn m1 of + Just conn -> do + void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId + COMContact <$$> associateMemberAndContact c2 m1 + _ -> messageWarning "probeMatch ignored: matched member doesn't have connection" >> pure Nothing | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing @@ -2540,14 +2350,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoProbeOk cgm1 probe = do cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe case cgm1 of - COMContact c1@Contact {contactId = cId1} -> + COMContact c1 -> case cgm2 of - Just (COMContact c2@Contact {contactId = cId2}) - | cId1 /= cId2 -> void $ mergeContacts c1 c2 - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" Just (COMGroupMember m2@GroupMember {memberContactId}) | isNothing memberContactId -> void $ associateMemberAndContact c1 m2 | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" + Just (COMContact _) -> messageWarning "xInfoProbeOk ignored: contacts are not merged" _ -> pure () COMGroupMember m1@GroupMember {memberContactId} -> case cgm2 of @@ -2667,36 +2475,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = msgCallStateError eventName Call {callState} = messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) - mergeContacts :: Contact -> Contact -> CM (Maybe Contact) - mergeContacts c1 c2 = do - let Contact {localDisplayName = cLDN1, profile = LocalProfile {displayName}} = c1 - Contact {localDisplayName = cLDN2} = c2 - case (suffixOrd displayName cLDN1, suffixOrd displayName cLDN2) of - (Just cOrd1, Just cOrd2) - | cOrd1 < cOrd2 -> merge c1 c2 - | cOrd2 < cOrd1 -> merge c2 c1 - | otherwise -> pure Nothing - _ -> pure Nothing - where - merge c1' c2' = do - c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' - toView $ CEvtContactsMerged user c1' c2' c2'' - when (directOrUsed c2'') $ showSecurityCodeChanged c2'' - pure $ Just c2'' - where - showSecurityCodeChanged mergedCt = do - let sc1_ = contactSecurityCode c1' - sc2_ = contactSecurityCode c2' - scMerged_ = contactSecurityCode mergedCt - case (sc1_, sc2_) of - (Just sc1, Nothing) - | scMerged_ /= Just sc1 -> securityCodeChanged mergedCt - | otherwise -> pure () - (Nothing, Just sc2) - | scMerged_ /= Just sc2 -> securityCodeChanged mergedCt - | otherwise -> pure () - _ -> pure () - associateMemberAndContact :: Contact -> GroupMember -> CM (Maybe Contact) associateMemberAndContact c m = do let Contact {localDisplayName = cLDN, profile = LocalProfile {displayName}} = c @@ -2729,7 +2507,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' - saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Bool) + saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfo) saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange @@ -2737,20 +2515,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> do ct <- withStore $ \db -> createDirectContact db vr user conn' p toView $ CEvtContactConnecting user ct - pure (conn', False) + pure (conn', Nothing) XGrpLinkInv glInv -> do (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CEvtGroupLinkConnecting user gInfo host - pure (conn', True) + pure (conn', Just gInfo) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason - pure (conn', True) + pure (conn', Just gInfo) -- TODO show/log error, other events in SMP confirmation - _ -> pure (conn', False) + _ -> pure (conn', Nothing) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msgScope_ msg brokerTs = do checkHostRole m memRole if sameMemberId memId (membership gInfo) @@ -2766,7 +2544,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (updatedMember, gInfo') toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' - pure $ forwardScope updatedMember + pure $ deliveryJobScope updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do @@ -2776,15 +2554,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (newMember, gInfo') memberAnnouncedToView newMember gInfo' - pure $ forwardScope newMember + pure $ deliveryJobScope newMember where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced - forwardScope GroupMember {groupMemberId, memberStatus} + deliveryJobScope GroupMember {groupMemberId, memberStatus} | memberStatus == GSMemPendingApproval = Nothing - | memberStatus == GSMemPendingReview = Just $ GFSMemberSupport groupMemberId - | otherwise = Just GFSMain + | memberStatus == GSMemPendingReview = Just $ DJSMemberSupport groupMemberId + | otherwise = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) @@ -2837,14 +2615,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do - GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ + introId <- withStore $ \db -> do + GroupMemberIntro {introId} <- getIntroduction db reMember m + liftIO $ updateIntroStatus db introId GMIntroInvReceived + pure introId + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) IntroInvitation {groupConnReq, directConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- @@ -2859,10 +2640,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? -- TODO keep as is? (GSMemIntroInvited has no purpose) let newMemberStatus = if memberPending toMember then memberStatus toMember else GSMemIntroInvited - withStore' $ \db -> saveMemberInvitation db toMember introInv newMemberStatus + withStore' $ \db -> updateGroupMemberStatus db userId toMember newMemberStatus subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership + let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -2872,7 +2654,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = chatV = vr `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs | membershipMemId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} @@ -2892,13 +2674,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} - pure $ memberEventForwardScope member + pure $ memberEventDeliveryScope member checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) - xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemRestrict gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}} m@GroupMember {memberRole = senderRole} @@ -2906,9 +2688,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MemberRestrictions {restriction} msg brokerTs - | membershipMemId == memId = - -- member shouldn't receive this message about themselves - messageError "x.grp.mem.restrict: admin blocks you" $> Nothing + | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} @@ -2923,12 +2703,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent groupMsgToView cInfo ci toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked} - pure $ memberEventForwardScope bm + pure $ memberEventDeliveryScope bm Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId Nothing bm' <- setMemberBlocked bm toView $ CEvtUnknownMemberBlocked user gInfo m bm' - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} Left e -> throwError $ ChatErrorStore e where setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm @@ -2975,22 +2755,25 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo - unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections gInfo False + -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages - pure (Just GFSAll, True) + pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Left _ -> messageError "x.grp.mem.del with unknown member ID" $> (Just GFSAll, False) + Left _ -> do + messageError "x.grp.mem.del with unknown member ID" + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} Right deletedMember@GroupMember {groupMemberId, memberProfile} -> checkRole deletedMember $ do -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave @@ -3007,11 +2790,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when withMessages $ deleteMessages gInfo' deletedMember' SMDRcv deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) toView $ CEvtDeletedMember user gInfo' m deletedMember' withMessages - pure (memberEventForwardScope deletedMember, False) + pure $ memberEventDeliveryScope deletedMember where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = - messageError "x.grp.mem.del with insufficient member permissions" $> (Nothing, False) + messageError "x.grp.mem.del with insufficient member permissions" $> Nothing | otherwise = a deleteMemberItem gEvent = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -3028,17 +2811,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event = XGrpMsgForward memberId memberName chatMsg brokerTs sendGroupMemberMessage gInfo member event Nothing (pure ()) + -- TODO [channels fwd] base on differentiation between groups and channels isUserGrpFwdRelay :: GroupInfo -> Bool - isUserGrpFwdRelay GroupInfo {membership = GroupMember {memberRole}} = - memberRole >= GRAdmin + isUserGrpFwdRelay GroupInfo {useRelays, membership = membership@GroupMember {memberRole}} + | isTrue useRelays = isMemberRelay membership + | otherwise = memberRole >= GRAdmin - deleteGroupConnections :: GroupInfo -> Bool -> CM () - deleteGroupConnections gInfo waitDelivery = do - -- member records are not deleted to keep history - members <- withStore' $ \db -> getGroupMembers db vr user gInfo - deleteMembersConnections' user members waitDelivery - - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item @@ -3051,19 +2830,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} - pure $ memberEventForwardScope m + pure $ memberEventDeliveryScope m xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted - unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections gInfo False + -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpInfo g@GroupInfo {groupProfile = p, businessChat} m@GroupMember {memberRole} p' msg brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" $> Nothing | otherwise = do @@ -3077,13 +2857,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' + void $ forkIO $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' - pure $ Just GFSAll + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} - xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe GroupForwardScope) + xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe DeliveryJobScope) xGrpPrefs g m@GroupMember {memberRole} ps' | memberRole < GROwner = messageError "x.grp.prefs with insufficient member permissions" $> Nothing - | otherwise = updateGroupPrefs_ g m ps' $> Just GFSAll + | otherwise = updateGroupPrefs_ g m ps' $> Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} updateGroupPrefs_ :: GroupInfo -> GroupMember -> GroupPreferences -> CM () updateGroupPrefs_ g@GroupInfo {groupProfile = p} m ps' = @@ -3183,8 +2964,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> CM () - xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs = do + xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () + xGrpMsgForward gInfo m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs brokerTs = do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Right author -> processForwardedMsg author @@ -3198,13 +2979,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processForwardedMsg :: GroupMember -> CM () processForwardedMsg author = do let body = chatMsgToBody chatMsg - rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg - case event of - XMsgNew mc -> void $ memberCanSend author scope $ (,False) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True + rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author body chatMsg brokerTs + forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of + XMsgNew mc -> void $ memberCanSend author scope $ (const Nothing) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True where ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (,False) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (const Nothing) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author sharedMsgId memId scope_ rcvMsg msgTs XMsgReact sharedMsgId (Just memId) scope_ reaction add -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author sharedMsgId @@ -3305,3 +3086,227 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing else pure Nothing _ -> pure Nothing + +deleteGroupConnections :: User -> GroupInfo -> Bool -> CM () +deleteGroupConnections user gInfo waitDelivery = do + vr <- chatVersionRange + -- member records are not deleted to keep history + members <- getMembers vr + deleteMembersConnections' user members waitDelivery + where + getMembers vr + | isTrue (useRelays gInfo) = withStore' $ \db -> getGroupRelays db vr user gInfo + | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo + +startDeliveryTaskWorkers :: CM () +startDeliveryTaskWorkers = do + workerScopes <- withStore' $ \db -> getPendingDeliveryTaskScopes db + lift $ forM_ workerScopes resumeDeliveryTaskWork + +resumeDeliveryTaskWork :: DeliveryWorkerKey -> CM' () +resumeDeliveryTaskWork = void . getDeliveryTaskWorker False + +getDeliveryTaskWorker :: Bool -> DeliveryWorkerKey -> CM' Worker +getDeliveryTaskWorker hasWork deliveryKey = do + ws <- asks deliveryTaskWorkers + a <- asks smpAgent + getAgentWorker "delivery_task" hasWork a deliveryKey ws $ + runDeliveryTaskWorker a deliveryKey + +runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () +runDeliveryTaskWorker a deliveryKey Worker {doWork} = do + delay <- asks $ deliveryWorkerDelay . config + vr <- chatVersionRange + -- TODO [channels fwd] in future may be required to read groupInfo and user on each iteration for up to date state + -- TODO - same for delivery jobs (runDeliveryJobWorker) + gInfo <- withStore $ \db -> do + user <- getUserByGroupId db groupId + getGroupInfo db vr user groupId + forever $ do + unless (delay == 0) $ liftIO $ threadDelay' delay + lift $ waitForWork doWork + runDeliveryTaskOperation vr gInfo + where + (groupId, workerScope) = deliveryKey + runDeliveryTaskOperation :: VersionRangeChat -> GroupInfo -> CM () + runDeliveryTaskOperation vr gInfo = do + withWork_ a doWork (withStore' $ \db -> getNextDeliveryTask db deliveryKey) $ \task -> + processDeliveryTask task + `catchAllErrors` \e -> do + withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) (tshow e) + eToView e + where + processDeliveryTask :: MessageDeliveryTask -> CM () + processDeliveryTask task@MessageDeliveryTask {jobScope} = + case jobScopeImpliedSpec jobScope of + DJDeliveryJob _includePending -> + withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do + let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + withStore' $ \db -> do + createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body + forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed + forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + lift . void $ getDeliveryJobWorker True deliveryKey + where + singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId + singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) + | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' + | otherwise = Nothing + DJRelayRemoved + | workerScope /= DWSGroup -> + throwChatError $ CEInternalError "delivery task worker: relay removed task in wrong worker scope" + | otherwise -> do + let MessageDeliveryTask {senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage} = task + fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + body = chatMsgToBody cm + withStore' $ \db -> do + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed + lift . void $ getDeliveryJobWorker True deliveryKey + +startDeliveryJobWorkers :: CM () +startDeliveryJobWorkers = do + workerScopes <- withStore' $ \db -> getPendingDeliveryJobScopes db + lift $ forM_ workerScopes resumeDeliveryJobWork + +resumeDeliveryJobWork :: DeliveryWorkerKey -> CM' () +resumeDeliveryJobWork = void . getDeliveryJobWorker False + +getDeliveryJobWorker :: Bool -> DeliveryWorkerKey -> CM' Worker +getDeliveryJobWorker hasWork deliveryKey = do + ws <- asks deliveryJobWorkers + a <- asks smpAgent + getAgentWorker "delivery_job" hasWork a deliveryKey ws $ + runDeliveryJobWorker a deliveryKey + +runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () +runDeliveryJobWorker a deliveryKey Worker {doWork} = do + delay <- asks $ deliveryWorkerDelay . config + vr <- chatVersionRange + (user, gInfo) <- withStore $ \db -> do + user <- getUserByGroupId db groupId + gInfo <- getGroupInfo db vr user groupId + pure (user, gInfo) + forever $ do + unless (delay == 0) $ liftIO $ threadDelay' delay + lift $ waitForWork doWork + runDeliveryJobOperation vr user gInfo + where + (groupId, workerScope) = deliveryKey + runDeliveryJobOperation :: VersionRangeChat -> User -> GroupInfo -> CM () + runDeliveryJobOperation vr user gInfo = do + withWork_ a doWork (withStore' $ \db -> getNextDeliveryJob db deliveryKey) $ \job -> + processDeliveryJob job + `catchAllErrors` \e -> do + withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) (tshow e) + eToView e + where + processDeliveryJob :: MessageDeliveryJob -> CM () + processDeliveryJob job = + case jobScopeImpliedSpec jobScope of + DJDeliveryJob _includePending -> do + sendBodyToMembers + withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete + DJRelayRemoved + | workerScope /= DWSGroup -> + throwChatError $ CEInternalError "delivery job worker: relay removed job in wrong worker scope" + | otherwise -> do + sendBodyToMembers + deleteGroupConnections user gInfo True + withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete + where + MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + sendBodyToMembers :: CM () + sendBodyToMembers + | isTrue (useRelays gInfo) = -- channel + case jobScope of + -- there's no member review in channels, so job spec includePending is ignored + DJSGroup {} -> do + bucketSize <- asks $ deliveryBucketSize . config + sendLoop bucketSize startingCursor + where + sendLoop :: Int -> Maybe GroupMemberId -> CM () + sendLoop bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + unless (null mems) $ do + deliver body mems + let cursorGMId' = groupMemberId' $ last mems + withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' + unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + DJSMemberSupport scopeGMId -> do + -- for member support scope we just load all recipients in one go, without cursor + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let moderatorFilter mem = + memberCurrent mem + && maxVersion (memberChatVRange mem) >= groupKnockingVersion + && Just (groupMemberId' mem) /= singleSenderGMId_ + modMs' = filter moderatorFilter modMs + mems <- + if Just scopeGMId == singleSenderGMId_ + then pure modMs' + else do + scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + pure $ scopeMem : modMs' + unless (null mems) $ deliver body mems + | otherwise = -- fully connected group + case singleSenderGMId_ of + Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" + Just singleSenderGMId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + mems <- buildMemberList sender + unless (null mems) $ deliver body mems + where + buildMemberList sender = case jobScope of + DJSGroup {jobSpec} + | jobSpecImpliedPending jobSpec -> + filter memberCurrentOrPending <$> getAllIntroducedAndInvited + | otherwise -> + filter memberCurrent <$> getAllIntroducedAndInvited + DJSMemberSupport scopeGMId -> do + -- moderators introduced to this invited member + introducedModMs <- + if memberCategory sender == GCInviteeMember + then withStore' $ \db -> getForwardIntroducedModerators db vr user sender + else pure [] + -- invited moderators to which this member was introduced + invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user sender + let modMs = introducedModMs <> invitedModMs + modMs' = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs + if scopeGMId == groupMemberId' sender + then pure modMs' + else + withStore' (\db -> getForwardScopeMember db vr user sender scopeGMId) >>= \case + Just scopeMem -> pure $ scopeMem : modMs' + _ -> pure modMs' + where + getAllIntroducedAndInvited = do + ChatConfig {highlyAvailable} <- asks config + -- members introduced to this invited member + introducedMembers <- + if memberCategory sender == GCInviteeMember + then withStore' $ \db -> getForwardIntroducedMembers db vr user sender highlyAvailable + else pure [] + -- invited members to which this member was introduced + invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user sender highlyAvailable + pure $ introducedMembers <> invitedMembers + where + deliver :: ByteString -> [GroupMember] -> CM () + deliver msgBody mems = + let mConns = mapMaybe (fmap snd . readyMemberConn) mems + msgReqs = foldMemConns mConns + in void $ withAgent (`sendMessages` msgReqs) + where + foldMemConns :: [Connection] -> [MsgReq] + foldMemConns mConns = snd $ foldr' addReq (lastMemIdx_, []) mConns + where + lastMemIdx_ = let len = length mConns in if len > 1 then Just len else Nothing + addReq :: Connection -> (Maybe Int, [MsgReq]) -> (Maybe Int, [MsgReq]) + addReq conn (memIdx_, reqs) = + (subtract 1 <$> memIdx_, req : reqs) + where + req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + vrValue_ = case memIdx_ of + Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body + Just 1 -> VRValue (Just 1) msgBody + Just _ -> VRRef 1 diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5a5f0922e7..f5d07d18af 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -31,7 +31,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) @@ -49,7 +49,6 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -185,24 +184,6 @@ toMsgScope :: GroupInfo -> GroupChatScopeInfo -> MsgScope toMsgScope GroupInfo {membership} = \case GCSIMemberSupport {groupMember_} -> MSMember $ memberId' $ fromMaybe membership groupMember_ -data GroupForwardScope - = GFSAll -- message should be forwarded to all group members, even pending (e.g. XGrpDel, XGrpInfo) - | GFSMain -- message should be forwarded to current group members only (e.g. regular messages in group) - | GFSMemberSupport GroupMemberId - deriving (Eq, Ord, Show) - -toGroupForwardScope :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupForwardScope -toGroupForwardScope GroupInfo {membership} = \case - Nothing -> GFSMain - Just GCSIMemberSupport {groupMember_} -> GFSMemberSupport $ groupMemberId' $ fromMaybe membership groupMember_ - -memberEventForwardScope :: GroupMember -> Maybe GroupForwardScope -memberEventForwardScope m@GroupMember {memberRole, memberStatus} - | memberStatus == GSMemPendingApproval = Nothing - | memberStatus == GSMemPendingReview = Just $ GFSMemberSupport $ groupMemberId' m - | memberRole >= GRModerator = Just GFSAll - | otherwise = Just GFSMain - chatInfoToRef :: ChatInfo c -> Maybe ChatRef chatInfoToRef = \case DirectChat Contact {contactId} -> Just $ ChatRef CTDirect contactId Nothing @@ -1163,7 +1144,8 @@ data SndMessage = SndMessage data NewRcvMessage e = NewRcvMessage { chatMsgEvent :: ChatMsgEvent e, - msgBody :: MsgBody + msgBody :: MsgBody, + brokerTs :: UTCTime } deriving (Show) diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index c1c45d7b0a..2c3bd2b87d 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -7,13 +7,21 @@ module Simplex.Chat.Messages.Batch ( MsgBatch (..), batchMessages, + batchDeliveryTasks1, ) where import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) +import Data.List (foldl') +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.List.NonEmpty as L import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) +import Simplex.Chat.Delivery import Simplex.Chat.Messages +import Simplex.Chat.Protocol +import Simplex.Chat.Types (VersionRangeChat) data MsgBatch = MsgBatch ByteString [SndMessage] @@ -49,3 +57,38 @@ batchMessages maxLen = addBatch . foldr addToBatch ([], [], 0, 0) [msg] -> body msg msgs -> B.concat ["[", B.intercalate "," (map body msgs), "]"] body SndMessage {msgBody} = msgBody + +-- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +batchDeliveryTasks1 vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList + where + addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) + addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task + -- too large: skip msgBody, record taskId in largeTaskIds + | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) + -- fits: include in batch + | batchLen <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + -- doesn’t fit: stop adding further messages + | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) + where + MessageDeliveryTask {taskId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel = _messageFromChannel} = task + -- TODO [channels fwd] handle messageFromChannel (null memberId in XGrpMsgForward) + msgBody = + let fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + in chatMsgToBody cm + msgLen = B.length msgBody + len' + | n == 0 = msgLen + | otherwise = msgLen + len + 1 -- 1 accounts for comma + batchLen + | n == 0 = len' + | otherwise = len' + 2 -- 2 accounts for opening and closing brackets + toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) + toResult (msgBodies, taskIds, largeTaskIds, _, _) = + (encodeMessages (reverse msgBodies), reverse taskIds, reverse largeTaskIds) + encodeMessages :: [ByteString] -> ByteString + encodeMessages = \case + [] -> mempty + [msg] -> msg + msgs -> B.concat ["[", B.intercalate "," msgs, "]"] diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index f18a3900da..b22cfebcdd 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) @@ -254,7 +254,8 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + migrationBackupPath = Just "" }, chatCmd = "", chatCmdDelay = 3, @@ -276,7 +277,6 @@ defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, logLevel = CLLError, - coreApi = True, deviceNameForRemote = "Mobile" } @@ -294,8 +294,9 @@ chatMigrateInit dbFilePrefix dbKey confirm = do chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm - chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) confirmMigrations - agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) confirmMigrations + let migrationConfig = MigrationConfig confirmMigrations (Just "") + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index c9b5e020d9..24baa37e4e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,7 +46,6 @@ import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types (User) -import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) import Simplex.Messaging.Agent.Store.Entity diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 9411c66175..afc01e0493 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -67,7 +67,8 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, highlyAvailable :: Bool, - yesToUpMigrations :: Bool + yesToUpMigrations :: Bool, + migrationBackupPath :: Maybe FilePath } data CreateBotOpts = CreateBotOpts @@ -197,7 +198,7 @@ coreChatOptsP appDir defaultDbName = do switch ( long "connections" <> short 'c' - <> help "Log every contact and group connection on start (also with `-l info`)" + <> help "Log connections subscription errors on start (also with `-l info`)" ) logServerHosts <- switch @@ -243,6 +244,7 @@ coreChatOptsP appDir defaultDbName = do <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) + migrationBackupPath <- migrationBackupPathP pure CoreChatOpts { dbOptions, @@ -268,7 +270,8 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, highlyAvailable, - yesToUpMigrations + yesToUpMigrations, + migrationBackupPath } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index f7c429e93e..13af13b20a 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -42,7 +42,7 @@ chatDbOptsP _appDir defaultDbName = do ( long "pool-size" <> metavar "DB_POOL_SIZE" <> help "Database connection pool size" - <> value 1 + <> value 10 <> showDefault ) dbCreateSchema <- @@ -52,6 +52,9 @@ chatDbOptsP _appDir defaultDbName = do ) pure ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} +migrationBackupPathP :: Parser (Maybe FilePath) +migrationBackupPathP = pure Nothing + dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr @@ -78,7 +81,7 @@ mobileDbOpts schemaPrefix connstr = do ChatDbOpts { dbConnstr, dbSchemaPrefix, - dbPoolSize = 1, + dbPoolSize = 10, dbCreateSchema = True } diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 0507076613..8591f0801c 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -54,6 +54,19 @@ chatDbOptsP appDir defaultDbName = do vacuumOnMigration = not disableVacuum } +migrationBackupPathP :: Parser (Maybe FilePath) +migrationBackupPathP = + flag' Nothing + ( long "disable-backup" + <> help "Disable backup when migrating database" + ) + <|> + (fmap Just . strOption) + ( long "backup-directory" + <> help "Directory to backup database for migration" + <> value "" + ) + dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" @@ -63,8 +76,8 @@ toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuf { dbFilePath = dbFilePrefix <> dbSuffix, dbKey, keepKey, - track = trackQueries, - vacuum = vacuumOnMigration + vacuum = vacuumOnMigration, + track = trackQueries } chatSuffix :: String diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index d6b826491e..2aafad94a1 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -34,11 +34,10 @@ import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (fromRight) -import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, mapMaybe) +import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -53,7 +52,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) -import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -154,8 +153,6 @@ agentToChatVersion v data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} - | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} - | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} deriving (Eq, Show) @@ -165,8 +162,6 @@ connEntityInfo :: ConnectionEntity -> String connEntityInfo = \case RcvDirectMsgConnection c ct_ -> ctInfo ct_ <> ", status: " <> show (connStatus c) RcvGroupMsgConnection c g m -> mInfo g m <> ", status: " <> show (connStatus c) - SndFileConnection c _ft -> "snd file, status: " <> show (connStatus c) - RcvFileConnection c _ft -> "rcv file, status: " <> show (connStatus c) UserContactConnection c _uc -> "user address, status: " <> show (connStatus c) where ctInfo = maybe "connection" $ \Contact {contactId} -> "contact " <> show contactId @@ -176,8 +171,6 @@ updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = Just $ st c}) <$> ct_) RcvGroupMsgConnection c gInfo m@GroupMember {activeConn = c'} -> RcvGroupMsgConnection (st c) gInfo m {activeConn = st <$> c'} - SndFileConnection c ft -> SndFileConnection (st c) ft - RcvFileConnection c ft -> RcvFileConnection (st c) ft UserContactConnection c uc -> UserContactConnection (st c) uc where st c = c {connStatus} @@ -313,6 +306,8 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) +type MessageFromChannel = Bool + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -392,32 +387,6 @@ isForwardedGroupMsg ev = case ev of XGrpPrefs _ -> True _ -> False --- applied after building list of messages to forward and building list of group members to forward to, see Chat; --- --- this filters out members if any of forwarded events in batch is an XGrpMemRestrict event referring to them, --- but practically XGrpMemRestrict is not batched with other events so it wouldn't prevent forwarding of other events --- to these members; --- --- same for reports (MCReport) - they are not batched with other events, so we can safely filter out --- members with role less than moderator when forwarding -msgsForwardedToMember :: NonEmpty (ChatMessage 'Json) -> GroupMember -> Bool -msgsForwardedToMember fwdMsgs GroupMember {memberId, memberRole} = - (memberId `notElem` restrictMemberIds) && (not hasReport || memberRole >= GRModerator) - where - restrictMemberIds = mapMaybe restrictMemberId $ L.toList fwdMsgs - restrictMemberId :: ChatMessage 'Json -> Maybe MemberId - restrictMemberId ChatMessage {chatMsgEvent} = - case chatMsgEvent of - XGrpMemRestrict mId _ -> Just mId - _ -> Nothing - hasReport = any isReportEvent fwdMsgs - isReportEvent ChatMessage {chatMsgEvent} = - case chatMsgEvent of - XMsgNew mc -> case mcExtMsgContent mc of - ExtMsgContent {content = MCReport {}} -> True - _ -> False - _ -> False - data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) @@ -1222,6 +1191,9 @@ instance ToJSON (ChatMessage 'Json) where instance FromJSON (ChatMessage 'Json) where parseJSON v = appJsonToCM <$?> parseJSON v +instance FromField (ChatMessage 'Json) where + fromField = blobFieldDecoder J.eitherDecodeStrict' + data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 47dce826ce..a706bfc0de 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 4, 2] +minRemoteCtrlVersion = AppVersion [6, 4, 6, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 4, 4, 2] +minRemoteHostVersion = AppVersion [6, 4, 6, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -531,7 +531,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo Left e -> eToView' $ ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a -takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar +takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, agentConnId = AgentConnId "", connectionEntity_ = Nothing}) . atomically . takeTMVar type GetChunk = Int -> IO ByteString diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d3d3395ed1..e81ec0cc3a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), - GroupLink (..), GroupLinkInfo (..), AddressSettings (..), AutoAccept (..), @@ -16,17 +15,16 @@ module Simplex.Chat.Store ) where -import Simplex.Chat.Store.Groups (GroupLink (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) import Simplex.Messaging.Agent.Store.Interface (DBOpts, createDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig, MigrationError) #if defined(dbPostgres) import Simplex.Chat.Store.Postgres.Migrations #else import Simplex.Chat.Store.SQLite.Migrations #endif -createChatStore :: DBOpts -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore :: DBOpts -> MigrationConfig -> IO (Either MigrationError DBStore) createChatStore dbCreateOpts = createDBStore dbCreateOpts migrations diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8930acf3c1..9467675272 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -15,27 +15,28 @@ module Simplex.Chat.Store.Connections getConnectionEntityByConnReq, getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, - getConnectionsToSubscribe, - unsetConnectionToSubscribe + getContactConnsToSub, + getUCLConnsToSub, + getMemberConnsToSub, + getPendingConnsToSub, + shouldSyncConnections, + setConnectionsSyncTs, ) where -import Control.Applicative ((<|>)) -import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) -import Data.Maybe (catMaybes, fromMaybe) +import Data.Maybe (fromMaybe) +import Data.Time.Clock (getCurrentTime) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct -import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (eitherToMaybe) @@ -49,12 +50,12 @@ import Database.SQLite.Simple.QQ (sql) getChatLockEntity :: DB.Connection -> AgentConnId -> ExceptT StoreError IO ChatLockEntity getChatLockEntity db agentConnId = do - ((connId, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId)) <- + ((connId, connType) :. (contactId, groupMemberId, userContactLinkId)) <- ExceptT . firstRow id (SEConnectionNotFound agentConnId) $ DB.query db [sql| - SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + SELECT connection_id, conn_type, contact_id, group_member_id, user_contact_link_id FROM connections WHERE agent_conn_id = ? |] @@ -63,8 +64,6 @@ getChatLockEntity db agentConnId = do case connType of ConnMember -> maybe err (fmap CLGroup . getMemberGroupId) groupMemberId ConnContact -> pure $ maybe (CLConnection connId) CLContact contactId - ConnSndFile -> maybe err (pure . CLFile) sndFileId - ConnRcvFile -> maybe err (pure . CLFile) rcvFileId ConnUserContact -> maybe err (pure . CLUserContact) userContactLinkId where getMemberGroupId :: GroupMemberId -> ExceptT StoreError IO GroupId @@ -84,8 +83,6 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do case connType of ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ entId c ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ entId c - ConnSndFile -> SndFileConnection c <$> getConnSndFileTransfer_ entId c - ConnRcvFile -> RcvFileConnection c <$> getRcvFileTransfer db user entId ConnUserContact -> UserContactConnection c <$> getUserContact_ entId where getConnection_ :: ExceptT StoreError IO Connection @@ -95,7 +92,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -110,7 +107,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do db [sql| SELECT - c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl @@ -120,14 +117,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do |] (userId, contactId, CSActive) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -143,7 +140,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -172,26 +169,6 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do let groupInfo = toGroupInfo vr userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) - getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer - getConnSndFileTransfer_ fileId Connection {connId} = - ExceptT $ - firstRow' (sndFileTransfer_ fileId connId) (SESndFileNotFound fileId) $ - DB.query - db - [sql| - SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.group_member_id, cs.local_display_name, m.local_display_name - FROM snd_files s - JOIN files f USING (file_id) - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) - WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ? - |] - (userId, fileId, connId) - sndFileTransfer_ :: Int64 -> Int64 -> (FileStatus, String, Integer, Integer, FilePath, Maybe Int64, Maybe InlineFileMode, Maybe Int64, Maybe ContactName, Maybe ContactName) -> Either StoreError SndFileTransfer - sndFileTransfer_ fileId connId (fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, contactName_, memberName_) = - case contactName_ <|> memberName_ of - Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, recipientDisplayName, connId, agentConnId, groupMemberId} - Nothing -> Left $ SESndFileInvalid fileId getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do userContact_ @@ -259,16 +236,108 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ -getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> IO ([ConnId], [ConnectionEntity]) -getConnectionsToSubscribe db vr = do - aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" - entities <- forM aConnIds $ \acId -> do - getUserByAConnId db acId >>= \case - Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId) - Nothing -> pure Nothing - unsetConnectionToSubscribe db - let connIds = map (\(AgentConnId connId) -> connId) aConnIds - pure (connIds, catMaybes entities) +getContactConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getContactConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnDeleted, CSActive) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT c.agent_conn_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? + |] + cond = + [sql| + AND c.conn_status != ? + AND ct.contact_status = ? AND ct.deleted = 0 + |] -unsetConnectionToSubscribe :: DB.Connection -> IO () -unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1" +getUCLConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getUCLConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnDeleted) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT c.agent_conn_id + FROM connections c + JOIN user_contact_links ucl ON ucl.user_contact_link_id = c.user_contact_link_id + WHERE c.user_id = ? + |] + cond = " AND c.conn_status != ?" + +getMemberConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getMemberConnsToSub db User {userId, userContactId} filterToSubscribe = + map fromOnly <$> + DB.query + db + query + ((userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + :. (userId, ConnDeleted, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1" + | otherwise = baseQuery + baseQuery = + [sql| + WITH user_groups AS MATERIALIZED ( + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? + AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + ) + SELECT c.agent_conn_id + FROM connections c + JOIN group_members m ON m.group_member_id = c.group_member_id + JOIN user_groups ug ON ug.group_id = m.group_id + WHERE c.user_id = ? + AND c.conn_status != ? + AND m.member_status NOT IN (?,?,?) + |] + +getPendingConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getPendingConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnContact, ConnDeleted) + where + query + | filterToSubscribe = baseQuery <> " AND to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT agent_conn_id + FROM connections + WHERE user_id = ? + |] + cond = + [sql| + AND conn_type = ? + AND contact_id IS NULL + AND conn_status != ? + |] + +shouldSyncConnections :: DB.Connection -> IO Bool +shouldSyncConnections db = + fromOnlyBI . head + <$> DB.query_ + db + "SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1" + +setConnectionsSyncTs :: DB.Connection -> IO () +setConnectionsSyncTs db = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections_sync + SET should_sync = 0, last_sync_ts = ? + WHERE connections_sync_id = 1 + |] + (Only currentTs) diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index d9ad9ec763..1e0ca8bdc5 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -14,9 +14,9 @@ module Simplex.Chat.Store.ContactRequest setContactAcceptedXContactId, setBusinessChatAcceptedXContactId, setRequestSharedMsgIdForContact, - setRequestSharedMsgIdForGroup + setRequestSharedMsgIdForGroup, ) - where +where import Control.Monad import Control.Monad.Except @@ -24,11 +24,11 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) -import Simplex.Chat.Protocol (businessChatsVersion, MsgContent) +import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (InvitationId) @@ -47,21 +47,21 @@ import Database.SQLite.Simple.QQ (sql) #endif createOrUpdateContactRequest :: - DB.Connection - -> TVar ChaChaDRG - -> VersionRangeChat - -> User - -> Int64 - -> UserContactLink - -> Bool - -> InvitationId - -> VersionRangeChat - -> Profile - -> Maybe XContactId - -> Maybe SharedMsgId - -> Maybe (SharedMsgId, MsgContent) - -> PQSupport - -> ExceptT StoreError IO RequestStage + DB.Connection -> + TVar ChaChaDRG -> + VersionRangeChat -> + User -> + Int64 -> + UserContactLink -> + Bool -> + InvitationId -> + VersionRangeChat -> + Profile -> + Maybe XContactId -> + Maybe SharedMsgId -> + Maybe (SharedMsgId, MsgContent) -> + PQSupport -> + ExceptT StoreError IO RequestStage createOrUpdateContactRequest db gVar @@ -86,147 +86,146 @@ createOrUpdateContactRequest Just ct -> do cr <- liftIO $ getContactRequestByXContactId xContactId pure $ RSAcceptedRequest cr (REContact ct) - Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case - Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - cr <- liftIO $ getContactRequestByXContactId xContactId - pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) - Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest - -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request - Nothing -> - liftIO (getContactRequestByXContactId xContactId) >>= \case - -- 3a) if request was found, we update it - Just cr -> updateContactRequest cr - -- 3b) if no request was found, we create a new contact request - Nothing -> createContactRequest - where - getAcceptedContact :: XContactId -> IO (Maybe Contact) - getAcceptedContact xContactId = do - ct_ <- - maybeFirstRow (toContact vr user []) $ + Nothing -> + liftIO (getAcceptedBusinessChat xContactId) >>= \case + Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + cr <- liftIO $ getContactRequestByXContactId xContactId + pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) + Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest + -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request + Nothing -> + liftIO (getContactRequestByXContactId xContactId) >>= \case + -- 3a) if request was found, we update it + Just cr -> updateContactRequest cr + -- 3b) if no request was found, we create a new contact request + Nothing -> createContactRequest + where + getAcceptedContact :: XContactId -> IO (Maybe Contact) + getAcceptedContact xContactId = do + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + |] + (userId, xContactId) + mapM (addDirectChatTags db) ct_ + getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) + getAcceptedBusinessChat xContactId = do + g_ <- + maybeFirstRow (toGroupInfo vr userContactId []) $ + DB.query + db + (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (xContactId, userId, userContactId) + mapM (addGroupChatTags db) g_ + getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) + getContactRequestByXContactId xContactId = + maybeFirstRow toContactRequest $ DB.query db [sql| SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, + cr.contact_id, cr.business_group_id, cr.user_contact_link_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? LIMIT 1 |] (userId, xContactId) - mapM (addDirectChatTags db) ct_ - getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) - getAcceptedBusinessChat xContactId = do - g_ <- - maybeFirstRow (toGroupInfo vr userContactId []) $ - DB.query - db - (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (xContactId, userId, userContactId) - mapM (addGroupChatTags db) g_ - getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ - DB.query - db - [sql| - SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, - cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, - cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN contact_profiles p USING (contact_profile_id) - WHERE cr.user_id = ? - AND cr.xcontact_id = ? - LIMIT 1 - |] - (userId, xContactId) - createContactRequest :: ExceptT StoreError IO RequestStage - createContactRequest = do - currentTs <- liftIO $ getCurrentTime - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do - liftIO $ - DB.execute - db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) - profileId <- liftIO $ insertedRowId db - liftIO $ - DB.execute - db - [sql| + createContactRequest :: ExceptT StoreError IO RequestStage + createContactRequest = do + currentTs <- liftIO $ getCurrentTime + ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + liftIO $ + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + profileId <- liftIO $ insertedRowId db + liftIO $ + DB.execute + db + [sql| INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id, welcome_shared_msg_id, request_shared_msg_id, pq_support) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) - :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) - ) - contactRequestId <- liftIO $ insertedRowId db - createRequestEntity ldn profileId contactRequestId currentTs - where - createRequestEntity ldn profileId contactRequestId currentTs - | businessAddress = - if isSimplexTeam && maxV < businessChatsVersion - then createContact' - else createBusinessChat - | otherwise = createContact' - where - createContact' = do - let ctUserPreferences = newContactUserPrefs user profile - liftIO $ - DB.execute - db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" - (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) - contactId <- liftIO $ insertedRowId db - liftIO $ - DB.execute - db - "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" - (contactId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - ct <- getContact db vr user contactId - pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) - createBusinessChat = do - let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user - (gInfo@GroupInfo {groupId}, clientMember) <- - createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences - liftIO $ - DB.execute - db - "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" - (groupId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) - updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do - currentTs <- liftIO getCurrentTime - liftIO $ updateProfile currentTs - updateRequest currentTs - ucr' <- getContactRequest db user contactRequestId - re_ <- getRequestEntity ucr' - pure $ RSCurrentRequest (Just ucr) ucr' re_ - where - updateProfile currentTs = - DB.execute - db - [sql| + ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) + :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) + ) + contactRequestId <- liftIO $ insertedRowId db + createRequestEntity ldn profileId contactRequestId currentTs + where + createRequestEntity ldn profileId contactRequestId currentTs + | businessAddress = + if isSimplexTeam && maxV < businessChatsVersion + then createContact' + else createBusinessChat + | otherwise = createContact' + where + createContact' = do + let ctUserPreferences = newContactUserPrefs user profile + liftIO $ + DB.execute + db + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" + (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) + contactId <- liftIO $ insertedRowId db + liftIO $ + DB.execute + db + "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" + (contactId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + ct <- getContact db vr user contactId + pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) + createBusinessChat = do + let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user + (gInfo@GroupInfo {groupId}, clientMember) <- + createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences + liftIO $ + DB.execute + db + "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" + (groupId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) + updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + currentTs <- liftIO getCurrentTime + liftIO $ updateProfile currentTs + updateRequest currentTs + ucr' <- getContactRequest db user contactRequestId + re_ <- getRequestEntity ucr' + pure $ RSCurrentRequest (Just ucr) ucr' re_ + where + updateProfile currentTs = + DB.execute + db + [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, @@ -241,21 +240,20 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) - updateRequest currentTs = - if displayName == oldDisplayName - then - liftIO $ - DB.execute - db - [sql| + (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + updateRequest currentTs = + if displayName == oldDisplayName + then + liftIO $ + DB.execute + db + [sql| UPDATE contact_requests SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) - else - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) + else ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ do DB.execute db @@ -276,21 +274,21 @@ createOrUpdateContactRequest |] (ldn, currentTs, contactId) safeDeleteLDN db user oldLdn - getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) - getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = - case (contactId_, businessGroupId_) of - (Just contactId, Nothing) -> do - ct <- getContact db vr user contactId - pure $ Just (REContact ct) - (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db vr user businessGroupId - case gInfo of - GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - pure $ Just (REBusinessChat gInfo clientMember) - _ -> throwError SEInvalidBusinessChatContactRequest - (Nothing, Nothing) -> pure Nothing - _ -> throwError $ SEInvalidContactRequestEntity contactRequestId + getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) + getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = + case (contactId_, businessGroupId_) of + (Just contactId, Nothing) -> do + ct <- getContact db vr user contactId + pure $ Just (REContact ct) + (Nothing, Just businessGroupId) -> do + gInfo <- getGroupInfo db vr user businessGroupId + case gInfo of + GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + pure $ Just (REBusinessChat gInfo clientMember) + _ -> throwError SEInvalidBusinessChatContactRequest + (Nothing, Nothing) -> pure Nothing + _ -> throwError $ SEInvalidContactRequestEntity contactRequestId setContactAcceptedXContactId :: DB.Connection -> Contact -> XContactId -> IO () setContactAcceptedXContactId db Contact {contactId} xContactId = diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs new file mode 100644 index 0000000000..30fffac0e7 --- /dev/null +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -0,0 +1,376 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} + +module Simplex.Chat.Store.Delivery + ( createMsgDeliveryTask, + deleteGroupDeliveryTasks, + deleteGroupDeliveryJobs, + getPendingDeliveryTaskScopes, + getNextDeliveryTask, + getNextDeliveryTasks, + updateDeliveryTaskStatus, + setDeliveryTaskErrStatus, + deleteDoneDeliveryTasks, + createMsgDeliveryJob, + getPendingDeliveryJobScopes, + getNextDeliveryJob, + updateDeliveryJobStatus, + setDeliveryJobErrStatus, + getGroupMembersByCursor, + updateDeliveryJobCursor, + deleteDoneDeliveryJobs, + ) +where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Delivery +import Simplex.Chat.Protocol hiding (Binary) +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, getWorkItems, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (firstRow') +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Control.Monad.Except +import Data.Either (rights) +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Store.Groups (getGroupMemberById) +#endif + +type DeliveryJobScopeRow = (DeliveryWorkerScope, Maybe DeliveryJobSpecTag, Maybe BoolInt, Maybe GroupMemberId) + +jobScopeRow_ :: DeliveryJobScope -> DeliveryJobScopeRow +jobScopeRow_ = \case + DJSGroup {jobSpec} -> case jobSpec of + DJDeliveryJob {includePending} -> (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) + DJRelayRemoved -> (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) + DJSMemberSupport {supportGMId} -> (DWSMemberSupport, Nothing, Nothing, Just supportGMId) + +toJobScope_ :: DeliveryJobScopeRow -> Maybe DeliveryJobScope +toJobScope_ = \case + (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) -> Just $ DJSGroup {jobSpec = DJDeliveryJob {includePending}} + (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) -> Just $ DJSGroup {jobSpec = DJRelayRemoved} + (DWSMemberSupport, Nothing, Nothing, Just supportGMId) -> Just $ DJSMemberSupport {supportGMId} + _ -> Nothing + +createMsgDeliveryTask :: DB.Connection -> GroupInfo -> GroupMember -> NewMessageDeliveryTask -> IO () +createMsgDeliveryTask db gInfo sender newTask = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO delivery_tasks ( + group_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + sender_group_member_id, message_id, message_from_channel, task_status, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI messageFromChannel, DTSNew, currentTs, currentTs)) + where + GroupInfo {groupId} = gInfo + NewMessageDeliveryTask {messageId, jobScope, messageFromChannel} = newTask + +deleteGroupDeliveryTasks :: DB.Connection -> GroupInfo -> IO () +deleteGroupDeliveryTasks db GroupInfo {groupId} = + DB.execute db "DELETE FROM delivery_tasks WHERE group_id = ?" (Only groupId) + +deleteGroupDeliveryJobs :: DB.Connection -> GroupInfo -> IO () +deleteGroupDeliveryJobs db GroupInfo {groupId} = + DB.execute db "DELETE FROM delivery_jobs WHERE group_id = ?" (Only groupId) + +getPendingDeliveryTaskScopes :: DB.Connection -> IO [DeliveryWorkerKey] +getPendingDeliveryTaskScopes db = + DB.query + db + [sql| + SELECT DISTINCT group_id, worker_scope + FROM delivery_tasks + WHERE failed = 0 AND task_status = ? + |] + (Only DTSNew) + +getNextDeliveryTask :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryTask)) +getNextDeliveryTask db deliveryKey = do + getWorkItem "delivery task" getTaskId (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) + where + (groupId, workerScope) = deliveryKey + getTaskId :: IO (Maybe Int64) + getTaskId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND task_status = ? + ORDER BY delivery_task_id ASC + LIMIT 1 + |] + (groupId, workerScope, DTSNew) + +type MessageDeliveryTaskRow = (Only Int64) :. DeliveryJobScopeRow :. (GroupMemberId, MemberId, ContactName, UTCTime, ChatMessage 'Json, BoolInt) + +getMsgDeliveryTask_ :: DB.Connection -> Int64 -> IO (Either StoreError MessageDeliveryTask) +getMsgDeliveryTask_ db taskId = + firstRow' toTask (SEDeliveryTaskNotFound taskId) $ + DB.query + db + [sql| + SELECT + t.delivery_task_id, + t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + FROM delivery_tasks t + JOIN messages msg ON msg.message_id = t.message_id + JOIN group_members m ON m.group_member_id = t.sender_group_member_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE t.delivery_task_id = ? + |] + (Only taskId) + where + toTask :: MessageDeliveryTaskRow -> Either StoreError MessageDeliveryTask + toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, BI messageFromChannel)) = + case toJobScope_ jobScopeRow of + Just jobScope -> Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel} + Nothing -> Left $ SEInvalidDeliveryTask taskId' + +markDeliveryTaskFailed_ :: DB.Connection -> Int64 -> IO () +markDeliveryTaskFailed_ db taskId = + DB.execute db "UPDATE delivery_tasks SET failed = 1 where delivery_task_id = ?" (Only taskId) + +-- TODO [channels fwd] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks +-- passed MessageDeliveryTask defines the jobScope to search for +getNextDeliveryTasks :: DB.Connection -> GroupInfo -> MessageDeliveryTask -> IO (Either StoreError [Either StoreError MessageDeliveryTask]) +getNextDeliveryTasks db gInfo task = + getWorkItems "message delivery task" getTaskIds (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) + where + GroupInfo {groupId, useRelays} = gInfo + MessageDeliveryTask {jobScope, senderGMId} = task + getTaskIds :: IO [Int64] + getTaskIds + | isTrue useRelays = + map fromOnly + <$> DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (Only DTSNew)) + | otherwise = + -- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering + -- on sender_group_member_id here, so that the job can then retrieve less members as recipients, + -- optimizing for this single sender (see processDeliveryJob -> getForwardIntroducedMembers, etc.). + -- We do this optimization in the job to decrease load on admins using mobile devices for clients. + map fromOnly + <$> DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND sender_group_member_id = ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (senderGMId, DTSNew)) + +updateDeliveryTaskStatus :: DB.Connection -> Int64 -> DeliveryTaskStatus -> IO () +updateDeliveryTaskStatus db taskId status = updateDeliveryTaskStatus_ db taskId status Nothing + +setDeliveryTaskErrStatus :: DB.Connection -> Int64 -> Text -> IO () +setDeliveryTaskErrStatus db taskId errReason = updateDeliveryTaskStatus_ db taskId DTSError (Just errReason) + +updateDeliveryTaskStatus_ :: DB.Connection -> Int64 -> DeliveryTaskStatus -> Maybe Text -> IO () +updateDeliveryTaskStatus_ db taskId status errReason_ = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_tasks SET task_status = ?, task_err_reason = ?, updated_at = ? WHERE delivery_task_id = ?" + (status, errReason_, currentTs, taskId) + +deleteDoneDeliveryTasks :: DB.Connection -> UTCTime -> IO () +deleteDoneDeliveryTasks db createdAtCutoff = do + DB.execute + db + [sql| + DELETE FROM delivery_tasks + WHERE created_at <= ? + AND (task_status IN (?,?) OR failed = 1) + |] + (createdAtCutoff, DTSProcessed, DTSError) + +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO delivery_jobs ( + group_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?) + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + where + GroupInfo {groupId} = gInfo + +getPendingDeliveryJobScopes :: DB.Connection -> IO [DeliveryWorkerKey] +getPendingDeliveryJobScopes db = + DB.query + db + [sql| + SELECT DISTINCT group_id, worker_scope + FROM delivery_jobs + WHERE failed = 0 AND job_status = ? + |] + (Only DJSPending) + +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) + +getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) +getNextDeliveryJob db deliveryKey = do + getWorkItem "delivery job" getJobId getJob markJobFailed + where + (groupId, workerScope) = deliveryKey + getJobId :: IO (Maybe Int64) + getJobId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT delivery_job_id + FROM delivery_jobs + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND job_status = ? + ORDER BY delivery_job_id ASC + LIMIT 1 + |] + (groupId, workerScope, DJSPending) + getJob :: Int64 -> IO (Either StoreError MessageDeliveryJob) + getJob jobId = + firstRow' toDeliveryJob (SEDeliveryJobNotFound jobId) $ + DB.query + db + [sql| + SELECT + delivery_job_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, cursor_group_member_id + FROM delivery_jobs + WHERE delivery_job_id = ? + |] + (Only jobId) + where + toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob + toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = + case toJobScope_ jobScopeRow of + Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} + Nothing -> Left $ SEInvalidDeliveryJob jobId' + markJobFailed :: Int64 -> IO () + markJobFailed jobId = + DB.execute db "UPDATE delivery_jobs SET failed = 1 where delivery_job_id = ?" (Only jobId) + +updateDeliveryJobStatus :: DB.Connection -> Int64 -> DeliveryJobStatus -> IO () +updateDeliveryJobStatus db jobId status = updateDeliveryJobStatus_ db jobId status Nothing + +setDeliveryJobErrStatus :: DB.Connection -> Int64 -> Text -> IO () +setDeliveryJobErrStatus db jobId errReason = updateDeliveryJobStatus_ db jobId DJSError (Just errReason) + +updateDeliveryJobStatus_ :: DB.Connection -> Int64 -> DeliveryJobStatus -> Maybe Text -> IO () +updateDeliveryJobStatus_ db jobId status errReason_ = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ?" + (status, errReason_, currentTs, jobId) + +-- TODO [channels fwd] possible improvement is to prioritize owners and "active" members +getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] +getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do + gmIds :: [Int64] <- + map fromOnly <$> case cursorGMId_ of + Nothing -> + DB.query + db + (query <> orderLimit) + ( (groupId, userContactId, singleSenderGMId_, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete) + :. (Only count) + ) + Just cursorGMId -> + DB.query + db + (query <> " AND group_member_id > ?" <> orderLimit) + ( (groupId, userContactId, singleSenderGMId_, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete) + :. (cursorGMId, count) + ) +#if defined(dbPostgres) + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_member_id IN ?") + (Only (In gmIds)) +#else + rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds +#endif + where + query = + [sql| + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + |] + orderLimit = " ORDER BY group_member_id ASC LIMIT ?" + +updateDeliveryJobCursor :: DB.Connection -> Int64 -> GroupMemberId -> IO () +updateDeliveryJobCursor db jobId cursorGMId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_jobs SET cursor_group_member_id = ?, updated_at = ? WHERE delivery_job_id = ?" + (cursorGMId, currentTs, jobId) + +deleteDoneDeliveryJobs :: DB.Connection -> UTCTime -> IO () +deleteDoneDeliveryJobs db createdAtCutoff = do + DB.execute + db + [sql| + DELETE FROM delivery_jobs + WHERE created_at <= ? + AND (job_status IN (?,?) OR failed = 1) + |] + (createdAtCutoff, DJSComplete, DJSError) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index f25f7f2562..a517a7365b 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -71,7 +71,6 @@ module Simplex.Chat.Store.Direct createAcceptedContactConn, updateContactAccepted, getUserByContactRequestId, - getPendingContactConnections, getContactConnections, getConnectionById, getConnectionsContacts, @@ -244,7 +243,7 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE (user_id = ? AND via_contact_uri_hash = ?) @@ -262,13 +261,13 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -277,8 +276,6 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do ( (c.user_id = ? AND c.via_contact_uri_hash = ?) OR (c.user_id = ? AND c.via_contact_uri_hash = ?) ) AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 |] (userId, cReqHash1, userId, cReqHash2, CSActive) mapM (addDirectChatTags db) ct @@ -349,7 +346,7 @@ createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" Nothing currentTs + contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs getContact db vr user contactId updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -386,7 +383,7 @@ createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection - createDirectContact db vr user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias Nothing currentTs + contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db vr user contactId @@ -792,7 +789,6 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, - viaGroup = Nothing, contactUsed, contactStatus = CSActive, chatSettings = defaultChatSettings, @@ -854,56 +850,27 @@ getContact_ db vr user@User {userId} contactId deleted = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = ? - AND ( - c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 - ) cc - ) - OR c.connection_id IS NULL - ) |] - (userId, contactId, BI deleted, ConnReady, ConnSndReady) + (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User getUserByContactRequestId db contactRequestId = ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) -getPendingContactConnections :: DB.Connection -> User -> IO [PendingContactConnection] -getPendingContactConnections db User {userId} = do - map toPendingContactConnection - <$> DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at - FROM connections - WHERE user_id = ? - AND conn_type = ? - AND contact_id IS NULL - |] - (userId, ConnContact) - getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] getContactConnections db vr userId Contact {contactId} = connections =<< liftIO getConnections_ @@ -913,7 +880,7 @@ getContactConnections db vr userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -931,7 +898,7 @@ getConnectionById db vr User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 76d14ca689..eac046666b 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -12,13 +12,7 @@ {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Files - ( getLiveSndFileTransfers, - getLiveRcvFileTransfers, - getPendingSndChunks, - createSndDirectFTConnection, - createSndGroupFileTransfer, - createSndGroupFileTransferConnection, - createSndDirectInlineFT, + ( createSndDirectInlineFT, createSndGroupInlineFT, updateSndDirectFTDelivery, updateSndGroupFTDelivery, @@ -41,10 +35,6 @@ module Simplex.Chat.Store.Files getChatRefByFileId, lookupChatRefByFileId, updateSndFileStatus, - createSndFileChunk, - updateSndFileChunkMsg, - updateSndFileChunkSent, - deleteSndFileChunks, createRcvFileTransfer, createRcvGroupFileTransfer, createRcvStandaloneFileTransfer, @@ -54,8 +44,6 @@ module Simplex.Chat.Store.Files updateRcvFileAgentId, getRcvFileTransferById, getRcvFileTransfer, - acceptRcvFileTransfer, - getContactByFileId, acceptRcvInlineFT, startRcvInlineFT, xftpAcceptRcvFT, @@ -74,12 +62,10 @@ module Simplex.Chat.Store.Files getFileTransferMeta, lookupFileTransferRedirectMeta, getSndFileTransfer, - getSndFileTransfers, getContactFileInfo, getNoteFolderFileInfo, createLocalFile, getLocalCryptoFile, - getLocalFileMeta, updateDirectCIFileStatus, ) where @@ -99,22 +85,17 @@ import Data.Type.Equality import Data.Word (Word32) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Chat.Util (week) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF -import Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Version import System.FilePath (takeFileName) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..), (:.) (..)) @@ -126,91 +107,6 @@ import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.ToField (ToField) #endif -getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] -getLiveSndFileTransfers db User {userId} = do - cutoffTs <- addUTCTime (-week) <$> getCurrentTime - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT DISTINCT f.file_id - FROM files f - JOIN snd_files s USING (file_id) - WHERE f.user_id = ? - AND s.file_status IN (?, ?, ?) - AND s.file_descr_id IS NULL - AND s.file_inline IS NULL - AND s.created_at > ? - |] - (userId, FSNew, FSAccepted, FSConnected, cutoffTs) - concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds - where - liveTransfer :: SndFileTransfer -> Bool - liveTransfer SndFileTransfer {fileStatus} = fileStatus `elem` [FSNew, FSAccepted, FSConnected] - -getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer] -getLiveRcvFileTransfers db user@User {userId} = do - cutoffTs <- addUTCTime (-week) <$> getCurrentTime - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT f.file_id - FROM files f - JOIN rcv_files r USING (file_id) - WHERE f.user_id = ? AND r.file_status IN (?, ?) - AND r.rcv_file_inline IS NULL - AND r.file_descr_id IS NULL - AND r.created_at > ? - |] - (userId, FSAccepted, FSConnected, cutoffTs) - rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds - -getPendingSndChunks :: DB.Connection -> Int64 -> Int64 -> IO [Integer] -getPendingSndChunks db fileId connId = - map fromOnly - <$> DB.query - db - [sql| - SELECT chunk_number - FROM snd_file_chunks - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id IS NULL - ORDER BY chunk_number - |] - (fileId, connId) - -createSndDirectFTConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () -createSndDirectFTConnection db vr user@User {userId} fileId (cmdId, acId) subMode = do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode - setCommandConnId db user cmdId connId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, FSAccepted, connId, currentTs, currentTs) - -createSndGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> IO FileTransferMeta -createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize, fileInline} chunkSize = do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) - fileId <- insertedRowId db - pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} - -createSndGroupFileTransferConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () -createSndGroupFileTransferConnection db vr user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode - setCommandConnId db user cmdId connId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) - createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> ExceptT StoreError IO SndFileTransfer createSndDirectInlineFT _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Just Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = liftIO $ do @@ -458,63 +354,11 @@ lookupChatRefByFileId db User {userId} fileId = |] (userId, fileId) --- TODO v6.0 remove -createSndFileConnection_ :: DB.Connection -> VersionRangeChat -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection -createSndFileConnection_ db vr userId fileId agentConnId subMode = do - currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId ConnNew (minVersion vr) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff - updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do currentTs <- getCurrentTime DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId) -createSndFileChunk :: DB.Connection -> SndFileTransfer -> IO (Maybe Integer) -createSndFileChunk db SndFileTransfer {fileId, connId, fileSize, chunkSize} = do - chunkNo <- getLastChunkNo - insertChunk chunkNo - pure chunkNo - where - getLastChunkNo = do - ns <- DB.query db "SELECT chunk_number FROM snd_file_chunks WHERE file_id = ? AND connection_id = ? AND chunk_sent = 1 ORDER BY chunk_number DESC LIMIT 1" (fileId, connId) - pure $ case map fromOnly ns of - [] -> Just 1 - n : _ -> if n * chunkSize >= fileSize then Nothing else Just (n + 1) - insertChunk chunkNo_ = forM_ chunkNo_ $ \chunkNo -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, connId, chunkNo, currentTs, currentTs) - -updateSndFileChunkMsg :: DB.Connection -> SndFileTransfer -> Integer -> AgentMsgId -> IO () -updateSndFileChunkMsg db SndFileTransfer {fileId, connId} chunkNo msgId = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_agent_msg_id = ?, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_number = ? - |] - (msgId, currentTs, fileId, connId, chunkNo) - -updateSndFileChunkSent :: DB.Connection -> SndFileTransfer -> AgentMsgId -> IO () -updateSndFileChunkSent db SndFileTransfer {fileId, connId} msgId = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_sent = 1, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id = ? - |] - (currentTs, fileId, connId, msgId) - -deleteSndFileChunks :: DB.Connection -> SndFileTransfer -> IO () -deleteSndFileChunks db SndFileTransfer {fileId, connId} = - DB.execute db "DELETE FROM snd_file_chunks WHERE file_id = ? AND connection_id = ?" (fileId, connId) - createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime @@ -684,11 +528,9 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, - c.connection_id, c.agent_conn_id + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays FROM rcv_files r JOIN files f USING (file_id) - LEFT JOIN connections c ON r.file_id = c.rcv_file_id LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? @@ -699,54 +541,32 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays)) = case contactName_ <|> memberName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> case fileStatus' of FSNew -> pure $ ft name RFSNew - FSAccepted -> ft name . RFSAccepted <$> rfi - FSConnected -> ft name . RFSConnected <$> rfi - FSComplete -> ft name . RFSComplete <$> rfi - FSCancelled -> ft name . RFSCancelled <$> rfi_ + FSAccepted -> ft name . RFSAccepted <$> filePath + FSConnected -> ft name . RFSConnected <$> filePath + FSComplete -> ft name . RFSComplete <$> filePath + FSCancelled -> pure $ ft name (RFSCancelled filePath_) where - standaloneName_ = case (connId_, agentRcvFileId, filePath_) of - (Nothing, Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer + standaloneName_ = case (agentRcvFileId, filePath_) of + (Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer _ -> Nothing ft senderDisplayName fileStatus = let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} - rfi = maybe (throwError $ SERcvFileInvalid fileId) pure =<< rfi_ - rfi_ = case (filePath_, connId_, agentConnId_) of - (Just filePath, connId, agentConnId) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId} - _ -> pure Nothing + filePath = case filePath_ of + Nothing -> throwError $ SERcvFileInvalid fileId + Just fp -> pure fp cancelled = maybe False unBI cancelled_ -acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem -acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do - currentTs <- getCurrentTime - acceptRcvFT_ db user fileId filePath False Nothing currentTs - DB.execute - db - "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, BI (subMode == SMOnlyCreate)) - connId <- insertedRowId db - setCommandConnId db user cmdId connId - runExceptT $ getChatItemByFileId db vr user fileId - -getContactByFileId :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> ExceptT StoreError IO Contact -getContactByFileId db vr user@User {userId} fileId = do - cId <- getContactIdByFileId - getContact db vr user cId - where - getContactIdByFileId = - ExceptT . firstRow fromOnly (SEContactNotFoundByFileId fileId) $ - DB.query db "SELECT contact_id FROM files WHERE user_id = ? AND file_id = ?" (userId, fileId) - acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem acceptRcvInlineFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath False (Just IFMOffer) =<< getCurrentTime @@ -887,8 +707,7 @@ getFileTransferProgress db user fileId = do ft <- getFileTransfer db user fileId liftIO $ (ft,) . map fromOnly <$> case ft of - FTSnd _ [] -> pure [Only 0] - FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) + FTSnd _ _ -> pure [Only 0] FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer @@ -1027,7 +846,7 @@ getLocalCryptoFile db userId fileId sent = when sent $ throwError $ SEFileNotFound fileId RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId case fileStatus of - RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs + RFSComplete filePath -> pure $ CryptoFile filePath cryptoArgs _ -> throwError $ SEFileNotFound fileId [(Just _, Nothing, _)] -> do unless sent $ throwError $ SEFileNotFound fileId diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1b218df56d..00aa527603 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -18,7 +18,6 @@ module Simplex.Chat.Store.Groups GroupInfoRow, GroupMemberRow, MaybeGroupMemberRow, - GroupLink (..), toGroupInfo, toGroupMember, toMaybeGroupMember, @@ -64,16 +63,14 @@ module Simplex.Chat.Store.Groups getScopeMemberIdViaMemberId, getGroupMembers, getGroupModerators, + getGroupRelays, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, deleteGroup, - getUserGroupsToSubscribe, - getUserGroupDetails, - getUserGroupsWithSummary, - getGroupSummary, + getBaseGroupDetails, getContactGroupPreferences, getGroupInvitation, createNewContactMember, @@ -101,7 +98,6 @@ module Simplex.Chat.Store.Groups updateGroupMemberRole, createIntroductions, updateIntroStatus, - saveIntroInvitation, getIntroduction, getIntroducedGroupMemberIds, getForwardIntroducedMembers, @@ -111,9 +107,6 @@ module Simplex.Chat.Store.Groups getForwardScopeMember, createIntroReMember, createIntroToMemberContact, - saveMemberInvitation, - getViaGroupMember, - getViaGroupContact, getMatchingContacts, getMatchingMembers, getMatchingMemberContacts, @@ -122,14 +115,12 @@ module Simplex.Chat.Store.Groups matchReceivedProbe, matchReceivedProbeHash, matchSentProbe, - mergeContactRecords, associateMemberWithContactRecord, associateContactWithMemberRecord, deleteOldProbes, updateGroupSettings, updateGroupMemberSettings, updateGroupMemberBlocked, - getXGrpMemIntroContDirect, getHostConnId, createMemberContact, getMemberContact, @@ -162,9 +153,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import qualified Data.Aeson.TH as J import Data.Bifunctor (second) -import Data.Bitraversable (bitraverse) import Data.Char (toLower) import Data.Either (rights) import Data.Int (Int64) @@ -172,10 +161,11 @@ import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) +import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -188,7 +178,6 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) -import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -230,7 +219,7 @@ getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -280,16 +269,6 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -data GroupLink = GroupLink - { userContactLinkId :: Int64, - connLinkContact :: CreatedLinkContact, - shortLinkDataSet :: Bool, - shortLinkLargeDataSet :: BoolDef, - groupLinkId :: GroupLinkId, - acceptMemberRole :: GroupMemberRole - } - deriving (Show) - getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO GroupLink getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow toGroupLink (SEGroupLinkNotFound gInfo) $ @@ -359,6 +338,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc pure GroupInfo { groupId, + useRelays = BoolDef False, localDisplayName = ldn, groupProfile, localAlias = "", @@ -374,6 +354,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 1, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -432,6 +413,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ pure ( GroupInfo { groupId, + useRelays = BoolDef False, localDisplayName, groupProfile, localAlias = "", @@ -447,6 +429,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 2, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -804,51 +787,6 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members -getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup -getGroupToSubscribe db User {userId, userContactId} groupId = do - shortInfo <- getGroupInfoToSubscribe - members <- liftIO getGroupMembersToSubscribe - pure $ ShortGroup shortInfo members - where - getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo - getGroupInfoToSubscribe = ExceptT $ do - firstRow toInfo (SEGroupNotFound groupId) $ - DB.query - db - [sql| - SELECT g.local_display_name, mu.member_status - FROM groups g - JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? - AND mu.member_status NOT IN (?,?,?) - |] - (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - where - toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo - toInfo (groupName, membershipStatus) = - ShortGroupInfo groupId groupName membershipStatus - getGroupMembersToSubscribe :: IO [ShortGroupMember] - getGroupMembersToSubscribe = do - map toShortMember - <$> DB.query - db - [sql| - SELECT m.group_member_id, m.local_display_name, c.agent_conn_id - FROM group_members m - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) - AND m.member_status NOT IN (?,?,?) - |] - (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - where - toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember - toShortMember (groupMemberId, localDisplayName, agentConnId) = - ShortGroupMember groupMemberId groupId localDisplayName agentConnId - deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItems db User {userId} GroupInfo {groupId} = DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) @@ -940,67 +878,21 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] -getUserGroupsToSubscribe db user@User {userId} = do - groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds - -getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] -getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - g_ <- - map (toGroupInfo vr userContactId []) - <$> DB.query - db - [sql| - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - |] - (userId, userContactId, search, search, search, search) - mapM (addGroupChatTags db) g_ +getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] +getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do + map (toGroupInfo vr userContactId []) + <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where - search = maybe "" (map toLower) search_ - -getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfoSummary] -getUserGroupsWithSummary db vr user _contactId_ search_ = - getUserGroupDetails db vr user _contactId_ search_ - >>= mapM (\g@GroupInfo {groupId} -> GIS g <$> getGroupSummary db user groupId) - --- the statuses on non-current members should match memberCurrent' function -getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary -getGroupSummary db User {userId} groupId = do - currentMembers_ <- - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - |] - (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) - pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} + condition = + [sql| + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] + search = maybe "" (T.map toLower) search_ getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] getContactGroupPreferences db User {userId} Contact {contactId} = do @@ -1021,42 +913,21 @@ getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName getGroupInfo db vr user gId -groupMemberQuery :: Query -groupMemberQuery = - [sql| - SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - |] - getGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember db vr user@User {userId} groupId groupMemberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") - (userId, groupId, groupMemberId, userId) + (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db vr user@User {userId} groupId = +getHostMember db vr user groupId = ExceptT . firstRow (toContactMember vr user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") - (userId, groupId, GCHostMember) + (groupId, GCHostMember) getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = @@ -1097,15 +968,15 @@ getGroupMemberById db vr user@User {userId} groupMemberId = DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") - (userId, groupMemberId, userId) + (groupMemberId, userId) getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db vr user@User {userId} GroupInfo {groupId} memberId = +getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") - (userId, groupId, memberId) + (groupId, memberId) getScopeMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberId -> ExceptT StoreError IO GroupMemberId getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberId @@ -1127,7 +998,7 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, userId, groupId, userContactId) + (userId, groupId, userContactId) getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1135,7 +1006,16 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") - (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) + (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) + +-- TODO [channels fwd] retrieve relays based on knowledge about member from protocol, not role (isMemberRelay) +getGroupRelays :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupRelays db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") + (userId, groupId, userContactId, GRAdmin) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1143,6 +1023,7 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { <$> DB.query db ( groupMemberQuery + <> " " <> [sql| WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_status IN (?, ?, ?, ?) @@ -1151,11 +1032,7 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) |] ) - (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) - -toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember vr User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do @@ -1664,7 +1541,7 @@ createIntroductions db chatV members toMember = do |] (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) introId <- insertedRowId db - pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} + pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} where checkInverseIntro :: IO (Maybe Int64) checkInverseIntro = @@ -1686,56 +1563,21 @@ updateIntroStatus db introId introStatus = do |] (introStatus, currentTs, introId) -saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro -saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} = do - intro <- getIntroduction db reMember toMember - liftIO $ do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE group_member_intros - SET intro_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_intro_id = ? - |] - (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) - pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} - -saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> GroupMemberStatus -> IO () -saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} newMemberStatus = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE group_members - SET member_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_id = ? - |] - (newMemberStatus, groupConnReq, directConnReq, currentTs, groupMemberId) - getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction db reMember toMember = ExceptT $ do - toIntro - <$> DB.query +getIntroduction db reMember toMember = ExceptT $ + firstRow toIntro SEIntroNotFound $ + DB.query db [sql| - SELECT group_member_intro_id, group_queue_info, direct_queue_info, intro_status + SELECT group_member_intro_id, intro_status FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? |] (groupMemberId' reMember, groupMemberId' toMember) where - toIntro :: [(Int64, Maybe ConnReqInvitation, Maybe ConnReqInvitation, GroupMemberIntroStatus)] -> Either StoreError GroupMemberIntro - toIntro [(introId, groupConnReq, directConnReq, introStatus)] = - let introInvitation = IntroInvitation <$> groupConnReq <*> pure directConnReq - in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} - toIntro _ = Left SEIntroNotFound + toIntro :: (Int64, GroupMemberIntroStatus) -> GroupMemberIntro + toIntro (introId, introStatus) = + GroupMemberIntro {introId, reMember, toMember, introStatus} getIntroducedGroupMemberIds :: DB.Connection -> GroupMember -> IO [GroupMemberId] getIntroducedGroupMemberIds db invitee = @@ -1885,8 +1727,8 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = DB.execute db [sql| - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? + INSERT INTO contacts (contact_profile_id local_display_name, user_id, created_at, updated_at, chat_ts) + SELECT contact_profile_id, ?, ?, ?, ?, ? FROM group_members WHERE group_member_id = ? |] @@ -1909,77 +1751,6 @@ createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Version createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff -getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) -getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do - gm_ <- - maybeFirstRow toGroupAndMember $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, - -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - |] - (userId, userId, contactId, userContactId) - mapM (bitraverse (addGroupChatTags db) pure) gm_ - where - toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) - toGroupAndMember (groupInfoRow :. memberRow :. connRow) = - let groupInfo = toGroupInfo vr userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) - -getViaGroupContact :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do - contactId_ <- - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT ct.contact_id - FROM group_members m - JOIN groups g ON g.group_id = m.group_id - JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id - WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - LIMIT 1 - |] - (userId, groupMemberId) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ - updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do @@ -2306,69 +2077,6 @@ getContactOrMember_ db vr user ids = (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId _ -> throwError $ SEInternalError "" --- if requested merge direction is overruled (toFromContacts), keepLDN is kept -mergeContactRecords :: DB.Connection -> VersionRangeChat -> User -> Contact -> Contact -> ExceptT StoreError IO Contact -mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keepLDN} from = do - let (toCt, fromCt) = toFromContacts to from - Contact {contactId = toContactId, localDisplayName = toLDN} = toCt - Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt - assertNotUser db user toCt - assertNotUser db user fromCt - liftIO $ do - currentTs <- getCurrentTime - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - [sql| - UPDATE group_members - SET contact_id = ?, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), - updated_at = ? - WHERE contact_id = ? - AND user_id = ? - |] - (toContactId, toContactId, toContactId, currentTs, fromContactId, userId) - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - deleteUnusedDisplayName_ db userId fromLDN - when (keepLDN /= toLDN && keepLDN == fromLDN) $ - DB.execute - db - [sql| - UPDATE display_names - SET local_display_name = ?, updated_at = ? - WHERE user_id = ? AND local_display_name = ? - |] - (keepLDN, currentTs, userId, toLDN) - getContact db vr user toContactId - where - toFromContacts :: Contact -> Contact -> (Contact, Contact) - toFromContacts c1 c2 - | d1 && not d2 = (c1, c2) - | d2 && not d1 = (c2, c1) - | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) - | otherwise = (c2, c1) - where - d1 = directOrUsed c1 - d2 = directOrUsed c2 - ctCreatedAt Contact {createdAt} = createdAt - associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () associateMemberWithContactRecord db @@ -2492,36 +2200,6 @@ updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember (mrs, currentTs, userId, groupId, groupMemberId) pure m {blockedByAdmin = mrsBlocked mrs} -getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) -getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do - fmap join . maybeFirstRow toCont $ - DB.query - db - [sql| - SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv - FROM contacts ct - JOIN group_members m ON m.contact_id = ct.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.group_member_id = m.group_member_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? - |] - (userId, userId, contactId, GCHostMember) - where - toCont :: (Int64, GroupId, GroupMemberId, MemberId, Maybe ConnReqInvitation) -> Maybe (Int64, XGrpMemIntroCont) - toCont (hostConnId, groupId, groupMemberId, memberId, connReq_) = case connReq_ of - Just groupConnReq -> Just (hostConnId, XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}) - _ -> Nothing - getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostConnId db user@User {userId} groupId = do hostMemberId <- getHostMemberId_ db user groupId @@ -2598,7 +2276,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2903,5 +2581,3 @@ updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) pure (g :: GroupInfo) {localAlias = localAlias} - -$(J.deriveJSON defaultJSON ''GroupLink) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c152bdc4bf..47aabd16ee 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -286,7 +286,7 @@ getLastRcvMsgInfo db connId = RcvMsgInfo {msgId, msgDeliveryId, msgDeliveryStatus, agentMsgId, agentMsgMeta} createNewRcvMessage :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = +createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody, brokerTs} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of @@ -314,10 +314,12 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} share db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) + ((MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, brokerTs, currentTs, currentTs, connId_, groupId_) + :. (sharedMsgId_, authorMember, forwardedByMember)) msgId <- insertedRowId db pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} @@ -509,17 +511,17 @@ setSupportChatTs db groupMemberId chatTs = setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) setSupportChatMemberAttention db vr user g m memberAttention = do - m' <- updateGMAttention m + m' <- updateGMAttention g' <- updateGroupMembersRequireAttention db user g m m' pure (g', m') where - updateGMAttention m@GroupMember {groupMemberId} = do + updateGMAttention = do currentTs <- getCurrentTime DB.execute db "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" (memberAttention, currentTs, groupMemberId' m) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId @@ -1165,7 +1167,7 @@ checkContactHasItems db User {userId} Contact {contactId} = "SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id = ?)" (userId, contactId) -getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChat db vr user contactId pagination search_ = do let search = fromMaybe "" search_ ct <- getContact db vr user contactId @@ -1175,18 +1177,18 @@ getDirectChat db vr user contactId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getDirectChatBefore_ db user ct beforeId count search CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getDirectChatInitial_ db user ct count -- the last items in reverse order (the last item in the conversation is the first in the returned list) -getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO (Chat 'CTDirect) getDirectChatLast_ db user ct count search = do ciIds <- getDirectChatItemIdsLast_ db user ct count search ts <- getCurrentTime cis <- mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] +getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO [ChatItemId] getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = map fromOnly <$> DB.query @@ -1243,7 +1245,7 @@ getDirectChatItemLast db user@User {userId} contactId = do (userId, contactId) getDirectChatItem db user contactId chatItemId -getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do afterCI <- getDirectChatItem db user contactId afterId ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search @@ -1251,7 +1253,7 @@ getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) cis emptyChatStats -getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = map fromOnly <$> DB.query @@ -1266,7 +1268,7 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = |] (userId, contactId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do beforeCI <- getDirectChatItem db user contactId beforeId ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search @@ -1274,7 +1276,7 @@ getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = map fromOnly <$> DB.query @@ -1289,12 +1291,12 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = |] (userId, contactId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround_ db user ct aroundId count search = do stats <- liftIO $ getContactStats_ db user ct getDirectChatAround' db user ct aroundId count search stats -getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround' db user ct@Contact {contactId} aroundId count search stats = do aroundCI <- getDirectChatItem db user contactId aroundId beforeIds <- liftIO $ getDirectCIsBefore_ db user ct aroundCI count search @@ -1402,7 +1404,7 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId @@ -1413,7 +1415,7 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g scopeInfo contentFilter beforeId count search CPAround aroundId count -> getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g scopeInfo contentFilter count getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo @@ -1464,7 +1466,7 @@ getGroupChatScopeForItem_ db itemId = (Nothing, Nothing) -> Nothing (Nothing, Just _) -> Nothing -- shouldn't happen -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do ciIds <- getGroupChatItemIDs db user g scopeInfo_ contentFilter GRLast count search ts <- liftIO getCurrentTime @@ -1473,7 +1475,7 @@ getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> ExceptT StoreError IO [ChatItemId] +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> Text -> ExceptT StoreError IO [ChatItemId] getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter range count search = case (scopeInfo_, contentFilter) of (Nothing, Nothing) -> liftIO $ @@ -1518,7 +1520,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilte orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob - | null search = searchQuery "" () + | T.null search = searchQuery "" () | otherwise = searchQuery " AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' " (Only search) where searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] @@ -1568,7 +1570,7 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) @@ -1577,7 +1579,7 @@ getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) @@ -1586,12 +1588,12 @@ getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter before cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search = do stats <- getGroupStats_ db user g scopeInfo getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats = do aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) @@ -1734,7 +1736,7 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) ) -getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat db user folderId pagination search_ = do let search = fromMaybe "" search_ nf <- getNoteFolder db user folderId @@ -1744,17 +1746,17 @@ getLocalChat db user folderId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getLocalChatBefore_ db user nf beforeId count search CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getLocalChatInitial_ db user nf count -getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) +getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO (Chat 'CTLocal) getLocalChatLast_ db user nf count search = do ciIds <- getLocalChatItemIdsLast_ db user nf count search ts <- getCurrentTime cis <- mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] +getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO [ChatItemId] getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = map fromOnly <$> DB.query @@ -1795,7 +1797,7 @@ safeToLocalItem currentTs itemId = \case file = Nothing } -getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do afterCI <- getLocalChatItem db user noteFolderId afterId ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search @@ -1803,7 +1805,7 @@ getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = d cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) cis emptyChatStats -getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = map fromOnly <$> DB.query @@ -1818,7 +1820,7 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc |] (userId, noteFolderId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do beforeCI <- getLocalChatItem db user noteFolderId beforeId ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search @@ -1826,7 +1828,7 @@ getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = map fromOnly <$> DB.query @@ -1841,12 +1843,12 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea |] (userId, noteFolderId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround_ db user nf aroundId count search = do stats <- liftIO $ getLocalStats_ db user nf getLocalChatAround' db user nf aroundId count search stats -getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround' db user nf@NoteFolder {noteFolderId} aroundId count search stats = do aroundCI <- getLocalChatItem db user noteFolderId aroundId beforeIds <- liftIO $ getLocalCIsBefore_ db user nf aroundCI count search @@ -2037,7 +2039,7 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i pure (chatItemId, deleteAt) updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> IO () -updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} = do +updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do currentTs <- getCurrentTime DB.execute db @@ -2082,10 +2084,11 @@ updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, member UPDATE group_members SET support_chat_items_unread = 0, support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 + support_chat_items_mentions = 0, + updated_at = ? WHERE group_member_id = ? |] - (Only groupMemberId) + (currentTs, groupMemberId) m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it @@ -2201,8 +2204,6 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -deriving instance Show BoolInt - setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do let deleteAt = addUTCTime (realToFrac ttl) currentTs @@ -2369,7 +2370,7 @@ toGroupChatItem ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2378,7 +2379,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" liftIO getFirstUnreadItemId_ >>= \case Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Nothing -> liftIO $ getAllChatItemsLast_ count diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index ce1987db15..89f8f6070b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -16,6 +16,11 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250721_indexes import Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type +import Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks +import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary +import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections +import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync +import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -31,7 +36,12 @@ schemaMigrations = ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), - ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type) + ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), + ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), + ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs new file mode 100644 index 0000000000..688c0aee76 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs @@ -0,0 +1,128 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250813_delivery_tasks :: Text +m20250813_delivery_tasks = + T.pack + [r| +CREATE TABLE delivery_tasks ( + delivery_task_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending SMALLINT, + job_scope_support_gm_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id BIGINT REFERENCES messages ON DELETE CASCADE, + message_from_channel SMALLINT NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed SMALLINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks(job_scope_support_gm_id); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks(sender_group_member_id); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); + +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); + + + +CREATE TABLE delivery_jobs ( + delivery_job_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending SMALLINT, + job_scope_support_gm_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BYTEA, + cursor_group_member_id BIGINT, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed SMALLINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs(job_scope_support_gm_id); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); + +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); + + + +ALTER TABLE messages ADD COLUMN broker_ts TIMESTAMPTZ; +|] + +down_m20250813_delivery_tasks :: Text +down_m20250813_delivery_tasks = + T.pack + [r| +ALTER TABLE messages DROP COLUMN broker_ts; + + + +DROP INDEX idx_delivery_jobs_group_id; +DROP INDEX idx_delivery_jobs_job_scope_support_gm_id; +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +DROP INDEX idx_delivery_jobs_next; +DROP INDEX idx_delivery_jobs_created_at; + +DROP TABLE delivery_jobs; + + + +DROP INDEX idx_delivery_tasks_group_id; +DROP INDEX idx_delivery_tasks_job_scope_support_gm_id; +DROP INDEX idx_delivery_tasks_sender_group_member_id; +DROP INDEX idx_delivery_tasks_message_id; + +DROP INDEX idx_delivery_tasks_next; +DROP INDEX idx_delivery_tasks_next_for_job_scope; +DROP INDEX idx_delivery_tasks_next_for_job_scope_sender; +DROP INDEX idx_delivery_tasks_created_at; + +DROP TABLE delivery_tasks; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..915781d2c0 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250919_group_summary :: Text +m20250919_group_summary = + T.pack + [r| +ALTER TABLE groups ADD COLUMN summary_current_members_count BIGINT NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE FUNCTION is_current_member(p_status TEXT) RETURNS BOOLEAN +LANGUAGE plpgsql AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + +UPDATE groups g +SET summary_current_members_count = COALESCE(c.cnt, 0) +FROM ( + SELECT group_id, COUNT(group_member_id) AS cnt + FROM group_members + WHERE is_current_member(member_status) = TRUE + GROUP BY group_id +) c +WHERE g.group_id = c.group_id; + +CREATE FUNCTION on_group_members_insert_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE FUNCTION on_group_members_delete_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + +CREATE FUNCTION on_group_members_update_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER tr_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_insert_update_summary(); + +CREATE TRIGGER tr_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_delete_update_summary(); + +CREATE TRIGGER tr_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_update_update_summary(); +|] + +down_m20250919_group_summary :: Text +down_m20250919_group_summary = + T.pack + [r| +DROP TRIGGER tr_group_members_insert_update_summary ON group_members; +DROP TRIGGER tr_group_members_delete_update_summary ON group_members; +DROP TRIGGER tr_group_members_update_update_summary ON group_members; + +DROP FUNCTION on_group_members_insert_update_summary; +DROP FUNCTION on_group_members_delete_update_summary; +DROP FUNCTION on_group_members_update_update_summary; + +DROP FUNCTION is_current_member; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs new file mode 100644 index 0000000000..7ebba83177 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs @@ -0,0 +1,96 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250922_remove_unused_connections :: Text +m20250922_remove_unused_connections = + T.pack + [r| +DELETE FROM connections WHERE snd_file_id IS NOT NULL; +DELETE FROM connections WHERE rcv_file_id IS NOT NULL; + +DROP TABLE snd_file_chunks; + +WITH ranked_contact_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.contact_id + ORDER BY + CASE WHEN c.conn_status = 'ready' OR c.conn_status = 'snd-ready' THEN 1 ELSE 0 END DESC, + c.created_at DESC + ) AS rn + FROM connections c + WHERE c.contact_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_contact_connections + WHERE rn > 1 +); + +WITH ranked_group_member_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.group_member_id + ORDER BY c.connection_id DESC + ) AS rn + FROM connections c + WHERE c.group_member_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_group_member_connections + WHERE rn > 1 +); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(user_id, to_subscribe); + +DROP INDEX idx_contacts_via_group; +ALTER TABLE contacts DROP COLUMN via_group; +|] + +down_m20250922_remove_unused_connections :: Text +down_m20250922_remove_unused_connections = + T.pack + [r| +CREATE TABLE snd_file_chunks( + file_id BIGINT NOT NULL, + connection_id BIGINT NOT NULL, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT, + chunk_sent SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +); + +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); + +ALTER TABLE contacts ADD COLUMN via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs new file mode 100644 index 0000000000..08d88eeea8 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251007_connections_sync :: Text +m20251007_connections_sync = + T.pack + [r| +CREATE TABLE connections_sync( + connections_sync_id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + should_sync SMALLINT NOT NULL DEFAULT 0, + last_sync_ts TIMESTAMPTZ +); + +INSERT INTO connections_sync (connections_sync_id, should_sync, last_sync_ts) VALUES (1, 1, NULL); +|] + +down_m20251007_connections_sync :: Text +down_m20251007_connections_sync = + T.pack + [r| +DROP TABLE connections_sync; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs new file mode 100644 index 0000000000..f5a227a3c5 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251017_chat_tags_cascade :: Text +m20251017_chat_tags_cascade = + T.pack + [r| +ALTER TABLE chat_tags DROP CONSTRAINT chat_tags_user_id_fkey; + +ALTER TABLE chat_tags + ADD CONSTRAINT chat_tags_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES users(user_id) + ON DELETE CASCADE; +|] + +down_m20251017_chat_tags_cascade :: Text +down_m20251017_chat_tags_cascade = + T.pack + [r| +ALTER TABLE chat_tags DROP CONSTRAINT chat_tags_user_id_fkey; + +ALTER TABLE chat_tags + ADD CONSTRAINT chat_tags_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES users(user_id); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index c0560de376..601dd97a6e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -15,6 +15,76 @@ SET row_security = off; CREATE SCHEMA test_chat_schema; + +CREATE FUNCTION test_chat_schema.is_current_member(p_status text) RETURNS boolean + LANGUAGE plpgsql + AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_insert_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_update_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + SET default_table_access_method = heap; @@ -320,6 +390,25 @@ ALTER TABLE test_chat_schema.connections ALTER COLUMN connection_id ADD GENERATE +CREATE TABLE test_chat_schema.connections_sync ( + connections_sync_id bigint NOT NULL, + should_sync smallint DEFAULT 0 NOT NULL, + last_sync_ts timestamp with time zone +); + + + +ALTER TABLE test_chat_schema.connections_sync ALTER COLUMN connections_sync_id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME test_chat_schema.connections_sync_connections_sync_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.contact_profiles ( contact_profile_id bigint NOT NULL, display_name text NOT NULL, @@ -388,7 +477,6 @@ CREATE TABLE test_chat_schema.contacts ( user_id bigint NOT NULL, local_display_name text NOT NULL, is_user smallint DEFAULT 0 NOT NULL, - via_group bigint, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone NOT NULL, xcontact_id bytea, @@ -432,6 +520,66 @@ ALTER TABLE test_chat_schema.contacts ALTER COLUMN contact_id ADD GENERATED ALWA +CREATE TABLE test_chat_schema.delivery_jobs ( + delivery_job_id bigint NOT NULL, + group_id bigint NOT NULL, + worker_scope text NOT NULL, + job_scope_spec_tag text, + job_scope_include_pending smallint, + job_scope_support_gm_id bigint, + single_sender_group_member_id bigint, + body bytea, + cursor_group_member_id bigint, + job_status text NOT NULL, + job_err_reason text, + failed smallint DEFAULT 0, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.delivery_jobs ALTER COLUMN delivery_job_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.delivery_jobs_delivery_job_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.delivery_tasks ( + delivery_task_id bigint NOT NULL, + group_id bigint NOT NULL, + worker_scope text NOT NULL, + job_scope_spec_tag text, + job_scope_include_pending smallint, + job_scope_support_gm_id bigint, + sender_group_member_id bigint NOT NULL, + message_id bigint, + message_from_channel smallint DEFAULT 0 NOT NULL, + task_status text NOT NULL, + task_err_reason text, + failed smallint DEFAULT 0, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.delivery_tasks ALTER COLUMN delivery_task_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.delivery_tasks_delivery_task_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.display_names ( user_id bigint NOT NULL, local_display_name text NOT NULL, @@ -656,7 +804,8 @@ CREATE TABLE test_chat_schema.groups ( welcome_shared_msg_id bytea, request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, - via_group_link_uri bytea + via_group_link_uri bytea, + summary_current_members_count bigint DEFAULT 0 NOT NULL ); @@ -707,7 +856,8 @@ CREATE TABLE test_chat_schema.messages ( shared_msg_id bytea, shared_msg_id_user smallint, author_group_member_id bigint, - forwarded_by_group_member_id bigint + forwarded_by_group_member_id bigint, + broker_ts timestamp with time zone ); @@ -1051,18 +1201,6 @@ ALTER TABLE test_chat_schema.settings ALTER COLUMN settings_id ADD GENERATED ALW -CREATE TABLE test_chat_schema.snd_file_chunks ( - file_id bigint NOT NULL, - connection_id bigint NOT NULL, - chunk_number bigint NOT NULL, - chunk_agent_msg_id bigint, - chunk_sent smallint DEFAULT 0 NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - - - CREATE TABLE test_chat_schema.snd_files ( file_id bigint NOT NULL, connection_id bigint NOT NULL, @@ -1239,6 +1377,11 @@ ALTER TABLE ONLY test_chat_schema.connections +ALTER TABLE ONLY test_chat_schema.connections_sync + ADD CONSTRAINT connections_sync_pkey PRIMARY KEY (connections_sync_id); + + + ALTER TABLE ONLY test_chat_schema.contact_profiles ADD CONSTRAINT contact_profiles_pkey PRIMARY KEY (contact_profile_id); @@ -1274,6 +1417,16 @@ ALTER TABLE ONLY test_chat_schema.contacts +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_pkey PRIMARY KEY (delivery_job_id); + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_pkey PRIMARY KEY (delivery_task_id); + + + ALTER TABLE ONLY test_chat_schema.display_names ADD CONSTRAINT display_names_pkey PRIMARY KEY (user_id, local_display_name); @@ -1439,11 +1592,6 @@ ALTER TABLE ONLY test_chat_schema.settings -ALTER TABLE ONLY test_chat_schema.snd_file_chunks - ADD CONSTRAINT snd_file_chunks_pkey PRIMARY KEY (file_id, connection_id, chunk_number); - - - ALTER TABLE ONLY test_chat_schema.snd_files ADD CONSTRAINT snd_files_pkey PRIMARY KEY (file_id, connection_id); @@ -1725,7 +1873,7 @@ CREATE INDEX idx_connections_conn_req_inv ON test_chat_schema.connections USING -CREATE INDEX idx_connections_contact_id ON test_chat_schema.connections USING btree (contact_id); +CREATE UNIQUE INDEX idx_connections_contact_id ON test_chat_schema.connections USING btree (contact_id); @@ -1737,7 +1885,7 @@ CREATE INDEX idx_connections_group_member ON test_chat_schema.connections USING -CREATE INDEX idx_connections_group_member_id ON test_chat_schema.connections USING btree (group_member_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON test_chat_schema.connections USING btree (group_member_id); @@ -1745,7 +1893,7 @@ CREATE INDEX idx_connections_rcv_file_id ON test_chat_schema.connections USING b -CREATE INDEX idx_connections_to_subscribe ON test_chat_schema.connections USING btree (to_subscribe); +CREATE INDEX idx_connections_to_subscribe ON test_chat_schema.connections USING btree (user_id, to_subscribe); @@ -1829,14 +1977,62 @@ CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON test_chat_schema -CREATE INDEX idx_contacts_via_group ON test_chat_schema.contacts USING btree (via_group); - - - CREATE INDEX idx_contacts_xcontact_id ON test_chat_schema.contacts USING btree (xcontact_id); +CREATE INDEX idx_delivery_jobs_created_at ON test_chat_schema.delivery_jobs USING btree (created_at); + + + +CREATE INDEX idx_delivery_jobs_group_id ON test_chat_schema.delivery_jobs USING btree (group_id); + + + +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON test_chat_schema.delivery_jobs USING btree (job_scope_support_gm_id); + + + +CREATE INDEX idx_delivery_jobs_next ON test_chat_schema.delivery_jobs USING btree (group_id, worker_scope, failed, job_status); + + + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON test_chat_schema.delivery_jobs USING btree (single_sender_group_member_id); + + + +CREATE INDEX idx_delivery_tasks_created_at ON test_chat_schema.delivery_tasks USING btree (created_at); + + + +CREATE INDEX idx_delivery_tasks_group_id ON test_chat_schema.delivery_tasks USING btree (group_id); + + + +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON test_chat_schema.delivery_tasks USING btree (job_scope_support_gm_id); + + + +CREATE INDEX idx_delivery_tasks_message_id ON test_chat_schema.delivery_tasks USING btree (message_id); + + + +CREATE INDEX idx_delivery_tasks_next ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, sender_group_member_id, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON test_chat_schema.delivery_tasks USING btree (sender_group_member_id); + + + CREATE INDEX idx_extra_xftp_file_descriptions_file_id ON test_chat_schema.extra_xftp_file_descriptions USING btree (file_id); @@ -1941,6 +2137,10 @@ CREATE INDEX idx_groups_inv_queue_info ON test_chat_schema.groups USING btree (i +CREATE INDEX idx_groups_summary_current_members_count ON test_chat_schema.groups USING btree (summary_current_members_count); + + + CREATE INDEX idx_groups_via_group_link_uri_hash ON test_chat_schema.groups USING btree (user_id, via_group_link_uri_hash); @@ -2089,10 +2289,6 @@ CREATE INDEX idx_smp_servers_user_id ON test_chat_schema.protocol_servers USING -CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON test_chat_schema.snd_file_chunks USING btree (file_id, connection_id); - - - CREATE INDEX idx_snd_files_connection_id ON test_chat_schema.snd_files USING btree (connection_id); @@ -2125,6 +2321,18 @@ CREATE INDEX note_folders_user_id ON test_chat_schema.note_folders USING btree ( +CREATE TRIGGER tr_group_members_delete_update_summary AFTER DELETE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_delete_update_summary(); + + + +CREATE TRIGGER tr_group_members_insert_update_summary AFTER INSERT ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_insert_update_summary(); + + + +CREATE TRIGGER tr_group_members_update_update_summary AFTER UPDATE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_update_update_summary(); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -2271,7 +2479,7 @@ ALTER TABLE ONLY test_chat_schema.chat_tags_chats ALTER TABLE ONLY test_chat_schema.chat_tags - ADD CONSTRAINT chat_tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id); + ADD CONSTRAINT chat_tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; @@ -2390,6 +2598,41 @@ ALTER TABLE ONLY test_chat_schema.contacts +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_job_scope_support_gm_id_fkey FOREIGN KEY (job_scope_support_gm_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_single_sender_group_member_id_fkey FOREIGN KEY (single_sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_job_scope_support_gm_id_fkey FOREIGN KEY (job_scope_support_gm_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_message_id_fkey FOREIGN KEY (message_id) REFERENCES test_chat_schema.messages(message_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_sender_group_member_id_fkey FOREIGN KEY (sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.display_names ADD CONSTRAINT display_names_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; @@ -2445,11 +2688,6 @@ ALTER TABLE ONLY test_chat_schema.contacts -ALTER TABLE ONLY test_chat_schema.contacts - ADD CONSTRAINT fk_contacts_groups FOREIGN KEY (via_group) REFERENCES test_chat_schema.groups(group_id) ON DELETE SET NULL; - - - ALTER TABLE ONLY test_chat_schema.files ADD CONSTRAINT fk_files_chat_items FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -2705,11 +2943,6 @@ ALTER TABLE ONLY test_chat_schema.settings -ALTER TABLE ONLY test_chat_schema.snd_file_chunks - ADD CONSTRAINT snd_file_chunks_file_id_connection_id_fkey FOREIGN KEY (file_id, connection_id) REFERENCES test_chat_schema.snd_files(file_id, connection_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.snd_files ADD CONSTRAINT snd_files_file_id_fkey FOREIGN KEY (file_id) REFERENCES test_chat_schema.files(file_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 64fbbddda0..af46c14b83 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -46,7 +46,6 @@ module Simplex.Chat.Store.Profiles getUserContactProfiles, createUserContactLink, getUserAddressConnection, - getUserContactLinks, deleteUserAddress, getUserAddress, getUserContactLinkById, @@ -322,9 +321,9 @@ updateUserProfile db user p' DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (currentTs, userId) pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt - userMemberProfileChanged = newName /= displayName || newFullName /= fullName || newImage /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, image, localAlias}, userMemberProfileUpdatedAt} = user - Profile {displayName = newName, fullName = newFullName, image = newImage, preferences} = p' + userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences @@ -378,7 +377,7 @@ getUserAddressConnection db vr User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -387,26 +386,6 @@ getUserAddressConnection db vr User {userId} = do |] (userId, userId) -getUserContactLinks :: DB.Connection -> VersionRangeChat -> User -> IO [(Connection, UserContact)] -getUserContactLinks db vr User {userId} = - map toUserContactConnection - <$> DB.query - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, - uc.user_contact_link_id, uc.conn_req_contact, uc.group_id - FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? - |] - (userId, userId) - where - toUserContactConnection :: (ConnectionRow :. (Int64, ConnReqContact, Maybe GroupId)) -> (Connection, UserContact) - toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection vr connRow, UserContact {userContactLinkId, connReqContact, groupId}) - deleteUserAddress :: DB.Connection -> User -> IO () deleteUserAddress db user@User {userId} = do DB.execute diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3d5fc307ee..1c819e6537 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -139,6 +139,11 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250721_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type +import Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks +import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary +import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections +import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync +import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -277,7 +282,12 @@ schemaMigrations = ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), - ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type) + ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), + ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), + ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs new file mode 100644 index 0000000000..0b70fb9dcb --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs @@ -0,0 +1,164 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- TODO [channels fwd] add later in new migration for MemberProfileUpdate delivery jobs: +-- TODO - ALTER TABLE group_members ADD COLUMN last_profile_delivery_ts TEXT; +-- TODO - ALTER TABLE group_members ADD COLUMN join_ts TEXT; + +-- How columns correspond to types: + +-- both tables: +-- - <-> DeliveryWorkerKey = (GroupId, DeliveryWorkerScope), +-- - job_scope_spec_tag <-> Maybe DeliveryJobSpecTag (for DJSGroup job scope), +-- - job_scope_include_pending <-> Maybe Bool (for DJDeliveryJob job spec), +-- - job_scope_support_gm_id <-> Maybe GroupMemberId (for DJSMemberSupport job scope), +-- - failed <-> Bool (for internal worker use, to mark failed work items). + +-- delivery_tasks table: +-- - sender_group_member_id <-> GroupMemberId (sender of the original message that created task), +-- - message_id <-> MessageId (reference to the original message that created task), +-- - message_from_channel <-> Maybe MessageFromChannel (for MessageDeliveryTask), +-- - task_status <-> DeliveryTaskStatus, +-- - task_err_reason <-> Maybe Text (set when task status is DTSError, not encoded in status to allow filtering by DTSError in queries). + +-- delivery_jobs table: +-- - single_sender_group_member_id <-> Maybe GroupMemberId (set when all messages in job's delivery body are from the same sender), +-- - body <-> ByteString (JSON encoded batch of messages or single message packed with forwarding metadata (XGrpMsgForward)), +-- - cursor_group_member_id <-> Maybe GroupMemberId (for tracking progress of job processing buckets of recipient members), +-- - job_status <-> DeliveryJobStatus, +-- - job_err_reason <-> Maybe Text (set when job status is DJSError, not encoded in status to allow filtering by DJSError in queries). +-- +-- Pair of columns defines the scope of work for a worker. +-- +-- Both tasks and jobs are defined by the same range of worker scopes, with a delivery task worker +-- converting tasks into jobs for a delivery job worker of the same scope. +-- Each group can have up to 1 task worker and 1 job worker for each worker scope. +-- See DeliveryWorkerScope. +-- +-- Columns job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id narrow down delivery scope for a job. +-- See DeliveryJobScope. +-- +-- In some cases multiple tasks of the same job scope may be converted into a single job. +-- For example, messages referenced in DJDeliveryJob tasks of the same scope/spec can be batched for a single delivery. +-- Some tasks, for example of DJRelayRemoved specialization, are converted one-to-one into jobs. +-- +-- Delivery scopes can be expanded to create more specialized jobs. +-- For example DWSMemberProfileUpdate workers are planned to deliver senders' profiles to +-- all members satisfying criteria: sender last_profile_delivery_ts < recipient join_ts. +-- Tasks for these jobs are planned to be created based on sender and group state, rather than per message, +-- so member profile updates will be delivered separately from message deliveries. +-- See rfc doc for more details. +m20250813_delivery_tasks :: Query +m20250813_delivery_tasks = + [sql| +CREATE TABLE delivery_tasks ( + delivery_task_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages ON DELETE CASCADE, + message_from_channel INTEGER NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks(job_scope_support_gm_id); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks(sender_group_member_id); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); + +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); + +CREATE TABLE delivery_jobs ( + delivery_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BLOB, + cursor_group_member_id INTEGER, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs(job_scope_support_gm_id); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); + +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); + +ALTER TABLE messages ADD COLUMN broker_ts TEXT; +|] + +down_m20250813_delivery_tasks :: Query +down_m20250813_delivery_tasks = + [sql| +ALTER TABLE messages DROP COLUMN broker_ts; + +DROP INDEX idx_delivery_jobs_group_id; +DROP INDEX idx_delivery_jobs_job_scope_support_gm_id; +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +DROP INDEX idx_delivery_jobs_next; +DROP INDEX idx_delivery_jobs_created_at; + +DROP TABLE delivery_jobs; + +DROP INDEX idx_delivery_tasks_group_id; +DROP INDEX idx_delivery_tasks_job_scope_support_gm_id; +DROP INDEX idx_delivery_tasks_sender_group_member_id; +DROP INDEX idx_delivery_tasks_message_id; + +DROP INDEX idx_delivery_tasks_next; +DROP INDEX idx_delivery_tasks_next_for_job_scope; +DROP INDEX idx_delivery_tasks_next_for_job_scope_sender; +DROP INDEX idx_delivery_tasks_created_at; + +DROP TABLE delivery_tasks; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..0a9e7451e3 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs @@ -0,0 +1,95 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250919_group_summary :: Query +m20250919_group_summary = + [sql| +ALTER TABLE groups ADD COLUMN summary_current_members_count INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO group_member_status_predicates(member_status, current_member) +VALUES + ('rejected', 0), + ('removed', 0), + ('left', 0), + ('deleted', 0), + ('unknown', 0), + ('invited', 0), + ('pending_approval', 0), + ('pending_review', 0), + ('introduced', 1), + ('intro-inv', 1), + ('accepted', 1), + ('announced', 1), + ('connected', 1), + ('complete', 1), + ('creator', 1); + +UPDATE groups +SET summary_current_members_count = c.cnt +FROM ( + SELECT m.group_id, COUNT(m.group_member_id) AS cnt + FROM group_members m + JOIN group_member_status_predicates p ON m.member_status = p.member_status + WHERE p.current_member = 1 + GROUP BY m.group_id +) AS c +WHERE groups.group_id = c.group_id; + +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; + +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; + +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; +|] + +down_m20250919_group_summary :: Query +down_m20250919_group_summary = + [sql| +DROP TRIGGER on_group_members_insert_update_summary; +DROP TRIGGER on_group_members_delete_update_summary; +DROP TRIGGER on_group_members_update_update_summary; + +DROP TABLE group_member_status_predicates; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs new file mode 100644 index 0000000000..d159065b13 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs @@ -0,0 +1,93 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250922_remove_unused_connections :: Query +m20250922_remove_unused_connections = + [sql| +DELETE FROM connections WHERE snd_file_id IS NOT NULL; +DELETE FROM connections WHERE rcv_file_id IS NOT NULL; + +DROP TABLE snd_file_chunks; + +WITH ranked_contact_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.contact_id + ORDER BY + CASE WHEN c.conn_status = 'ready' OR c.conn_status = 'snd-ready' THEN 1 ELSE 0 END DESC, + c.created_at DESC + ) AS rn + FROM connections c + WHERE c.contact_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_contact_connections + WHERE rn > 1 +); + +WITH ranked_group_member_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.group_member_id + ORDER BY c.connection_id DESC + ) AS rn + FROM connections c + WHERE c.group_member_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_group_member_connections + WHERE rn > 1 +); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(user_id, to_subscribe); + +DROP INDEX idx_contacts_via_group; +ALTER TABLE contacts DROP COLUMN via_group; +|] + +down_m20250922_remove_unused_connections :: Query +down_m20250922_remove_unused_connections = + [sql| +CREATE TABLE snd_file_chunks( + file_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER, + chunk_sent INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +) WITHOUT ROWID; + +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); + +ALTER TABLE contacts ADD COLUMN via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs new file mode 100644 index 0000000000..239f0aeec1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- should_sync should be set manually when required +m20251007_connections_sync :: Query +m20251007_connections_sync = + [sql| +CREATE TABLE connections_sync( + connections_sync_id INTEGER PRIMARY KEY AUTOINCREMENT, + should_sync INTEGER NOT NULL DEFAULT 0, + last_sync_ts TEXT +); + +INSERT INTO connections_sync (connections_sync_id, should_sync, last_sync_ts) VALUES (1, 1, NULL); +|] + +down_m20251007_connections_sync :: Query +down_m20251007_connections_sync = + [sql| +DROP TABLE connections_sync; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs new file mode 100644 index 0000000000..1f82831404 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20251017_chat_tags_cascade :: Query +m20251017_chat_tags_cascade = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'user_id INTEGER REFERENCES users', 'user_id INTEGER REFERENCES users ON DELETE CASCADE') +WHERE name = 'chat_tags' AND type = 'table'; + +PRAGMA writable_schema=0; +|] + +down_m20251017_chat_tags_cascade :: Query +down_m20251017_chat_tags_cascade = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'user_id INTEGER REFERENCES users ON DELETE CASCADE', 'user_id INTEGER REFERENCES users') +WHERE name = 'chat_tags' AND type = 'table'; + +PRAGMA writable_schema=0; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 41f01b3b51..3c15a4303c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -63,9 +63,11 @@ Query: SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path FROM rcv_file_chunks WHERE rcv_file_id = ? + ORDER BY chunk_no ASC Plan: SEARCH rcv_file_chunks USING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +USE TEMP B-TREE FOR ORDER BY Query: SELECT snd_file_chunk_id, chunk_no, chunk_offset, chunk_size, digest @@ -327,16 +329,6 @@ Plan: SEARCH r USING INTEGER PRIMARY KEY (rowid=?) SEARCH s USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT - user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, - last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support - FROM connections - WHERE conn_id = ? - -Plan: -SEARCH connections USING PRIMARY KEY (conn_id=?) - Query: SELECT DISTINCT s.xftp_host, s.xftp_port, s.xftp_key_hash @@ -384,16 +376,42 @@ SEARCH s USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR DISTINCT Query: - SELECT DISTINCT c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) + SELECT DISTINCT c.conn_id, c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) FROM commands c + JOIN connections cs ON c.conn_id = cs.conn_id LEFT JOIN servers s ON s.host = c.host AND s.port = c.port - WHERE conn_id = ? + WHERE cs.deleted = 0 Plan: -SEARCH c USING INDEX idx_commands_conn_id (conn_id=?) +SCAN c USING INDEX idx_commands_conn_id +SEARCH cs USING PRIMARY KEY (conn_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) LEFT-JOIN USE TEMP B-TREE FOR DISTINCT +Query: + SELECT DISTINCT c.user_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash) + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE c.deleted = 0 AND q.deleted = 0 +Plan: +SCAN q USING INDEX idx_rcv_queues_client_notice_id +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT c.user_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash) + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 +Plan: +SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) +USE TEMP B-TREE FOR DISTINCT + Query: SELECT DISTINCT ntf_host, ntf_port, ntf_key_hash FROM ntf_tokens_to_delete @@ -459,6 +477,16 @@ Query: Plan: SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) +Query: + SELECT n.host, n.port, n.entity_id, COALESCE(n.server_key_hash, s.key_hash), n.created_at, n.notice_ttl + FROM client_notices n + JOIN servers s ON n.host = s.host AND n.port = s.port + WHERE n.protocol = 'smp' + +Plan: +SEARCH n USING INDEX idx_client_notices_entity (protocol=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + Query: SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status FROM snd_messages s @@ -469,6 +497,15 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +Query: + SELECT user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, + last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support + FROM connections + WHERE conn_id = ? AND deleted = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + Query: DELETE FROM conn_confirmations WHERE conn_id = ? @@ -554,10 +591,10 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_ Query: INSERT INTO rcv_queues ( host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, - snd_id, queue_mode, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, + snd_id, queue_mode, status, to_subscribe, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, link_id, link_key, link_priv_sig_key, link_enc_fixed_data, ntf_public_key, ntf_private_key, ntf_id, rcv_ntf_dh_secret - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); Plan: @@ -579,16 +616,15 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_ Query: INSERT INTO snd_queues - (host, port, snd_id, queue_mode, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + (host, port, snd_id, queue_mode, conn_id, snd_private_key, e2e_pub_key, e2e_dh_secret, status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (host, port, snd_id) DO UPDATE SET host=EXCLUDED.host, port=EXCLUDED.port, snd_id=EXCLUDED.snd_id, queue_mode=EXCLUDED.queue_mode, conn_id=EXCLUDED.conn_id, - snd_public_key=EXCLUDED.snd_public_key, snd_private_key=EXCLUDED.snd_private_key, e2e_pub_key=EXCLUDED.e2e_pub_key, e2e_dh_secret=EXCLUDED.e2e_dh_secret, @@ -733,7 +769,28 @@ SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, - q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version + FROM snd_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + + JOIN (SELECT DISTINCT conn_id, snd_queue_id FROM snd_message_deliveries WHERE failed = 0) d + ON d.conn_id = q.conn_id AND d.snd_queue_id = q.snd_queue_id + WHERE c.deleted = 0 + +Plan: +MATERIALIZE d +SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired +SCAN d +SEARCH q USING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, + q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version FROM snd_queues q JOIN servers s ON q.host = s.host AND q.port = s.port @@ -746,7 +803,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -761,7 +818,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -776,7 +833,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -791,7 +848,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -806,7 +863,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -819,6 +876,30 @@ SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH c USING PRIMARY KEY (conn_id=?) +Query: + SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH q USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? +Plan: +SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=? AND host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + Query: DELETE FROM commands WHERE command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) @@ -841,6 +922,20 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) +Query: DELETE FROM connections WHERE user_id = 2 +Plan: +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=?) +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=?) +SEARCH commands USING COVERING INDEX idx_commands_conn_id (conn_id=?) +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH conn_invitations USING COVERING INDEX idx_conn_invitations_contact_conn_id (contact_conn_id=?) +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id = ? Plan: SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) @@ -892,6 +987,14 @@ Query: DELETE FROM snd_queues WHERE conn_id = ? AND snd_queue_id = ? Plan: SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) +Query: DELETE FROM users WHERE user_id = 2 +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) @@ -964,18 +1067,26 @@ Query: SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND failed = 0 LIM Plan: SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) -Query: SELECT DISTINCT conn_id FROM snd_message_deliveries WHERE failed = 0 +Query: SELECT conn_id FROM connections WHERE deleted = 0 Plan: -SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired +SCAN connections Query: SELECT conn_id FROM connections WHERE user_id = ? Plan: SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +Query: SELECT count(1) FROM connections +Plan: +SCAN connections USING COVERING INDEX idx_connections_user + Query: SELECT count(1) FROM snd_message_bodies Plan: SCAN snd_message_bodies +Query: SELECT count(1) FROM users +Plan: +SCAN users + Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? Plan: SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) @@ -1036,6 +1147,10 @@ Query: SELECT started_at, servers_stats FROM servers_stats WHERE servers_stats_i Plan: SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT user_id FROM users WHERE deleted = 0 +Plan: +SCAN users + Query: SELECT user_id FROM users WHERE user_id = ? AND deleted = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) @@ -1128,6 +1243,10 @@ Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE con Plan: SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +Query: UPDATE rcv_queues SET to_subscribe = 0 WHERE to_subscribe = 1 +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queues_to_subscribe (to_subscribe=?) + Query: UPDATE servers_stats SET servers_stats = ?, updated_at = ? WHERE servers_stats_id = 1 Plan: SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 5a76567db0..dbb4823024 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -48,6 +48,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -57,6 +82,27 @@ Query: Plan: +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN + Query: SELECT -- GroupInfo @@ -65,7 +111,7 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -95,6 +141,22 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND sender_group_member_id = ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next_for_job_scope_sender (group_id=? AND worker_scope=? AND job_scope_spec_tag=? AND job_scope_include_pending=? AND job_scope_support_gm_id=? AND sender_group_member_id=? AND failed=? AND task_status=?) + Query: UPDATE contact_profiles SET user_id = ?, updated_at = ? @@ -176,6 +238,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -185,6 +272,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -196,27 +308,21 @@ Plan: Query: SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, + cr.contact_id, cr.business_group_id, cr.user_contact_link_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? LIMIT 1 Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -USE TEMP B-TREE FOR ORDER BY +SEARCH cr USING INDEX idx_contact_requests_xcontact_id (user_id=? AND xcontact_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT COUNT(1) @@ -249,34 +355,6 @@ Query: Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) -Query: - SELECT g.local_display_name, mu.member_status - FROM groups g - JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? - AND mu.member_status NOT IN (?,?,?) - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) - -Query: - SELECT m.group_member_id, m.local_display_name, c.agent_conn_id - FROM group_members m - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) - AND m.member_status NOT IN (?,?,?) - -Plan: -SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT timed_ttl FROM chat_items @@ -358,6 +436,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -367,6 +470,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -376,6 +504,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO messages ( @@ -387,7 +540,7 @@ Plan: Query: SELECT - c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl @@ -401,21 +554,14 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, - cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, - cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN contact_profiles p USING (contact_profile_id) - WHERE cr.user_id = ? - AND cr.xcontact_id = ? - LIMIT 1 + delivery_job_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, cursor_group_member_id + FROM delivery_jobs + WHERE delivery_job_id = ? Plan: -SEARCH cr USING INDEX idx_contact_requests_xcontact_id (user_id=? AND xcontact_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT COUNT(1) @@ -608,11 +754,11 @@ Query: WHERE user_id = ? AND short_link_inv = ? LIMIT 1 Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -623,7 +769,7 @@ SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE (user_id = ? AND via_contact_uri_hash = ?) @@ -648,6 +794,28 @@ Plan: SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_job_id + FROM delivery_jobs + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND job_status = ? + ORDER BY delivery_job_id ASC + LIMIT 1 + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_next (group_id=? AND worker_scope=? AND failed=? AND job_status=?) + +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND task_status = ? + ORDER BY delivery_task_id ASC + LIMIT 1 + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) + Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission FROM group_profiles gp @@ -802,6 +970,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -822,8 +1015,9 @@ Plan: Query: INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -938,13 +1132,13 @@ SEARCH ri USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AN Query: SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -953,8 +1147,6 @@ Query: ( (c.user_id = ? AND c.via_contact_uri_hash = ?) OR (c.user_id = ? AND c.via_contact_uri_hash = ?) ) AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 Plan: MULTI-INDEX OR @@ -964,87 +1156,6 @@ INDEX 2 SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -USE TEMP B-TREE FOR ORDER BY - -Query: - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, - -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - -Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - -Query: - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - -Plan: -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT 1 @@ -1077,20 +1188,6 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) INDEX 2 SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) -Query: - SELECT DISTINCT f.file_id - FROM files f - JOIN snd_files s USING (file_id) - WHERE f.user_id = ? - AND s.file_status IN (?, ?, ?) - AND s.file_descr_id IS NULL - AND s.file_inline IS NULL - AND s.created_at > ? - -Plan: -SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) -SEARCH s USING INDEX idx_snd_files_file_descr_id (file_descr_id=? AND file_id=?) - Query: SELECT agent_conn_id FROM ( SELECT @@ -1108,7 +1205,7 @@ USE TEMP B-TREE FOR ORDER BY Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -1116,8 +1213,8 @@ Query: WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT chat_item_id @@ -1218,7 +1315,7 @@ Plan: SEARCH chat_item_moderations USING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) Query: - SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + SELECT connection_id, conn_type, contact_id, group_member_id, user_contact_link_id FROM connections WHERE agent_conn_id = ? @@ -1234,18 +1331,6 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) - Query: SELECT ct.contact_id FROM contacts ct @@ -1270,46 +1355,6 @@ SEARCH cp USING COVERING INDEX idx_contact_profiles_contact_link (user_id=? AND SEARCH ct USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -Query: - SELECT ct.contact_id - FROM group_members m - JOIN groups g ON g.group_id = m.group_id - JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id - WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - LIMIT 1 - -Plan: -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name - FROM contacts ct - JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) - AND c.conn_type = ? - AND ct.deleted = 0 - -Plan: -SEARCH c USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) -LIST SUBQUERY 1 -SCAN temp_conn_ids -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT f.file_id - FROM files f - JOIN rcv_files r USING (file_id) - WHERE f.user_id = ? AND r.file_status IN (?, ?) - AND r.rcv_file_inline IS NULL - AND r.file_descr_id IS NULL - AND r.created_at > ? - -Plan: -SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) -SEARCH r USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT g.group_id FROM groups g @@ -1421,11 +1466,9 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, - c.connection_id, c.agent_conn_id + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays FROM rcv_files r JOIN files f USING (file_id) - LEFT JOIN connections c ON r.file_id = c.rcv_file_id LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? @@ -1433,7 +1476,6 @@ Query: Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH r USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_rcv_file_id (rcv_file_id=?) LEFT-JOIN SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN @@ -1468,7 +1510,7 @@ Query: ) Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) LIST SUBQUERY 1 SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -1502,7 +1544,8 @@ Query: UPDATE group_members SET support_chat_items_unread = 0, support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 + support_chat_items_mentions = 0, + updated_at = ? WHERE group_member_id = ? Plan: @@ -1574,6 +1617,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -1583,6 +1651,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO msg_deliveries @@ -1609,44 +1702,24 @@ Plan: Query: SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = ? - AND ( - c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 - ) cc - ) - OR c.connection_id IS NULL - ) Plan: SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -CO-ROUTINE cc -SEARCH cc USING INDEX idx_connections_contact_id (contact_id=?) -USE TEMP B-TREE FOR ORDER BY -SCAN cc Query: SELECT @@ -1697,7 +1770,8 @@ Query: AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' ORDER BY updated_at DESC LIMIT ? Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) +USE TEMP B-TREE FOR ORDER BY Query: SELECT @@ -2996,6 +3070,23 @@ CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT + t.delivery_task_id, + t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + FROM delivery_tasks t + JOIN messages msg ON msg.message_id = t.message_id + JOIN group_members m ON m.group_member_id = t.sender_group_member_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE t.delivery_task_id = ? + +Plan: +SEARCH t USING INTEGER PRIMARY KEY (rowid=?) +SEARCH msg USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT COUNT(1) FROM chat_items @@ -3012,9 +3103,44 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) +Query: + SELECT agent_conn_id + FROM connections + WHERE user_id = ? + + AND conn_type = ? + AND contact_id IS NULL + AND conn_status != ? + +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: + SELECT c.agent_conn_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? + + AND c.conn_status != ? + AND ct.contact_status = ? AND ct.deleted = 0 + +Plan: +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT c.agent_conn_id + FROM connections c + JOIN user_contact_links ucl ON ucl.user_contact_link_id = c.user_contact_link_id + WHERE c.user_id = ? + AND c.conn_status != ? +Plan: +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH ucl USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -3027,7 +3153,7 @@ SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id= Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c @@ -3038,20 +3164,6 @@ Plan: SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) -Query: - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, - uc.user_contact_link_id, uc.conn_req_contact, uc.group_id - FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? - -Plan: -SEARCH c USING INDEX idx_connections_updated_at (user_id=?) -SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT chat_item_id FROM chat_item_messages @@ -3217,6 +3329,16 @@ Query: Plan: SEARCH chat_item_versions USING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +Query: + SELECT chat_tag_id, chat_tag_emoji, chat_tag_text + FROM chat_tags + WHERE user_id = ? + ORDER BY tag_order + +Plan: +SEARCH chat_tags USING INDEX idx_chat_tags_user_id (user_id=?) +USE TEMP B-TREE FOR ORDER BY + Query: SELECT command_id, connection_id, command_function, command_status FROM commands @@ -3227,7 +3349,7 @@ SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -3236,16 +3358,6 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at - FROM connections - WHERE user_id = ? - AND conn_type = ? - AND contact_id IS NULL - -Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) - Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections @@ -3363,7 +3475,7 @@ Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) Query: - SELECT group_member_intro_id, group_queue_info, direct_queue_info, intro_status + SELECT group_member_intro_id, intro_status FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? @@ -3658,17 +3770,6 @@ SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id= LIST SUBQUERY 1 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name -Query: - UPDATE group_member_intros - SET intro_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_intro_id = ? - -Plan: -SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_member_intros SET intro_status='fwd' WHERE re_group_member_id IN (SELECT group_member_id FROM group_members WHERE local_display_name = ?) @@ -3697,6 +3798,31 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + WITH user_groups AS MATERIALIZED ( + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? + AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + ) + SELECT c.agent_conn_id + FROM connections c + JOIN group_members m ON m.group_member_id = c.group_member_id + JOIN user_groups ug ON ug.group_id = m.group_id + WHERE c.user_id = ? + AND c.conn_status != ? + AND m.member_status NOT IN (?,?,?) + +Plan: +MATERIALIZE user_groups +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?) + Query: DELETE FROM chat_items WHERE group_scope_group_member_id = ? @@ -3785,8 +3911,8 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) @@ -3990,6 +4116,22 @@ SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (m SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +Query: + DELETE FROM delivery_jobs + WHERE created_at <= ? + AND (job_status IN (?,?) OR failed = 1) + +Plan: +SEARCH delivery_jobs USING INDEX idx_delivery_jobs_created_at (created_at 0 OR chat_item_ttl IS NULL Plan: @@ -6022,10 +6217,6 @@ Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMI Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT group_id FROM groups WHERE user_id = ? -Plan: -SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) - Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL Plan: SCAN groups @@ -6090,6 +6281,10 @@ Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? A Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1 +Plan: +SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -6162,9 +6357,9 @@ Query: UPDATE connections SET security_code = ?, security_code_verified_at = ?, Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 +Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: -SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) +SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ? Plan: @@ -6234,6 +6429,14 @@ Query: UPDATE contacts SET xcontact_id = ? WHERE contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ? +Plan: +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE delivery_tasks SET task_status = ?, task_err_reason = ?, updated_at = ? WHERE delivery_task_id = ? +Plan: +SEARCH delivery_tasks USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ? Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 952d0541d9..4b12246a3f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1,8 +1,7 @@ CREATE TABLE migrations( - name TEXT NOT NULL, + name TEXT NOT NULL PRIMARY KEY, ts TEXT NOT NULL, - down TEXT, - PRIMARY KEY(name) + down TEXT ); CREATE TABLE contact_profiles( -- remote user profile @@ -62,7 +61,6 @@ CREATE TABLE contacts( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user - via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, @@ -156,7 +154,8 @@ CREATE TABLE groups( welcome_shared_msg_id BLOB, request_shared_msg_id BLOB, conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, - via_group_link_uri BLOB, -- received + via_group_link_uri BLOB, + summary_current_members_count INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -269,17 +268,6 @@ CREATE TABLE rcv_files( to_receive INTEGER, user_approved_relays INTEGER NOT NULL DEFAULT 0 ); -CREATE TABLE snd_file_chunks( - file_id INTEGER NOT NULL, - connection_id INTEGER NOT NULL, - chunk_number INTEGER NOT NULL, - chunk_agent_msg_id INTEGER, - chunk_sent INTEGER NOT NULL DEFAULT 0, - created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- 0(sent to agent), 1(sent to server) - FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, - PRIMARY KEY(file_id, connection_id, chunk_number) -) WITHOUT ROWID; CREATE TABLE rcv_file_chunks( file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE, chunk_number INTEGER NOT NULL, @@ -395,7 +383,8 @@ CREATE TABLE messages( shared_msg_id BLOB, shared_msg_id_user INTEGER, author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, - forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + broker_ts TEXT ); CREATE TABLE pending_group_messages( pending_group_message_id INTEGER PRIMARY KEY, @@ -674,7 +663,7 @@ CREATE TABLE operator_usage_conditions( ); CREATE TABLE chat_tags( chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users, + user_id INTEGER REFERENCES users ON DELETE CASCADE, chat_tag_text TEXT NOT NULL, chat_tag_emoji TEXT, tag_order INTEGER NOT NULL @@ -691,6 +680,47 @@ CREATE TABLE chat_item_mentions( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, display_name TEXT NOT NULL ); +CREATE TABLE delivery_tasks( + delivery_task_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages ON DELETE CASCADE, + message_from_channel INTEGER NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE delivery_jobs( + delivery_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BLOB, + cursor_group_member_id INTEGER, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE connections_sync( + connections_sync_id INTEGER PRIMARY KEY AUTOINCREMENT, + should_sync INTEGER NOT NULL DEFAULT 0, + last_sync_ts TEXT +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -735,7 +765,6 @@ CREATE INDEX idx_connections_via_user_contact_link ON connections( via_user_contact_link ); CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); -CREATE INDEX idx_connections_contact_id ON connections(contact_id); CREATE INDEX idx_connections_user_contact_link_id ON connections( user_contact_link_id ); @@ -747,7 +776,6 @@ CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( user_contact_link_id ); -CREATE INDEX idx_contacts_via_group ON contacts(via_group); CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); CREATE INDEX idx_files_user_id ON files(user_id); @@ -785,10 +813,6 @@ CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_message CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); CREATE INDEX idx_settings_user_id ON settings(user_id); -CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( - file_id, - connection_id -); CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); @@ -861,7 +885,6 @@ CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( user_id, item_status ); -CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); CREATE INDEX idx_contacts_contact_group_member_id ON contacts( contact_group_member_id ); @@ -1054,7 +1077,6 @@ CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( user_mention ); CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); -CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( group_id, shared_msg_id @@ -1103,3 +1125,94 @@ CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON contacts( CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON contacts( grp_direct_inv_from_member_conn_id ); +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks( + job_scope_support_gm_id +); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks( + sender_group_member_id +); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs( + job_scope_support_gm_id +); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs( + single_sender_group_member_id +); +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); +CREATE INDEX idx_groups_summary_current_members_count ON groups( + summary_current_members_count +); +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections( + group_member_id +); +CREATE INDEX idx_connections_to_subscribe ON connections( + user_id, + to_subscribe +); +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 745f1b4b9a..243db84da7 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -37,7 +37,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), AConnShortLink (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Store (AnyStoreError (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -148,12 +149,24 @@ data StoreError | SEUsageConditionsNotFound | SEInvalidQuote | SEInvalidMention + | SEInvalidDeliveryTask {taskId :: Int64} + | SEDeliveryTaskNotFound {taskId :: Int64} + | SEInvalidDeliveryJob {jobId :: Int64} + | SEDeliveryJobNotFound {jobId :: Int64} + | -- | Error when reading work item that suspends worker - do not use! + SEWorkItemError {errContext :: String} deriving (Show, Exception) instance AnyError StoreError where fromSomeException = SEInternalError . show {-# INLINE fromSomeException #-} +instance AnyStoreError StoreError where + isWorkItemError = \case + SEWorkItemError {} -> True + _ -> False + mkWorkItemError errContext = SEWorkItemError {errContext} + $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 @@ -200,14 +213,14 @@ fileInfoQuery = toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} -type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) +type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64) type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, ConnStatus, ConnType, BoolInt, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -239,13 +252,11 @@ toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI vi entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId entityId_ ConnMember = groupMemberId - entityId_ ConnRcvFile = rcvFileId - entityId_ ConnSndFile = sndFileId entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: VersionRangeChat -> MaybeConnectionRow -> Maybe Connection -toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = - Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) toMaybeConnection _ _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> ConnStatus -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection @@ -258,12 +269,12 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + contact_id, group_member_id, user_contact_link_id, created_at, updated_at, conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, customUserProfileId, connStatus, connType) - :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) + :. (ent ConnContact, ent ConnMember, ent ConnUserContact, currentTs, currentTs) :. (connChatVersion, minV, maxV, BI (subMode == SMOnlyCreate), pqSup, pqSup) ) connId <- insertedRowId db @@ -389,10 +400,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" Nothing currentTs + void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias viaGroup currentTs = +createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -401,8 +412,8 @@ createContact_ db User {userId} Profile {displayName, fullName, shortDescr, imag profileId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - ((profileId, ctUserPreferences, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared) + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)" + ((profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared) contactId <- insertedRowId db pure $ Right contactId @@ -464,12 +475,12 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} @@ -477,7 +488,7 @@ toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, via mergedPreferences = contactUserPreferences user userPreferences preferences incognito preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} toPreparedContact :: PreparedContactRow -> Maybe PreparedContact toPreparedContact (connFullLink, connShortLink, welcomeSharedMsgId, requestSharedMsgId) = @@ -643,21 +654,22 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention, viaGroupLinkUri} + groupSummary = GroupSummary {currentMembers} + in GroupInfo {groupId, useRelays = BoolDef False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -675,16 +687,38 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer supportChat = case supportChatTs_ of Just chatTs -> - Just GroupSupportChat { - chatTs, - unread = supportChatUnread, - memberAttention = supportChatMemberAttention, - mentions = supportChatMentions, - lastMsgFromMemberTs = supportChatLastMsgFromMemberTs - } + Just + GroupSupportChat + { chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions, + lastMsgFromMemberTs = supportChatLastMsgFromMemberTs + } _ -> Nothing in GroupMember {..} +groupMemberQuery :: Query +groupMemberQuery = + [sql| + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + |] + +toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember vr User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} + rowToLocalProfile :: ProfileRow -> LocalProfile rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences} @@ -694,7 +728,10 @@ toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just Busi toBusinessChatInfo _ = Nothing groupInfoQuery :: Query -groupInfoQuery = +groupInfoQuery = groupInfoQueryFields <> " " <> groupInfoQueryFrom + +groupInfoQueryFields :: Query +groupInfoQueryFields = [sql| SELECT -- GroupInfo @@ -703,13 +740,18 @@ groupInfoQuery = g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + |] + +groupInfoQueryFrom :: Query +groupInfoQueryFrom = + [sql| FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -795,7 +837,8 @@ setViaGroupLinkUri db groupId connId = do DB.query db "SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE connection_id = ?" - (Only connId) :: IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] + (Only connId) :: + IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] forM_ (listToMaybe r) $ \(viaContactUri, viaContactUriHash) -> DB.execute db diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 8a37a4c477..ca591903ff 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -33,11 +33,9 @@ import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types import Simplex.Chat.View -import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (tshow) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -196,8 +194,6 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case CEvtContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") - CEvtContactsDisconnected srv _ -> serverNtf srv "disconnected" - CEvtContactsSubscribed srv _ -> serverNtf srv "connected" CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") @@ -215,7 +211,6 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case _ -> pure () where sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification - serverNtf (SMPServer host _ _) str = sendNtf ("server " <> str, safeDecodeUtf8 $ strEncode host) msgText :: MsgContent -> Maybe MarkdownList -> Text msgText (MCFile _) _ = "wants to send a file" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 5d25ae64e3..bb86cb2522 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -49,7 +49,6 @@ import Data.Word (Word16) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) @@ -181,7 +180,6 @@ data Contact = Contact localDisplayName :: ContactName, profile :: LocalProfile, activeConn :: Maybe Connection, - viaGroup :: Maybe Int64, contactUsed :: Bool, contactStatus :: ContactStatus, chatSettings :: ChatSettings, @@ -355,9 +353,6 @@ data UserContact = UserContact } deriving (Eq, Show) -userContactGroupId :: UserContact -> Maybe GroupId -userContactGroupId UserContact {groupId} = groupId - data UserContactRequest = UserContactRequest { contactRequestId :: Int64, agentInvitationId :: AgentInvId, @@ -443,26 +438,6 @@ optionalFullName displayName fullName shortDescr | T.null fullName || displayName == fullName = maybe "" (\sd -> " (" <> sd <> ")") shortDescr | otherwise = " (" <> fullName <> ")" -data ShortGroup = ShortGroup - { shortInfo :: ShortGroupInfo, - members :: [ShortGroupMember] - } - -data ShortGroupInfo = ShortGroupInfo - { groupId :: GroupId, - groupName :: GroupName, - membershipStatus :: GroupMemberStatus - } - deriving (Eq, Show) - -data ShortGroupMember = ShortGroupMember - { groupMemberId :: GroupMemberId, - groupId :: GroupId, - memberName :: ContactName, - connId :: AgentConnId - } - deriving (Show) - data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -470,6 +445,7 @@ type GroupId = Int64 data GroupInfo = GroupInfo { groupId :: GroupId, + useRelays :: BoolDef, localDisplayName :: GroupName, groupProfile :: GroupProfile, localAlias :: Text, @@ -486,6 +462,7 @@ data GroupInfo = GroupInfo chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData, + groupSummary :: GroupSummary, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact } @@ -522,11 +499,18 @@ groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary - { currentMembers :: Int + { currentMembers :: Int64 } - deriving (Show) + deriving (Eq, Show) -data GroupInfoSummary = GIS {groupInfo :: GroupInfo, groupSummary :: GroupSummary} +data GroupLink = GroupLink + { userContactLinkId :: Int64, + connLinkContact :: CreatedLinkContact, + shortLinkDataSet :: Bool, + shortLinkLargeDataSet :: BoolDef, + groupLinkId :: GroupLinkId, + acceptMemberRole :: GroupMemberRole + } deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] @@ -596,6 +580,10 @@ groupFeatureMemberAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupMemb groupFeatureMemberAllowed feature GroupMember {memberRole} = groupFeatureMemberAllowed' feature memberRole . fullGroupPreferences +groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo -> Bool +groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} = + groupFeatureMemberAllowed' feature memberRole fullGroupPreferences + mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) @@ -688,10 +676,6 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 -redactedMemberProfile :: Profile -> Profile -redactedMemberProfile Profile {displayName, fullName, shortDescr, image, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} - data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -699,11 +683,6 @@ fromIncognitoProfile = \case NewIncognito p -> p ExistingIncognito lp -> fromLocalProfile lp -userProfileInGroup :: User -> Maybe Profile -> Profile -userProfileInGroup User {profile = p} incognitoProfile = - let p' = fromMaybe (fromLocalProfile p) incognitoProfile - in redactedMemberProfile p' - userProfileDirect :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile userProfileDirect user@User {profile = p} incognitoProfile ct canFallbackToUserTTL = let p' = fromMaybe (fromLocalProfile p) incognitoProfile @@ -885,15 +864,6 @@ data BusinessChatInfo = BusinessChatInfo } deriving (Eq, Show) -memberInfo :: GroupMember -> MemberInfo -memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = - MemberInfo - { memberId, - memberRole, - v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = redactedMemberProfile $ fromLocalProfile memberProfile - } - data MemberRestrictionStatus = MRSBlocked | MRSUnrestricted @@ -1002,6 +972,11 @@ groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} +-- TODO [channels fwd] knowledge whether member is a relay should come from protocol, not implicitly via role +-- TODO - in channels members should directly connect only to relays +isMemberRelay :: GroupMember -> Bool +isMemberRelay GroupMember {memberRole} = memberRole == GRAdmin + memberConn :: GroupMember -> Maybe Connection memberConn GroupMember {activeConn} = activeConn @@ -1229,7 +1204,9 @@ memberPending m = case memberStatus m of memberCurrentOrPending :: GroupMember -> Bool memberCurrentOrPending m = memberCurrent m || memberPending m --- update getGroupSummary if this is changed +-- *** Please note: +-- *** update getGroupSummary and SQL function used in update triggers if this is changed +-- *** memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case GSMemRejected -> False @@ -1317,9 +1294,6 @@ data SndFileTransfer = SndFileTransfer } deriving (Eq, Show) -sndFileTransferConnId :: SndFileTransfer -> ConnId -sndFileTransferConnId SndFileTransfer {agentConnId = AgentConnId acId} = acId - type FileTransferId = Int64 data FileInvitation = FileInvitation @@ -1407,10 +1381,10 @@ data RcvFileDescr = RcvFileDescr data RcvFileStatus = RFSNew - | RFSAccepted {fileInfo :: RcvFileInfo} - | RFSConnected {fileInfo :: RcvFileInfo} - | RFSComplete {fileInfo :: RcvFileInfo} - | RFSCancelled {fileInfo_ :: Maybe RcvFileInfo} + | RFSAccepted {filePath :: FilePath} + | RFSConnected {filePath :: FilePath} + | RFSComplete {filePath :: FilePath} + | RFSCancelled {filePath_ :: Maybe FilePath} deriving (Eq, Show) rcvFileComplete :: RcvFileStatus -> Bool @@ -1421,29 +1395,11 @@ rcvFileComplete = \case rcvFileCompleteOrCancelled :: RcvFileTransfer -> Bool rcvFileCompleteOrCancelled RcvFileTransfer {fileStatus, cancelled} = rcvFileComplete fileStatus || cancelled -data RcvFileInfo = RcvFileInfo - { filePath :: FilePath, - connId :: Maybe Int64, - agentConnId :: Maybe AgentConnId - } - deriving (Eq, Show) - -liveRcvFileTransferInfo :: RcvFileTransfer -> Maybe RcvFileInfo -liveRcvFileTransferInfo RcvFileTransfer {fileStatus} = case fileStatus of - RFSAccepted fi -> Just fi - RFSConnected fi -> Just fi - _ -> Nothing - -liveRcvFileTransferConnId :: RcvFileTransfer -> Maybe ConnId -liveRcvFileTransferConnId ft = acId =<< liveRcvFileTransferInfo ft - where - acId RcvFileInfo {agentConnId = Just (AgentConnId cId)} = Just cId - acId _ = Nothing - liveRcvFileTransferPath :: RcvFileTransfer -> Maybe FilePath -liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft - where - fp RcvFileInfo {filePath} = filePath +liveRcvFileTransferPath RcvFileTransfer {fileStatus} = case fileStatus of + RFSAccepted filePath -> Just filePath + RFSConnected filePath -> Just filePath + _ -> Nothing newtype AgentConnId = AgentConnId ConnId deriving (Eq, Ord, Show) @@ -1764,7 +1720,7 @@ instance TextEncoding ConnStatus where ConnReady -> "ready" ConnDeleted -> "deleted" -data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile | ConnUserContact +data ConnType = ConnContact | ConnMember | ConnUserContact deriving (Eq, Show) instance FromField ConnType where fromField = fromTextField_ textDecode @@ -1782,23 +1738,18 @@ instance TextEncoding ConnType where textDecode = \case "contact" -> Just ConnContact "member" -> Just ConnMember - "snd_file" -> Just ConnSndFile - "rcv_file" -> Just ConnRcvFile "user_contact" -> Just ConnUserContact _ -> Nothing textEncode = \case ConnContact -> "contact" ConnMember -> "member" - ConnSndFile -> "snd_file" - ConnRcvFile -> "rcv_file" ConnUserContact -> "user_contact" data GroupMemberIntro = GroupMemberIntro { introId :: Int64, reMember :: GroupMember, toMember :: GroupMember, - introStatus :: GroupMemberIntroStatus, - introInvitation :: Maybe IntroInvitation + introStatus :: GroupMemberIntroStatus } deriving (Show) @@ -1837,26 +1788,6 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data NetworkStatus - = NSUnknown - | NSConnected - | NSDisconnected - | NSError {connectionError :: String} - deriving (Eq, Ord, Show) - -netStatusStr :: NetworkStatus -> String -netStatusStr = \case - NSUnknown -> "unknown" - NSConnected -> "connected" - NSDisconnected -> "disconnected" - NSError e -> "error: " <> e - -data ConnNetworkStatus = ConnNetworkStatus - { agentConnId :: AgentConnId, - networkStatus :: NetworkStatus - } - deriving (Show) - type CommandId = Int64 aCorrId :: CommandId -> ACorrId @@ -1889,8 +1820,8 @@ instance TextEncoding CommandStatus where data CommandFunction = CFCreateConnGrpMemInv | CFCreateConnGrpInv - | CFCreateConnFileInvDirect - | CFCreateConnFileInvGroup + | CFCreateConnFileInvDirect -- deprecated + | CFCreateConnFileInvGroup -- deprecated | CFJoinConn | CFAllowConn | CFAcceptContact @@ -2046,10 +1977,6 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberSettings) $(JQ.deriveJSON defaultJSON ''SecurityCode) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NS") ''NetworkStatus) - -$(JQ.deriveJSON defaultJSON ''ConnNetworkStatus) - $(JQ.deriveJSON defaultJSON ''Connection) $(JQ.deriveJSON defaultJSON ''PendingContactConnection) @@ -2068,13 +1995,17 @@ $(JQ.deriveJSON defaultJSON ''BusinessChatInfo) $(JQ.deriveJSON defaultJSON ''PreparedGroup) +$(JQ.deriveToJSON defaultJSON ''GroupSummary) + +instance FromJSON GroupSummary where + parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) + omittedField = Just GroupSummary {currentMembers = 0} + $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) -$(JQ.deriveJSON defaultJSON ''GroupSummary) - -$(JQ.deriveJSON defaultJSON ''GroupInfoSummary) +$(JQ.deriveJSON defaultJSON ''GroupLink) instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP @@ -2108,8 +2039,6 @@ $(JQ.deriveJSON defaultJSON ''RcvFileDescr) $(JQ.deriveJSON defaultJSON ''XFTPRcvFile) -$(JQ.deriveJSON defaultJSON ''RcvFileInfo) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RFS") ''RcvFileStatus) $(JQ.deriveJSON defaultJSON ''RcvFileTransfer) @@ -2141,7 +2070,3 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) - -$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) - -$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index d0f23a7307..096cda06f5 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -14,7 +14,6 @@ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) -import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs deleted file mode 100644 index afea178e41..0000000000 --- a/src/Simplex/Chat/Types/Util.hs +++ /dev/null @@ -1,8 +0,0 @@ -module Simplex.Chat.Types.Util where - -import qualified Data.Aeson as J -import qualified Data.Aeson.Types as JT -import Simplex.Messaging.Encoding.String - -textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a -textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 89bc58171c..c16f76148f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -22,7 +22,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) -import Data.List (groupBy, intercalate, intersperse, partition, sortOn) +import Data.List (groupBy, intercalate, intersperse, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -50,13 +50,14 @@ import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), GroupLink (..), StoreError (..), UserContactLink (..)) +import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import qualified Simplex.FileTransfer.Transport as XFTP +import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol @@ -116,6 +117,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] + CRConnectionsDiff showIds userDiff connDiff + | showIds -> viewConnDiffIds userDiff connDiff + | otherwise -> viewConnDiffSummary userDiff connDiff CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] @@ -128,7 +132,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig netCfg -> viewNetworkConfig netCfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile - CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s + CRGroupInfo u g -> ttyUser u $ viewGroupInfo g CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats CRQueueInfo _ msgInfo qInfo -> [ "last received msg: " <> maybe "none" viewJSON msgInfo, @@ -228,7 +232,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [viewJSON j]) info_ - CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m @@ -377,8 +380,8 @@ ttyUserPrefix (currentRH, user_) outputRH User {userId, localDisplayName = u} ss userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] currentUserId = (\User {userId = uId} -> uId) <$> user_ -viewErrorsSummary :: [a] -> StyledString -> [StyledString] -viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] +viewSubErrorsSummary :: [ConnSubResult] -> [StyledString] +viewSubErrorsSummary subErrs = [ttyError (T.pack . show $ length subErrs) <> " subscription errors (run with -c option to show each error)" | not (null subErrs)] contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs @@ -424,7 +427,6 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -450,26 +452,11 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CEvtContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] CEvtContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CEvtConnectionsDiff userDiff connDiff -> viewConnDiffSync userDiff connDiff CEvtSubscriptionEnd u acEntity -> let Connection {connId} = entityConnection acEntity in ttyUser u [sShow connId <> ": END"] - CEvtContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CEvtContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CEvtContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] - CEvtContactSubSummary u summary -> - ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" - where - (errors, subscribed) = partition (isJust . contactError) summary - CEvtUserContactSubSummary u summary -> - ttyUser u $ - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") - where - (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary - addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError - (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CEvtNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] - CEvtNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] + CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -534,16 +521,6 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m - TEGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g - TEGroupInvitation u g -> ttyUser u [groupInvitationSub g] - TEGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] - TEMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] - TEMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - TEPendingSubSummary u _ -> ttyUser u [] - TESndFileSubError u SndFileTransfer {fileId, fileName} e -> - ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - TERcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CEvtCustomChatEvent u r -> ttyUser' u $ map plain $ T.lines r where ttyUser :: User -> [StyledString] -> [StyledString] @@ -622,9 +599,6 @@ viewUsersList us = Just CPTBot -> " (bot)" _ -> "" -viewGroupSubscribed :: GroupName -> [StyledString] -viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] - showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1199,12 +1173,6 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] -viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] -viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus - where - netStatus ConnNetworkStatus {networkStatus} = networkStatus - viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) - viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g@GroupInfo {membership} = case incognitoMembershipProfile g of @@ -1360,13 +1328,13 @@ viewContactConnected ct userIncognitoProfile testView = Nothing -> [ttyFullContact ct <> ": contact is connected"] -viewGroupsList :: [GroupInfoSummary] -> [StyledString] +viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - ldn_ :: GroupInfoSummary -> Text - ldn_ (GIS GroupInfo {localDisplayName} _) = T.toLower localDisplayName - groupSS (GIS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} GroupSummary {currentMembers}) = + ldn_ :: GroupInfo -> Text + ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName + groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g @@ -1410,21 +1378,6 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " -groupInvitationSub :: ShortGroupInfo -> StyledString -groupInvitationSub ShortGroupInfo {groupName = ldn} = - highlight ("#" <> viewName ldn) - <> " - you are invited (" - <> highlight ("/j " <> viewName ldn) - <> " to join, " - <> highlight ("/d #" <> viewName ldn) - <> " to delete invitation)" - -viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] -viewContactsMerged c1 c2 ct' = - [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, - "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" - ] - viewNewMemberContactReceivedInv :: User -> Contact -> GroupInfo -> GroupMember -> [StyledString] viewNewMemberContactReceivedInv user ct@Contact {localDisplayName = c} g m | isTrue (autoAcceptMemberContacts user) = @@ -1468,6 +1421,58 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] +viewConnDiffSync :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSync userDiff connDiff + | noDiff userDiff && noDiff connDiff = [] + | otherwise = + viewConnDiffSummary' userDiff connDiff + <> ["removed extra users in agent" | not (null $ extraIds userDiff)] + <> ["removed extra connections in agent" | not (null $ extraIds connDiff)] + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + +viewConnDiffSummary :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSummary userDiff connDiff + | noDiff userDiff && noDiff connDiff = + ["no difference between agent and chat connections"] + | otherwise = + viewConnDiffSummary' userDiff connDiff + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + +viewConnDiffSummary' :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSummary' userDiff connDiff = + ["connections difference summary:"] + <> showDatabaseDiff "users" userDiff + <> showDatabaseDiff "connections" connDiff + where + showDatabaseDiff name DatabaseDiff {missingIds, extraIds} = + ["number of missing " <> name <> " in agent: " <> sShow (length missingIds) | not (null missingIds)] + <> ["number of extra " <> name <> " in agent: " <> sShow (length extraIds) | not (null extraIds)] + +viewConnDiffIds :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffIds userDiff connDiff + | noDiff userDiff && noDiff connDiff = + ["no difference between agent and chat connections"] + | otherwise = + ["connections difference:"] + <> showDatabaseDiff "users" (\(AgentUserId uId) -> uId) userDiff + <> showDatabaseDiff "connections" (\(AgentConnId cId) -> cId) connDiff + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + showDatabaseDiff name unwrapId DatabaseDiff {missingIds, extraIds} = + ["missing " <> name <> " in agent (agent IDs): " <> showIds missingIds | not (null missingIds)] + <> ["extra " <> name <> " in agent (agent IDs): " <> showIds extraIds | not (null extraIds)] + where + showIds = plain . T.intercalate ", " . map (tshow . unwrapId) + +subStatusStr :: SubscriptionStatus -> String +subStatusStr = \case + SSActive -> "subscribed" + SSPending -> "disconnected" + SSRemoved e -> "removed: " <> e + SSNoSub -> "no subscription" + viewUserServers :: UserOperatorServers -> [StyledString] viewUserServers (UserOperatorServers _ [] []) = [] viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = @@ -1628,8 +1633,8 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> viewUITheme uiThemes <> viewCustomData customData -viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] -viewGroupInfo GroupInfo {groupId, uiThemes, customData} s = +viewGroupInfo :: GroupInfo -> [StyledString] +viewGroupInfo GroupInfo {groupId, uiThemes, customData, groupSummary = s} = [ "group ID: " <> sShow groupId, "current members: " <> sShow (currentMembers s) ] @@ -2251,8 +2256,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSNew -> "not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file" RFSAccepted _ -> "just started" RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize - RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath - RFSCancelled (Just RcvFileInfo {filePath}) -> "cancelled, received part path: " <> plain filePath + RFSComplete filePath -> "complete, path: " <> plain filePath + RFSCancelled (Just filePath) -> "cancelled, received part path: " <> plain filePath RFSCancelled Nothing -> "cancelled" viewFileTransferStatusXFTP :: AChatItem -> [StyledString] @@ -2400,7 +2405,7 @@ viewRemoteCtrl CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (App viewRemoteCtrlStopped :: RemoteCtrlStopReason -> [StyledString] viewRemoteCtrlStopped = \case - RCSRConnectionFailed (ChatErrorAgent (RCP RCEIdentity) _) -> + RCSRConnectionFailed (ChatErrorAgent (RCP RCEIdentity) _ _) -> ["remote controller stopped: this link was used with another controller, please create a new link on the host"] _ -> ["remote controller stopped"] @@ -2472,7 +2477,6 @@ viewChatError isCmd logLevel testView = \case CEFileCancelled f -> ["file cancelled: " <> plain f] CEFileCancel fileId e -> ["error cancelling file " <> sShow fileId <> ": " <> sShow e] CEFileAlreadyExists f -> ["file already exists: " <> plain f] - CEFileRead f e -> ["cannot read file " <> plain f <> ": " <> plain e] CEFileWrite f e -> ["cannot write file " <> plain f <> ": " <> plain e] CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e] CEFileRcvChunk e -> ["error receiving file: " <> plain e] @@ -2527,6 +2531,8 @@ viewChatError isCmd logLevel testView = \case | testView -> ["duplicate group message, group id: " <> sShow groupId <> ", message id: " <> sShow sharedMsgId] | otherwise -> [] SEUserNoteFolderNotFound -> ["no notes folder"] + SEInternalError {message} + | testView && message == "referenced group member not found" -> [] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] @@ -2534,7 +2540,7 @@ viewChatError isCmd logLevel testView = \case DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] - ChatErrorAgent err entity_ -> case err of + ChatErrorAgent err (AgentConnId acId) entity_ -> case err of CMD PROHIBITED cxt -> [withConnEntity <> plain ("error: command is prohibited, " <> cxt)] SMP _ SMP.AUTH -> [ withConnEntity @@ -2563,13 +2569,11 @@ viewChatError isCmd logLevel testView = \case "[" <> connEntityLabel entity <> ", connId: " <> cId conn <> "] " Just entity@(RcvGroupMsgConnection conn GroupInfo {groupId} GroupMember {groupMemberId}) -> "[" <> connEntityLabel entity <> ", groupId: " <> sShow groupId <> ", memberId: " <> sShow groupMemberId <> ", connId: " <> cId conn <> "] " - Just entity@(RcvFileConnection conn RcvFileTransfer {fileId}) -> - "[" <> connEntityLabel entity <> ", fileId: " <> sShow fileId <> ", connId: " <> cId conn <> "] " - Just entity@(SndFileConnection conn SndFileTransfer {fileId}) -> - "[" <> connEntityLabel entity <> ", fileId: " <> sShow fileId <> ", connId: " <> cId conn <> "] " Just entity@(UserContactConnection conn UserContact {userContactLinkId}) -> "[" <> connEntityLabel entity <> ", userContactLinkId: " <> sShow userContactLinkId <> ", connId: " <> cId conn <> "] " - Nothing -> "" + Nothing + | acId == "" -> "" + | otherwise -> plain $ "agent conn ID: " <> acId cId :: Connection -> StyledString cId Connection {connId} = sShow connId ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] @@ -2602,8 +2606,6 @@ connEntityLabel = \case RcvDirectMsgConnection _ (Just Contact {localDisplayName = c}) -> plain c RcvDirectMsgConnection _ Nothing -> "rcv direct msg" RcvGroupMsgConnection _ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} -> plain $ "#" <> g <> " " <> m - RcvFileConnection _ RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> plain $ "rcv file " <> T.pack fileName - SndFileConnection _ SndFileTransfer {fileName} -> plain $ "snd file " <> T.pack fileName UserContactConnection _ UserContact {} -> "contact address" ttyContact :: ContactName -> StyledString diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index bec8d99f7e..f56a4d803d 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -71,7 +71,7 @@ testBroadcastMessages ps = do withTestChat ps "alice" $ \alice -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" bob `connectVia` botLink bob #> "@broadcast_bot hello" bob <# "broadcast_bot> > hello" diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 3f59863f25..42d59cfa9f 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -12,15 +12,16 @@ import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_, when) +import qualified Data.Aeson as J import qualified Data.Text as T import Directory.Captcha +import Directory.Listing import Directory.Options import Directory.Service import Directory.Store import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB @@ -65,8 +66,8 @@ directoryServiceTests = do it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval - describe "list groups" $ do - it "should list user's groups" testListUserGroups + describe "list and promote groups" $ do + it "should list and promote user's groups" $ testListUserGroups True describe "member admission" $ do it "should ask member to pass captcha screen" testCapthaScreening describe "store log" $ do @@ -77,8 +78,8 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} -mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts -mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = +mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts +mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = DirectoryOpts { coreOptions = testCoreOpts @@ -101,9 +102,11 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = profileNameLimit = maxBound, captchaGenerator = Nothing, directoryLog = Just $ ps "directory_service.log", + migrateDirectoryLog = Nothing, serviceName = "SimpleX Directory", runCLI = False, searchResults = 3, + webFolder, testing = True } @@ -273,6 +276,7 @@ testSuspendResume ps = bob <## "The group is listed in directory." superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." superUser <## "The group remained listed in directory." +#if !defined(dbPostgres) -- upgrade link -- make it upgradeable first superUser #> "@'SimpleX Directory' /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" @@ -301,6 +305,7 @@ testSuspendResume ps = superUser <# "'SimpleX Directory'> > /owner 1:privacy hello there" superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" bob <# "'SimpleX Directory'> hello there" +#endif testDeleteGroup :: HasCallStack => TestParams -> IO () testDeleteGroup ps = @@ -531,7 +536,7 @@ testSearchGroups ps = testInviteToOwnersGroup :: HasCallStack => TestParams -> IO () testInviteToOwnersGroup ps = - withDirectoryServiceCfgOwnersGroup ps testCfg True $ \superUser dsLink -> + withDirectoryServiceCfgOwnersGroup ps testCfg True Nothing $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroupId superUser bob "privacy" "Privacy" 2 1 @@ -969,7 +974,7 @@ testDuplicateAskConfirmation ps = cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink <- groupAccepted cath "privacy" groupNotFound bob "privacy" - completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 + completeRegistrationId superUser cath "privacy" "Privacy" welcomeWithLink 2 1 groupFound bob "privacy" testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () @@ -1027,10 +1032,10 @@ testDuplicateProhibitWhenUpdated ps = cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." cath ##> "/gp privacy security Security" cath <## "changed to #security (Security)" - cath <# "'SimpleX Directory'> Thank you! The group link for ID 2 (security) is added to the welcome message." + cath <# "'SimpleX Directory'> Thank you! The group link for ID 1 (security) is added to the welcome message." cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 - approveRegistration superUser cath "security" 2 + approveRegistrationId superUser cath "security" 2 1 groupFound bob "security" groupFound cath "security" @@ -1049,7 +1054,7 @@ testDuplicateProhibitApproval ps = cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink' <- groupAccepted cath "privacy" - updateProfileWithLink cath "privacy" welcomeWithLink' 2 + updateProfileWithLink cath "privacy" welcomeWithLink' 1 notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 groupNotFound cath "privacy" completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 @@ -1060,14 +1065,15 @@ testDuplicateProhibitApproval ps = superUser <# ("'SimpleX Directory'> > " <> approve) superUser <## " The group ID 2 (privacy) is already listed in the directory." -testListUserGroups :: HasCallStack => TestParams -> IO () -testListUserGroups ps = - withDirectoryService ps $ \superUser dsLink -> +testListUserGroups :: HasCallStack => Bool -> TestParams -> IO () +testListUserGroups promote ps = + withDirectoryServiceCfgOwnersGroup ps testCfg False (Just "./tests/tmp/web") $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" + checkListings ["privacy"] [] connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob @@ -1075,7 +1081,9 @@ testListUserGroups ps = cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" cath <## "use @'SimpleX Directory' to send messages" registerGroupId superUser bob "security" "Security" 2 2 + checkListings ["privacy", "security"] [] registerGroupId superUser cath "anonymity" "Anonymity" 3 1 + checkListings ["privacy", "security", "anonymity"] [] listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" @@ -1085,8 +1093,52 @@ testListUserGroups ps = cath <## "" cath <## "The group is no longer listed in the directory." superUser <# "'SimpleX Directory'> The group ID 3 (anonymity) is de-listed (SimpleX Directory role is changed to member)." + checkListings ["privacy", "security"] [] groupNotFound cath "anonymity" listGroups superUser bob cath + when promote $ do + superUser #> "@'SimpleX Directory' /promote 1:privacy on" + superUser <# "'SimpleX Directory'> > /promote 1:privacy on" + superUser <## " Group promotion enabled." + checkListings ["privacy", "security"] ["privacy"] + bob ##> "/gp privacy privacy" + bob <## "description removed" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" + bob <## "It is hidden from the directory until approved." + cath <## "bob updated group #privacy:" + cath <## "description removed" + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." + superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" + superUser <## "privacy" + superUser <## "Welcome message:" + superUser <##. "Link to join the group privacy: https://localhost/g#" + superUser <## "3 members" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" + checkListings ["security"] [] + superUser #> "@'SimpleX Directory' /approve 1:privacy 1" + superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" + superUser <## " Group approved (promoted)!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" + bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + bob <## "" + bob <## "Supported commands:" + bob <## "/'filter 1' - to configure anti-spam filter." + bob <## "/'role 1' - to set default member role." + bob <## "/'link 1' - to view/upgrade group link." + checkListings ["privacy", "security"] ["privacy"] + +checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () +checkListings listed promoted = do + threadDelay 100000 + checkListing listingFileName listed + checkListing promotedFileName promoted + where + checkListing f expected = do + Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web/data" f + map groupName gs `shouldBe` expected + groupName DirectoryEntry {displayName} = displayName testCapthaScreening :: HasCallStack => TestParams -> IO () testCapthaScreening ps = @@ -1176,20 +1228,12 @@ testCapthaScreening ps = testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do - testListUserGroups ps - restoreDirectoryService ps 3 3 $ \superUser _dsLink -> + testListUserGroups False ps + restoreDirectoryService ps 11 $ \superUser _dsLink -> withTestChat ps "bob" $ \bob -> withTestChat ps "cath" $ \cath -> do - bob <## "2 contacts connected (use /cs for the list)" - bob - <### [ "#privacy: connected to server(s)", - "#security: connected to server(s)" - ] - cath <## "2 contacts connected (use /cs for the list)" - cath - <### [ "#privacy: connected to server(s)", - "#anonymity: connected to server(s)" - ] + bob <## "subscribed 5 connections on server localhost" + cath <## "subscribed 5 connections on server localhost" listGroups superUser bob cath groupFoundN 3 bob "privacy" groupFound bob "security" @@ -1294,10 +1338,10 @@ withDirectoryService :: HasCallStack => TestParams -> (TestCC -> String -> IO () withDirectoryService ps = withDirectoryServiceCfg ps testCfg withDirectoryServiceCfg :: HasCallStack => TestParams -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False +withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False Nothing -withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do +withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup webFolder test = do dsLink <- withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do @@ -1315,16 +1359,13 @@ withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do superUser <## "#owners: 'SimpleX Directory' joined the group" ds ##> "/ad" getContactLink ds True - withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test + withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test -restoreDirectoryService :: HasCallStack => TestParams -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () -restoreDirectoryService ps ctCount grCount test = do +restoreDirectoryService :: HasCallStack => TestParams -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService ps connCount test = do dsLink <- withTestChat ps serviceDbPrefix $ \ds -> do - ds <## (show ctCount <> " contacts connected (use /cs for the list)") - ds <## "Your address is active! To show: /sa" - ds <## (show grCount <> " group links active") - forM_ [1 .. grCount] $ \_ -> ds <##. "#" + ds .<## ("subscribed " <> show connCount <> " connections on server localhost") ds ##> "/sa" dsLink <- getContactLink ds False ds <## "auto_accept on" @@ -1332,29 +1373,24 @@ restoreDirectoryService ps ctCount grCount test = do withDirectory ps testCfg dsLink test withDirectory :: HasCallStack => TestParams -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False +withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False Nothing -withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test = do - let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing +withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do + let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] (if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing) webFolder runDirectory cfg opts $ withTestChatCfg ps cfg "super_user" $ \superUser -> do - superUser <## "1 contacts connected (use /cs for the list)" - when createOwnersGroup $ - superUser <## "#owners: connected to server(s)" + if createOwnersGroup + then superUser <## "subscribed 2 connections on server localhost" + else superUser <## "subscribed 1 connections on server localhost" test superUser dsLink runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () runDirectory cfg opts@DirectoryOpts {directoryLog} action = do - st <- restoreDirectoryStore directoryLog - t <- forkIO $ bot st + st <- openDirectoryLog directoryLog + t <- forkIO $ directoryService st opts cfg threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) - where - bot st = do - env <- newServiceState opts - let cfg' = cfg {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg' (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index acf7903003..8fd8a5976d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.Interface (closeDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) @@ -151,7 +151,8 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + migrationBackupPath = Nothing } #if !defined(dbPostgres) @@ -208,7 +209,8 @@ testCfg = showReceipts = False, shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, - tbqSize = 16 + tbqSize = 16, + confirmMigrations = MCYesUp } testCfgSlow :: ChatConfig @@ -296,13 +298,13 @@ startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError + createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} (MigrationConfig MCError Nothing) insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") #else createDatabase TestParams {tmpPath} CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} MCError + createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} (MigrationConfig MCError Nothing) insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 5d64514277..1b93013258 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -16,7 +16,7 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (forM_) +import Control.Monad (forM_, void) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -55,7 +55,7 @@ chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact it "retry connecting via the same link" testRetryConnecting - xit'' "retry connecting via the same link with client timeout" testRetryConnectingClientTimeout + it "retry connecting via the same link with client timeout" testRetryConnectingClientTimeout it "mark multiple messages as read" testMarkReadDirect it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile @@ -118,6 +118,11 @@ chatDirectTests = do it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption #endif + describe "connections synchronization" $ do + it "should report users missing in agent" testConnSyncMissingAgentUsers + it "should remove and report extra users in agent" testConnSyncExtraAgentUsers + it "should report connections missing in agent" testConnSyncMissingAgentConns + it "should remove and report extra connections in agent" testConnSyncExtraAgentConns describe "coordination between app and NSE" $ do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do @@ -129,6 +134,7 @@ chatDirectTests = do it "both users have contact link" testMultipleUserAddresses it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser + it "delete user with chat tags" testDeleteUserChatTags it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser @@ -175,8 +181,6 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange - describe "network statuses" $ do - it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -243,14 +247,14 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes inv <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" getInvitation alice - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> inv) bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" @@ -264,8 +268,8 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes bob <# "alice> message 1" bob #> "@alice message 2" alice <# "bob> message 2" - bob <## "server disconnected localhost (@alice)" - alice <## "server disconnected localhost (@bob)" + bob <## "disconnected 1 connections on server localhost" + alice <## "disconnected 1 connections on server localhost" serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -312,6 +316,8 @@ testRetryConnectingClientTimeout ps = do withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" + -- bob <## "1 subscription errors (run with -c option to show each error)" bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" _sLinkData <- getTermLine bob @@ -342,6 +348,7 @@ testRetryConnectingClientTimeout ps = do messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} } } + ChatConfig {presetServers = presetSrvs@PresetServers {netCfg}} = testCfg cfgZeroTimeout = (testCfg :: ChatConfig) { agentConfig = @@ -349,9 +356,7 @@ testRetryConnectingClientTimeout ps = do { quotaExceededTimeout = 1, messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} }, - presetServers = - let def@PresetServers {netCfg} = presetServers testCfg - in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = NetworkTimeout 10 10}} + presetServers = presetSrvs {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = NetworkTimeout 10 10}} } opts' = testOpts @@ -1247,6 +1252,7 @@ testAsyncInitiatingOffline withShortLink aliceCfg bobCfg ps = do bob ##> ("/c " <> inv) bob <## "confirmation sent!" withTestChatCfg ps aliceCfg "alice" $ \alice -> do + alice <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1262,6 +1268,8 @@ testAsyncAcceptingOffline withShortLink aliceCfg bobCfg ps = do bob <## "confirmation sent!" withTestChatCfg ps aliceCfg "alice" $ \alice -> do withTestChatCfg ps bobCfg "bob" $ \bob -> do + alice <## "subscribed 1 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1277,9 +1285,11 @@ testFullAsyncFast ps = do bob ##> ("/c " <> inv) bob <## "confirmation sent!" threadDelay 250000 - withTestChat ps "alice" $ \alice -> + withTestChat ps "alice" $ \alice -> do + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" - withTestChat ps "bob" $ \bob -> + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" testFullAsyncSlow :: HasCallStack => Bool -> ChatConfig -> ChatConfig -> TestParams -> IO () @@ -1292,13 +1302,15 @@ testFullAsyncSlow withShortLink aliceCfg bobCfg ps = do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withAlice $ \_ -> pure () -- connecting... notification in UI - withBob $ \_ -> pure () -- connecting... notification in UI + withAlice $ \alice -> + alice <## "subscribed 1 connections on server localhost" + withBob $ \bob -> + bob <## "subscribed 1 connections on server localhost" withAlice $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" withBob $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" where withAlice = withTestChatCfg ps aliceCfg "alice" @@ -1392,7 +1404,7 @@ testMaintenanceMode ps = do alice ##> "/_start" alice <## "chat started" -- chat works after start - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice #> "@bob hi again" bob <# "alice> hi again" bob #> "@alice hello" @@ -1413,7 +1425,7 @@ testMaintenanceMode ps = do testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice #> "@bob hello again" bob <# "alice> hello again" bob #> "@alice hello too" @@ -1514,6 +1526,168 @@ testDatabaseEncryption ps = do testChatWorking alice bob #endif +testConnSyncMissingAgentUsers :: HasCallStack => TestParams -> IO () +testConnSyncMissingAgentUsers ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "DELETE FROM users WHERE user_id = 2" + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of missing users in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <## "missing users in agent (agent IDs): 2" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of missing users in agent: 1" + + alice <## "subscribed 1 connections on server localhost" + + alice <##> bob + +testConnSyncExtraAgentUsers :: HasCallStack => TestParams -> IO () +testConnSyncExtraAgentUsers ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "INSERT INTO users DEFAULT VALUES" + agentUserCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM users" :: IO [[Int]] + agentUserCount `shouldBe` [[2]] + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of extra users in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <## "extra users in agent (agent IDs): 2" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of extra users in agent: 1" + alice <## "removed extra users in agent" + + alice <## "subscribed 1 connections on server localhost" + + threadDelay 100000 + agentUserCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM users" :: IO [[Int]] + agentUserCount `shouldBe` [[1]] + + alice <##> bob + +testConnSyncMissingAgentConns :: HasCallStack => TestParams -> IO () +testConnSyncMissingAgentConns ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + -- connection with cath is in user 2, below we delete connection by user_id + -- because it's one of the simplest ways to differentiate them in agent db + connectUsers alice cath + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "DELETE FROM connections WHERE user_id = 2" + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of missing connections in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <##. "missing connections in agent (agent IDs):" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of missing connections in agent: 1" + + alice <## "subscribed 1 connections on server localhost" + -- alice <## "[user: alisa] 1 subscription errors (run with -c option to show each error)" + + alice <##> bob + +testConnSyncExtraAgentConns :: HasCallStack => TestParams -> IO () +testConnSyncExtraAgentConns ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + connectUsers alice cath + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + -- deleting connection record in chat db + void $ withCCTransaction alice $ \db -> + DB.execute_ db "DELETE FROM connections WHERE contact_id = (SELECT contact_id FROM contacts WHERE local_display_name = 'cath')" + agentConnCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]] + agentConnCount `shouldBe` [[2]] + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of extra connections in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <##. "extra connections in agent (agent IDs):" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of extra connections in agent: 1" + alice <## "removed extra connections in agent" + + alice <## "subscribed 1 connections on server localhost" + + threadDelay 100000 + agentConnCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]] + agentConnCount `shouldBe` [[1]] + + alice <##> bob + testSubscribeAppNSE :: HasCallStack => TestParams -> IO () testSubscribeAppNSE ps = withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -1532,7 +1706,7 @@ testSubscribeAppNSE ps = (nseAlice "/_app activate" alice <## "ok" - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1743,8 +1917,8 @@ testUsersSubscribeAfterRestart ps = do withTestChat ps "alice" $ \alice -> do -- second user is active - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- second user receives message alice <##> bob @@ -1937,6 +2111,26 @@ testDeleteUser = alice ##> "/users" alice <## "no users" +testDeleteUserChatTags :: HasCallStack => TestParams -> IO () +testDeleteUserChatTags = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/_create tag {\"text\":\"my tag\"}" + alice <## "[{\"chatTagId\":1,\"chatTagText\":\"my tag\"}]" + alice ##> "/_tags @2 1" + alice <## "chat tags updated" + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice ##> "/_delete user 1 del_smp=off" + alice <## "ok" + + alice ##> "/users" + alice <## "alisa (active)" + testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () testUsersDifferentCIExpirationTTL ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -2049,8 +2243,8 @@ testUsersRestartCIExpiration ps = do showActiveUser alice "alice (Alice)" withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- first user messages alice ##> "/user alice" @@ -2148,8 +2342,8 @@ testEnableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- messages are not deleted for second user after restart alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) @@ -2204,8 +2398,8 @@ testDisableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- second user still has ttl configured after restart alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") @@ -2311,8 +2505,8 @@ testUsersTimedMessages ps = do alice <# "bob> alisa 4" withTestChat ps "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" alice ##> "/user alice" showActiveUser alice "alice (Alice)" @@ -2660,7 +2854,8 @@ testAbortSwitchContact ps = do -- repeat switch stop is prohibited alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected ps "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice started changing address for you" -- alice changes address again alice #$> ("/switch bob", id, "switch started") @@ -2706,8 +2901,8 @@ testAbortSwitchGroupMember ps = do -- repeat switch stop is prohibited alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected ps "bob" $ \bob -> do - bob <## "#team: connected to server(s)" + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice started changing address for you" -- alice changes address again alice #$> ("/switch #team bob", id, "switch started") @@ -2806,7 +3001,7 @@ testMsgDecryptError ps = alice <# "bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" alice #> "@bob hello again" bob <# "alice> skipped message ID 9..11" bob <# "alice> hello again" @@ -2817,7 +3012,7 @@ setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" alice #> "@bob 1" bob <# "alice> 1" bob #> "@alice 2" @@ -2828,7 +3023,7 @@ setupDesynchronizedRatchet ps alice = do alice <# "bob> 4" threadDelay 500000 withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" alice #> "@bob 1" @@ -2859,7 +3054,7 @@ testSyncRatchet ps = alice <# "bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "connection synchronization started" alice <## "bob: connection synchronization agreed" @@ -2898,7 +3093,7 @@ testSyncRatchetCodeReset ps = aliceInfo bob True setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "connection synchronization started" alice <## "bob: connection synchronization agreed" @@ -3122,7 +3317,7 @@ testUpdatePeerChatVRange ps = contactInfoChatVRange bob supportedChatVRange withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob #> "@alice hello 1" alice <# "bob> hello 1" @@ -3134,7 +3329,7 @@ testUpdatePeerChatVRange ps = contactInfoChatVRange bob supportedChatVRange withTestChatCfg ps cfg11 "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob #> "@alice hello 2" alice <# "bob> hello 2" @@ -3147,20 +3342,6 @@ testUpdatePeerChatVRange ps = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig -testGetNetworkStatuses :: HasCallStack => TestParams -> IO () -testGetNetworkStatuses ps = do - withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - connectUsers alice bob - alice ##> "/_network_statuses" - alice <## "1 connections connected" - withTestChatCfg ps cfg "alice" $ \alice -> - withTestChatCfg ps cfg "bob" $ \bob -> do - alice <## "1 connections connected" - bob <## "1 connections connected" - where - cfg = testCfg {coreApi = True} - vr11 :: VersionRangeChat vr11 = mkVersionRange (VersionChat 1) (VersionChat 1) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index c573a1957a..2de9d7ffe5 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -818,7 +818,7 @@ testXFTPContinueRcv ps = do -- server is down - file is not received withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/fr 1 ./tests/tmp" bob <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", @@ -833,7 +833,7 @@ testXFTPContinueRcv ps = do withXFTPServer $ do -- server is up - file reception is continued withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" @@ -866,7 +866,7 @@ testXFTPMarkToReceive = do bob <## "chat started" bob - <### [ "1 contacts connected (use /cs for the list)", + <### [ "subscribed 1 connections on server localhost", "started receiving file 1 (test.pdf) from alice", "saving file 1 from alice to test.pdf" ] @@ -892,7 +892,7 @@ testXFTPRcvError ps = do -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/fr 1 ./tests/tmp" bob <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7f8761c1e1..e9168ba469 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,5 +1,6 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -33,6 +34,7 @@ import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -92,8 +94,8 @@ chatGroupTests = do -- TODO [postgres] this test hangs with PostgreSQL it "send multiple messages (many chat batches)" testSendMultiManyBatches #endif - xit'' "shared message body is reused" testSharedMessageBody - xit'' "shared batch body is reused" testSharedBatchBody + it "shared message body is reused" testSharedMessageBody + it "shared batch body is reused" testSharedBatchBody describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do @@ -152,7 +154,8 @@ chatGroupTests = do it "manually accept contact with group member" testMemberContactAccept it "manually accept contact with group member incognito" testMemberContactAcceptIncognito describe "group message forwarding" $ do - it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage + it "forward batched messages" testGroupMsgForwardBatched it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit @@ -221,6 +224,19 @@ chatGroupTests = do it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen + -- TODO [channels fwd] enable tests (requires communicating useRelays to members) + -- TODO [channels fwd] add tests for channels + -- TODO - tests with multiple relays (all relays should deliver messages, members should deduplicate) + -- TODO - tests with delivery loop over members restored after restart + -- TODO - delivery in support scopes inside channels + xdescribe "channels" $ do + describe "relay delivery" $ do + it "should deliver messages to members" testChannelsRelayDeliver + describe "should deliver messages in a loop over members" $ do + it "number of recipients is multiple of bucket size (3/1)" (testChannelsRelayDeliverLoop 1) + it "number of recipients is NOT multiple of bucket size (3/2)" (testChannelsRelayDeliverLoop 2) + it "number of recipients is equal to bucket size (3/3)" (testChannelsRelayDeliverLoop 3) + it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -310,46 +326,26 @@ testGroupShared alice bob cath checkMessages = do bob <## "#team: you don't have permission to send messages" bob ##> "/rm team cath" bob <## "#team: you have insufficient permissions for this action, the required role is admin" - cath #> "#team hello" - concurrentlyN_ - [ alice <# "#team cath> hello", - bob <# "#team cath> hello" - ] alice ##> "/mr team bob admin" concurrentlyN_ [ alice <## "#team: you changed the role of bob to admin", bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin" ] - -- remove member - bob ##> "/rm team cath" - concurrentlyN_ - [ bob <## "#team: you removed cath from the group", - alice <## "#team: bob removed cath from the group", - do - cath <## "#team: bob removed you from the group" - cath <## "use /d #team to delete the group" - ] - bob #> "#team hi" - concurrently_ - (alice <# "#team bob> hi") - (cath "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath "#team hello" - cath <## "bad chat command: not current member" -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" when checkMessages $ threadDelay 1000000 alice #> "#team checking connection" - bob <# "#team alice> checking connection" + concurrently_ + (bob <# "#team alice> checking connection") + (cath <# "#team alice> checking connection") when checkMessages $ threadDelay 1000000 bob #> "#team received" - alice <# "#team bob> received" + concurrently_ + (alice <# "#team bob> received") + (cath <# "#team bob> received") when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] bob @@@ [("@alice", "contact deleted"), ("#team", "received")] @@ -710,7 +706,7 @@ testGroupDelete :: HasCallStack => TestParams -> IO () testGroupDelete = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) alice ##> "/d #team" concurrentlyN_ [ alice <## "#team: you deleted the group", @@ -978,7 +974,7 @@ testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 100000 @@ -1778,12 +1774,10 @@ testGroupDelayedModeration ps = do alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" bob <### [ "#team: new member cath is connected", @@ -1834,15 +1828,13 @@ testGroupDelayedModerationFullDelete ps = do cath <## "updated group preferences:" cath <## "Full deletion: on" withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" bob <### [ "#team: new member cath is connected", @@ -1861,7 +1853,7 @@ testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () testDeleteMemberWithMessages = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 750000 alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -1899,7 +1891,7 @@ testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () testDeleteMemberMarkMessagesDeleted = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 1000000 bob #> "#team hello" concurrently_ @@ -2012,7 +2004,7 @@ testSharedMessageBody ps = withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" alice #> "#team hello" bodiesCount1 <- withCCAgentTransaction alice $ \db -> DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] @@ -2022,13 +2014,9 @@ testSharedMessageBody ps = withTestChatOpts ps opts' "bob" $ \bob -> withTestChatOpts ps opts' "cath" $ \cath -> do concurrentlyN_ - [ alice <##. "server connected localhost", - do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + [ alice <## "subscribed 4 connections on server localhost", + bob <## "subscribed 3 connections on server localhost", + cath <## "subscribed 3 connections on server localhost" ] bob <# "#team alice> hello" cath <# "#team alice> hello" @@ -2036,7 +2024,7 @@ testSharedMessageBody ps = DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] bodiesCount2 `shouldBe` [[0]] - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -2060,7 +2048,7 @@ testSharedBatchBody ps = withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}" cms = intercalate ", " (map cm [1 .. 300 :: Int]) @@ -2076,13 +2064,9 @@ testSharedBatchBody ps = withTestChatOpts ps opts' "bob" $ \bob -> withTestChatOpts ps opts' "cath" $ \cath -> do concurrentlyN_ - [ alice <##. "server connected localhost", - do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + [ alice <## "subscribed 4 connections on server localhost", + bob <## "subscribed 3 connections on server localhost", + cath <## "subscribed 3 connections on server localhost" ] forM_ [(1 :: Int) .. 300] $ \i -> do concurrently_ @@ -2092,7 +2076,7 @@ testSharedBatchBody ps = DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] bodiesCount2 `shouldBe` [[0]] - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -2131,8 +2115,7 @@ testGroupAsync ps = do bob <# "#team alice> hello bob" withTestChat ps "alice" $ \alice -> do withNewTestChat ps "cath" cathProfile $ \cath -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)" + alice <## "subscribed 2 connections on server localhost" connectUsers alice cath alice ##> "/a team cath" concurrentlyN_ @@ -2152,21 +2135,18 @@ testGroupAsync ps = do withTestChat ps "cath" $ \cath -> do concurrentlyN_ [ do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <# "#team alice> hello cath" bob <## "#team: new member cath is connected", do - cath <## "2 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 withTestChat ps "bob" $ \bob -> do withNewTestChat ps "dan" danProfile $ \dan -> do - bob <## "2 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 4 connections on server localhost" connectUsers bob dan bob ##> "/a team dan" concurrentlyN_ @@ -2187,18 +2167,15 @@ testGroupAsync ps = do withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do - alice <## "2 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)" + alice <## "subscribed 4 connections on server localhost" alice <## "#team: bob added dan (Daniel) to the group (connecting...)" alice <## "#team: new member dan is connected", do - cath <## "2 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 4 connections on server localhost" cath <## "#team: bob added dan (Daniel) to the group (connecting...)" cath <## "#team: new member dan is connected", do - dan <## "3 contacts connected (use /cs for the list)" - dan <## "#team: connected to server(s)" + dan <## "subscribed 5 connections on server localhost" dan <## "#team: member alice (Alice) is connected" dan <## "#team: member cath (Catherine) is connected" ] @@ -2208,18 +2185,10 @@ testGroupAsync ps = do withTestChat ps "cath" $ \cath -> do withTestChat ps "dan" $ \dan -> do concurrentlyN_ - [ do - alice <## "3 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)", - do - bob <## "3 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "3 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)", - do - dan <## "3 contacts connected (use /cs for the list)" - dan <## "#team: connected to server(s)" + [ alice <## "subscribed 6 connections server localhost", + bob <## "subscribed 6 connections server localhost", + cath <## "subscribed 6 connections server localhost", + dan <## "subscribed 6 connections server localhost" ] alice #> "#team hello" concurrentlyN_ @@ -3560,12 +3529,12 @@ testPlanGroupLinkConnecting ps = do threadDelay 100000 withTestChat ps "alice" $ \alice -> do alice - <### [ "1 group links active", - "#team: group is empty", + <### [ "subscribed 1 connections on server localhost", "bob (Bob): accepting request to join group #team..." ] withTestChat ps "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "#team: joining the group..." bob <## "#team: you joined the group" @@ -3607,12 +3576,12 @@ testPlanGroupLinkConnectingSlow ps = do threadDelay 100000 withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice - <### [ "1 group links active", - "#team: group is empty", + <### [ "subscribed 1 connections on server localhost", "bob (Bob): accepting request to join group #team..." ] withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "#team: joining the group..." bob ##> ("/_connect plan 1 " <> gLink) @@ -3637,8 +3606,7 @@ testGroupMsgDecryptError ps = alice <# "#team bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" alice #> "#team hello again" bob <# "#team alice> skipped message ID 9..11" bob <# "#team alice> hello again" @@ -3649,8 +3617,7 @@ setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" alice #> "#team 1" bob <# "#team alice> 1" bob #> "#team 2" @@ -3660,8 +3627,7 @@ setupDesynchronizedRatchet ps alice = do bob #> "#team 4" alice <# "#team bob> 4" withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob ##> "/sync #team alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" alice #> "#team 1" @@ -3689,8 +3655,7 @@ testGroupSyncRatchet ps = alice <# "#team bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob `send` "#team 1" -- "send prohibited" error is not printed in group as SndMessage is created, -- but it should be displayed in per member snd statuses @@ -3735,8 +3700,7 @@ testGroupSyncRatchetCodeReset ps = aliceInfo bob True setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob ##> "/sync #team alice" bob <## "connection synchronization started" alice <## "#team bob: connection synchronization agreed" @@ -4434,20 +4398,20 @@ testMemberContactInvitedConnectionReplaced ps = do items `shouldContain` [(0, "security code changed")] withTestChat ps "bob" $ \bob -> do - subscriptions bob 1 + subscriptions bob 3 checkConnectionsWork alice bob withTestChat ps "alice" $ \alice -> do - subscriptions alice 2 + subscriptions alice 4 withTestChat ps "bob" $ \bob -> do - subscriptions bob 1 + subscriptions bob 3 checkConnectionsWork alice bob withTestChat ps "cath" $ \cath -> do - subscriptions cath 1 + subscriptions cath 3 -- group messages work alice #> "#team hello" @@ -4464,9 +4428,8 @@ testMemberContactInvitedConnectionReplaced ps = do (bob <# "#team cath> hey team") where subscriptions :: TestCC -> Int -> IO () - subscriptions cc n = do - cc <## (show n <> " contacts connected (use /cs for the list)") - cc <## "#team: connected to server(s)" + subscriptions cc n = + cc <## ("subscribed " <> show n <> " connections on server localhost") checkConnectionsWork alice bob = do alice <##> bob alice @@@ [("@bob", "hey"), ("@cath", "sent invitation to join group team as admin"), ("#team", "connected")] @@ -4855,8 +4818,8 @@ testMemberContactAcceptIncognito = cath ?#> ("@" <> bobIncognito <> " hey") bob ?<# (cathIncognito <> "> hey") -testGroupMsgForward :: HasCallStack => TestParams -> IO () -testGroupMsgForward = +testGroupMsgForwardMessage :: HasCallStack => TestParams -> IO () +testGroupMsgForwardMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -4884,6 +4847,61 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardBatched :: HasCallStack => TestParams -> IO () +testGroupMsgForwardBatched = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + setupGroupForwarding alice bob cath + + bob ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 3\"}}]" + bob <# "#team test 1" + bob <# "#team test 2" + bob <# "#team test 3" + alice <# "#team bob> test 1" + alice <# "#team bob> test 2" + alice <# "#team bob> test 3" + cath <# "#team bob> test 1 [>>]" + cath <# "#team bob> test 2 [>>]" + cath <# "#team bob> test 3 [>>]" + + threadDelay 1000000 + + cath ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 4\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 5\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 6\"}}]" + cath <# "#team test 4" + cath <# "#team test 5" + cath <# "#team test 6" + alice <# "#team cath> test 4" + alice <# "#team cath> test 5" + alice <# "#team cath> test 6" + bob <# "#team cath> test 4 [>>]" + bob <# "#team cath> test 5 [>>]" + bob <# "#team cath> test 6 [>>]" + + alice ##> "/tail #team 6" + alice <# "#team bob> test 1" + alice <# "#team bob> test 2" + alice <# "#team bob> test 3" + alice <# "#team cath> test 4" + alice <# "#team cath> test 5" + alice <# "#team cath> test 6" + + bob ##> "/tail #team 6" + bob <# "#team test 1" + bob <# "#team test 2" + bob <# "#team test 3" + bob <# "#team cath> test 4 [>>]" + bob <# "#team cath> test 5 [>>]" + bob <# "#team cath> test 6 [>>]" + + cath ##> "/tail #team 6" + cath <# "#team bob> test 1 [>>]" + cath <# "#team bob> test 2 [>>]" + cath <# "#team bob> test 3 [>>]" + cath <# "#team test 4" + cath <# "#team test 5" + cath <# "#team test 6" + testGroupMsgForwardReport :: HasCallStack => TestParams -> IO () testGroupMsgForwardReport = testChat3 aliceProfile bobProfile cathProfile $ @@ -6983,8 +7001,7 @@ testGroupMemberInactive ps = do threadDelay 1500000 withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <# "#team alice> 1" bob <# "#team alice> 2" bob <#. "#team alice> skipped message ID" @@ -8144,3 +8161,163 @@ testScopedSupportUnreadStatsCorrectOnOpen = testOpts { markRead = False } + +testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () +testChannelsRelayDeliver = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do + createChannel5 alice bob cath dan eve GRObserver + + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <# "#team cath> > alice hi" + eve <## " + 👍" + +-- TODO [channels fwd] correctly setup channel with relay forwarding +-- TODO - alice to create group as channel +-- TODO - add bob as relay +-- TODO - alice to manage group link, but members to connect to relay (bob) +createChannel5 :: TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> GroupMemberRole -> IO () +createChannel5 alice bob cath dan eve mRole = do + createGroup2 "team" alice bob + bob ##> ("/create link #team " <> B.unpack (strEncode mRole)) + gLink <- getGroupLink bob "team" mRole True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + bob <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: cath joined the group", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group" + cath <## "#team: member alice (Alice) is connected", + do + alice <## "#team: bob added cath (Catherine) to the group (connecting...)" + alice <## "#team: new member cath is connected" + ] + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + bob <## "dan (Daniel): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: dan joined the group", + do + dan <## "#team: joining the group..." + dan <## "#team: you joined the group" + dan <## "#team: member alice (Alice) is connected" + dan <## "#team: member cath (Catherine) is connected", + do + alice <## "#team: bob added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + do + cath <## "#team: bob added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + bob <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: eve joined the group", + eve + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member alice (Alice) is connected", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + alice <## "#team: bob added eve (Eve) to the group (connecting...)" + alice <## "#team: new member eve is connected", + do + cath <## "#team: bob added eve (Eve) to the group (connecting...)" + cath <## "#team: new member eve is connected", + do + dan <## "#team: bob added eve (Eve) to the group (connecting...)" + dan <## "#team: new member eve is connected" + ] + +testChannelsRelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () +testChannelsRelayDeliverLoop deliveryBucketSize = + testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do + createChannel5 alice bob cath dan eve GRObserver + + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <# "#team cath> > alice hi" + eve <## " + 👍" + where + cfg = testCfg {deliveryBucketSize} + +testChannelsSenderDeduplicateOwn :: HasCallStack => TestParams -> IO () +testChannelsSenderDeduplicateOwn ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> + createChannel5 alice bob cath dan eve GRMember + + -- chat relay bob is offline + alice #> "#team 1" + alice #> "#team 2" + alice #> "#team 3" + cath #> "#team 4" + cath #> "#team 5" + dan #> "#team 6" + + withTestChatCfg ps cfg "bob" $ \bob -> do + bob <## "subscribed 6 connections server localhost" + bob + <### [ WithTime "#team alice> 1", + WithTime "#team alice> 2", + WithTime "#team alice> 3", + WithTime "#team cath> 4", + WithTime "#team cath> 5", + WithTime "#team dan> 6" + ] + alice + <### [ WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]", + WithTime "#team dan> 6 [>>]" + ] + cath + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team dan> 6 [>>]" + ] + dan + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]" + ] + eve + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]", + WithTime "#team dan> 6 [>>]" + ] + where + cfg = testCfg {deliveryWorkerDelay = 250000} diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 07bf06b61b..a1ab8548ed 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -142,6 +142,7 @@ shortLinkTests = do it "changing profile should update address short link data" testShortLinkAddressChangeProfile it "changing auto-reply message should update address short link data" testShortLinkAddressChangeAutoReply it "changing group profile should update short link data" testShortLinkGroupChangeProfile + it "receiving group profile update should update short link data" testShortLinkGroupChangeProfileReceived testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -320,27 +321,27 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile cLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" getContactLink alice True - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> cLink) bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> cLink) alice <#? bob - alice <## "server disconnected localhost ()" - bob <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" alice ##> "/ac bob" alice <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" - bob <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ @@ -350,8 +351,8 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bob <# "alice> message 1" bob #> "@alice message 2" alice <# "bob> message 2" - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -1085,7 +1086,7 @@ testPlanAddressConnecting ps = do threadDelay 100000 withTestChat ps "alice" $ \alice -> do - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1093,6 +1094,7 @@ testPlanAddressConnecting ps = do alice <## "bob (Bob): accepting contact request, you can send messages to contact" withTestChat ps "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" bob @@@ [("@alice", "Audio/video calls: enabled")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1128,7 +1130,7 @@ testPlanAddressConnectingSlow ps = do threadDelay 100000 withTestChatCfg ps testCfgSlow "alice" $ \alice -> do - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1136,6 +1138,7 @@ testPlanAddressConnectingSlow ps = do alice <## "bob (Bob): accepting contact request..." withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting to contact alice" @@ -1545,10 +1548,12 @@ testSetConnectionIncognitoProhibitedDuringNegotiation ps = do bob <## "confirmation sent!" withTestChat ps "alice" $ \alice -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" alice <##> bob alice `hasContactProfiles` ["alice", "bob"] @@ -1566,9 +1571,11 @@ testSetConnectionIncognitoProhibitedDuringNegotiationSlow ps = do bob <## "confirmation sent!" withTestChatCfg ps testCfgSlow "alice" $ \alice -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" withTestChatCfg ps testCfgSlow "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1657,11 +1664,11 @@ testJoinGroupIncognito = alice <## "group #secret_club is created" alice <## "to add members use /a secret_club or /create link #secret_club" -- alice invites bob - alice ##> "/a secret_club bob admin" + alice ##> "/a secret_club bob member" concurrentlyN_ [ alice <## "invitation to join the group #secret_club sent to bob", do - bob <## "#secret_club: alice invites you to join the group as admin" + bob <## "#secret_club: alice invites you to join the group as member" bob <## "use /j secret_club to accept" ] bob ##> "/j secret_club" @@ -1669,11 +1676,11 @@ testJoinGroupIncognito = (alice <## "#secret_club: bob joined the group") (bob <## "#secret_club: you joined the group") -- alice invites cath - alice ##> ("/a secret_club " <> cathIncognito <> " admin") + alice ##> ("/a secret_club " <> cathIncognito <> " member") concurrentlyN_ [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), do - cath <## "#secret_club: alice invites you to join the group as admin" + cath <## "#secret_club: alice invites you to join the group as member" cath <## ("use /j secret_club to join incognito as " <> cathIncognito) ] -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts @@ -1687,15 +1694,12 @@ testJoinGroupIncognito = bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") ] - -- cath cannot invite to the group because her membership is incognito - cath ##> "/a secret_club dan" - cath <## "you are using an incognito profile for this group - prohibited to invite contacts" -- alice invites dan - alice ##> "/a secret_club dan admin" + alice ##> "/a secret_club dan member" concurrentlyN_ [ alice <## "invitation to join the group #secret_club sent to dan", do - dan <## "#secret_club: alice invites you to join the group as admin" + dan <## "#secret_club: alice invites you to join the group as member" dan <## "use /j secret_club to accept" ] dan ##> "/j secret_club" @@ -1754,39 +1758,39 @@ testJoinGroupIncognito = alice ##> "/ms secret_club" alice <### [ "alice (Alice): owner, you, created group", - "bob (Bob): admin, invited, connected", - ConsoleString $ cathIncognito <> ": admin, invited, connected", - "dan (Daniel): admin, invited, connected" + "bob (Bob): member, invited, connected", + ConsoleString $ cathIncognito <> ": member, invited, connected", + "dan (Daniel): member, invited, connected" ] bob ##> "/ms secret_club" bob <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, you, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, connected" + "bob (Bob): member, you, connected", + ConsoleString $ cathIncognito <> ": member, connected", + "dan (Daniel): member, connected" ] cath ##> "/ms secret_club" cath <### [ "alice (Alice): owner, host, connected", - "bob_1 (Bob): admin, connected", - ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", - "dan_1 (Daniel): admin, connected" + "bob_1 (Bob): member, connected", + ConsoleString $ "i " <> cathIncognito <> ": member, you, connected", + "dan_1 (Daniel): member, connected" ] dan ##> "/ms secret_club" dan <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, you, connected" + "bob (Bob): member, connected", + ConsoleString $ cathIncognito <> ": member, connected", + "dan (Daniel): member, you, connected" ] -- remove member - bob ##> ("/rm secret_club " <> cathIncognito) + alice ##> ("/rm secret_club " <> cathIncognito) concurrentlyN_ - [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), - alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + [ alice <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), + bob <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"), + dan <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"), do - cath <## "#secret_club: bob_1 removed you from the group" + cath <## "#secret_club: alice removed you from the group" cath <## "use /d #secret_club to delete the group" ] bob #> "#secret_club hi" @@ -3031,11 +3035,11 @@ testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobPro bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 text hello" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "invitation link: known prepared contact alice" @@ -3049,8 +3053,8 @@ testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobPro (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") alice <##> bob - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 1 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" tmp = tmpPath ps serverCfg' = smpServerCfg @@ -3196,11 +3200,11 @@ testShortLinkAddressConnectRetry ps = bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 text hello" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "contact address: known prepared contact alice" @@ -3221,8 +3225,8 @@ testShortLinkAddressConnectRetry ps = (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") alice <##> bob - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -3251,11 +3255,11 @@ testShortLinkAddressConnectRetryIncognito ps = bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 incognito=on text hello" bob <##. "smp agent error: BROKER" - bobIncognito <- withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + withSmpServer' serverCfg' $ do + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "contact address: known prepared contact alice" @@ -3284,9 +3288,8 @@ testShortLinkAddressConnectRetryIncognito ps = bob ?<# "alice> hi" bob ?#> "@alice hey" alice <# (bobIncognito <> "> hey") - pure bobIncognito - alice <## ("server disconnected localhost (@" <> bobIncognito <> ")") - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -3541,15 +3544,15 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" pure shortLink - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" bob ##> "/_connect group #1" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do bob ##> ("/_connect plan 1 " <> shortLink) bob <## "group link: known prepared group #team" - alice <## "server connected localhost (@bob)" - bob <## "server connected localhost (@alice)" + alice <## "subscribed 2 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> "/_connect group #1" bob <## "#team: connection started" @@ -3568,8 +3571,8 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob <# "#team alice> 1" bob #> "#team 2" alice <# "#team bob> 2" - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 3 connections on server localhost" + bob <## "disconnected 2 connections on server localhost" tmp = tmpPath ps serverCfg' = smpServerCfg @@ -4146,3 +4149,41 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile [alice, cath] *<# "#club bob> 2" cath #> "#club 3" [alice, bob] *<# "#club cath> 3" + +testShortLinkGroupChangeProfileReceived :: HasCallStack => TestParams -> IO () +testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cathProfile test + where + test alice bob cath = do + createGroup2' "team" alice (cath, GROwner) True + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + + cath ##> "/gp team club" + cath <## "changed to #club" + alice <## "cath updated group #team:" + alice <## "changed to #club" + + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#club: group is prepared" + bob ##> "/_connect group #1" + bob <## "#club: connection started" + alice <## "bob (Bob): accepting request to join group #club..." + concurrentlyN_ + [ alice <## "#club: bob joined the group", + do + bob <## "#club: joining the group..." + bob <## "#club: you joined the group" + bob <## "#club: member cath (Catherine) is connected", + do + cath <## "#club: alice added bob (Bob) to the group (connecting...)" + cath <## "#club: new member bob is connected" + ] + alice #> "#club 1" + [bob, cath] *<# "#club alice> 1" + bob #> "#club 2" + [alice, cath] *<# "#club bob> 2" + cath #> "#club 3" + [alice, bob] *<# "#club cath> 3" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index dfb0aece74..aa6ba70e7b 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -140,34 +140,6 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath -withTestChatGroup3Connected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatGroup3Connected ps dbPrefix action = do - withTestChat ps dbPrefix $ \cc -> do - cc <## "2 contacts connected (use /cs for the list)" - cc <## "#team: connected to server(s)" - action cc - -withTestChatGroup3Connected' :: HasCallStack => TestParams -> String -> IO () -withTestChatGroup3Connected' ps dbPrefix = withTestChatGroup3Connected ps dbPrefix $ \_ -> pure () - -withTestChatContactConnected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnected ps dbPrefix action = - withTestChat ps dbPrefix $ \cc -> do - cc <## "1 contacts connected (use /cs for the list)" - action cc - -withTestChatContactConnected' :: HasCallStack => TestParams -> String -> IO () -withTestChatContactConnected' ps dbPrefix = withTestChatContactConnected ps dbPrefix $ \_ -> pure () - -withTestChatContactConnectedV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnectedV1 ps dbPrefix action = - withTestChatV1 ps dbPrefix $ \cc -> do - cc <## "1 contacts connected (use /cs for the list)" - action cc - -withTestChatContactConnectedV1' :: HasCallStack => TestParams -> String -> IO () -withTestChatContactConnectedV1' ps dbPrefix = withTestChatContactConnectedV1 ps dbPrefix $ \_ -> pure () - -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () cc1 <##> cc2 = do diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index a2577240a1..f3364dac81 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -28,33 +28,15 @@ chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"result\":{\"type\":\"chatStarted\"}}" -networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"result\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" +connectionsDiffSwift :: LB.ByteString +connectionsDiffSwift = "{\"result\":{\"_owsf\":true,\"connectionsDiff\":{\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}}" -networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"result\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" +connectionsDiffTagged :: LB.ByteString +connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}" userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}" -memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"result\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" - -memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"result\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" - -userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"result\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" - -userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"result\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" - -pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"result\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" - -pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"result\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" - parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 400db87b36..52d722d26a 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -25,10 +25,7 @@ owsf2TaggedJSONTest = do activeUserExistsSwift `to` activeUserExistsTagged activeUserSwift `to` activeUserTagged chatStartedSwift `to` chatStartedTagged - networkStatusesSwift `to` networkStatusesTagged - memberSubSummarySwift `to` memberSubSummaryTagged - userContactSubSummarySwift `to` userContactSubSummaryTagged - pendingSubSummarySwift `to` pendingSubSummaryTagged + connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged where to :: LB.ByteString -> LB.ByteString -> IO () diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ba784a1fc..7213a0e91c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,12 +1,15 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-orphans #-} -module MobileTests where +module MobileTests (mobileTests) where +import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent.STM @@ -28,7 +31,8 @@ import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures -import Simplex.Chat.Controller (ChatController (..)) +import Simplex.Chat +import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -37,8 +41,7 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) -import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) @@ -111,36 +114,12 @@ chatStarted = chatStartedTagged #endif -networkStatuses :: LB.ByteString -networkStatuses = +connectionsDiff :: LB.ByteString +connectionsDiff = #if defined(darwin_HOST_OS) && defined(swiftJSON) - networkStatusesSwift + connectionsDiffSwift #else - networkStatusesTagged -#endif - -memberSubSummary :: LB.ByteString -memberSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - memberSubSummarySwift -#else - memberSubSummaryTagged -#endif - -userContactSubSummary :: LB.ByteString -userContactSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - userContactSubSummarySwift -#else - userContactSubSummaryTagged -#endif - -pendingSubSummary :: LB.ByteString -pendingSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - pendingSubSummarySwift -#else - pendingSubSummaryTagged + connectionsDiffTagged #endif parsedMarkdown :: LB.ByteString @@ -166,16 +145,16 @@ testChatApi :: TestParams -> IO () testChatApi ps = do let tmp = tmpPath ps dbPrefix = tmp "1" - f = dbPrefix <> chatSuffix - Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) MCYesUp - Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True + Right ChatDatabase {chatStore, agentStore} <- createChatDatabase (ChatDbOpts dbPrefix "myKey" DB.TQOff True) (MigrationConfig MCYesUp Nothing) + insertUser agentStore + Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` networkStatuses + chatRecvMsg cc `shouldReturn` connectionsDiff chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index f1b509ac6f..61ea5f8fdd 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -14,7 +14,7 @@ import Data.Maybe (fromJust, isJust) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -24,20 +24,20 @@ testSchemaPath :: FilePath testSchemaPath = "tests/tmp/test_schema.sql" -- copied from simplexmq -postgresSchemaDumpTest :: [Migration] -> [String] -> DBOpts -> FilePath -> Spec -postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBOpts {connstr, schema = testDBSchema} srcSchemaPath = do +postgresSchemaDumpTest :: [Migration] -> DBOpts -> FilePath -> Spec +postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSchema} srcSchemaPath = do it "verify and overwrite schema dump" testVerifySchemaDump it "verify schema down migrations" testSchemaMigrations where testVerifySchemaDump = do savedSchema <- ifM (doesFileExist srcSchemaPath) (readFile srcSchemaPath) (pure "") savedSchema `deepseq` pure () - void $ createDBStore testDBOpts migrations MCConsole + void $ createDBStore testDBOpts migrations (MigrationConfig MCConsole Nothing) getSchema srcSchemaPath `shouldReturn` savedSchema testSchemaMigrations = do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations - st <- createDBStore testDBOpts noDownMigrations MCYesUpDown >>= \case + st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e mapM_ (testDownMigration st) $ drop (length noDownMigrations) migrations @@ -48,14 +48,14 @@ postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBO putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testSchemaPath - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema' <- getSchema testSchemaPath schema' `shouldNotBe` schema - Migrations.run st $ MTRDown [downMigr] + Migrations.run st Nothing $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testSchemaPath schema'' `shouldBe` schema - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema''' <- getSchema testSchemaPath schema''' `shouldBe` schema' @@ -72,3 +72,9 @@ postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBO void $ readCreateProcess (shell $ sed <> " '/^--/d' " <> schemaPath) "" sch <- readFile schemaPath sch `deepseq` pure sch + +skipComparisonForDownMigrations :: [String] +skipComparisonForDownMigrations = + [ -- via_group field moves + "20250922_remove_unused_connections" + ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index df1739449e..b1b7dbc264 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -26,7 +26,7 @@ import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -63,7 +63,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -71,14 +71,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations MCError + Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -88,15 +88,15 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema' <- getSchema testDB testSchema unless (name m `elem` skipComparisonForUpMigrations) $ schema' `shouldNotBe` schema - Migrations.run st True $ MTRDown [downMigr] + Migrations.run st Nothing True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' @@ -129,7 +129,9 @@ skipComparisonForDownMigrations = -- index moves down to the end of the file "20250227_member_acceptance", -- index moves down to the end of the file - "20250721_indexes" + "20250721_indexes", + -- indexes move down to the end of the file + "20250922_remove_unused_connections" ] getSchema :: FilePath -> FilePath -> IO String @@ -150,7 +152,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appChatQueryPlans chatQueryStats - (createChatStore (DBOpts testDB "" False True TQOff) MCError) + (createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)) (\db -> do DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" @@ -159,7 +161,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appAgentQueryPlans agentQueryStats - (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) + (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing)) (const $ pure ()) chatSavedPlans' == chatSavedPlans `shouldBe` True agentSavedPlans' == agentSavedPlans `shouldBe` True diff --git a/tests/Test.hs b/tests/Test.hs index 46106f0654..e4e76fd43e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} -import APIDocs import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -27,9 +26,10 @@ import ViewTests import Control.Exception (bracket_) import PostgresSchemaDump import Simplex.Chat.Store.Postgres.Migrations (migrations) -import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) -import System.Directory (createDirectory, removePathForcibly) +import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) +import System.Directory (createDirectoryIfMissing, removePathForcibly) #else +import APIDocs import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump @@ -44,17 +44,12 @@ main = do agentQueryStats <- TM.emptyIO #endif withGlobalLogging logCfg . hspec -#if defined(dbPostgres) - . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) - . afterAll_ (dropDatabaseAndUser testDBConnectInfo) -#endif $ do #if defined(dbPostgres) - around_ (bracket_ (createDirectory "tests/tmp") (removePathForcibly "tests/tmp")) $ + createdDropDb . around_ (bracket_ (createDirectoryIfMissing False "tests/tmp") (removePathForcibly "tests/tmp")) $ describe "Postgres schema dump" $ postgresSchemaDumpTest migrations - [] -- skipComparisonForDownMigrations schemaDumpDBOpts "src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql" #else @@ -71,8 +66,7 @@ main = do describe "Operators" operatorTests describe "Random servers" randomServersTests #if defined(dbPostgres) - around testBracket - . after_ (dropAllSchemasExceptSystem testDBConnectInfo) + createdDropDb . around testBracket #else around (testBracket chatQueryStats agentQueryStats) #endif @@ -89,6 +83,9 @@ main = do #endif where #if defined(dbPostgres) + createdDropDb = + before_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + . after_ (dropDatabaseAndUser testDBConnectInfo) testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False} #else testBracket chatQueryStats agentQueryStats test = diff --git a/website/langs/ar.json b/website/langs/ar.json index 0c1fb4515a..022bb6a94f 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -232,7 +232,7 @@ "docs-dropdown-2": "الوصول إلى ملفات اندرويد", "docs-dropdown-3": "الوصول إلى قاعدة بيانات الدردشة", "glossary": "المعجم", - "docs-dropdown-8": "SimpleX خدمة الدليل", + "docs-dropdown-8": "دليل SimpleX", "simplex-chat-via-f-droid": "SimpleX Chat عبر F-Droid", "simplex-chat-repo": "مستودع SimpleX Chat", "stable-and-beta-versions-built-by-developers": "الإصدارات الثابتة والتجريبية التي أنشأها المطورون", @@ -255,5 +255,7 @@ "docs-dropdown-11": "الأسئلة الأكثر شيوعًا", "docs-dropdown-12": "الأمان", "hero-overlay-card-3-p-3": "قامت Trail of Bits بمراجعة التصميم التعموي لبروتوكولات شبكة SimpleX في يوليو 2024. اقرأ المزيد.", - "docs-dropdown-14": "SimpleX للأعمال التجارية" + "docs-dropdown-14": "SimpleX للأعمال التجارية", + "directory": "دليل", + "about-and-contact-us": "عن واتصل بنا" } diff --git a/website/langs/cs.json b/website/langs/cs.json index 146626551f..5a7b03616b 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -238,7 +238,7 @@ "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", "comparison-section-list-point-4a": "SimpleX relé nemůže ohrozit šifrování e2e. Ověřte bezpečnostní kód, který zmírňuje mimo pásmový útok na kanál", - "docs-dropdown-8": "Služba SimpleX Directory", + "docs-dropdown-8": "SimpleX Directory", "please-enable-javascript": "Prosím, povolte JavaScript k zobrazení QR kódu.", "please-use-link-in-mobile-app": "Prosím použijte odkaz v mobilní aplikaci", "simplex-chat-via-f-droid": "SimpleX Chat přes F-Droid", @@ -255,5 +255,7 @@ "docs-dropdown-11": "FAQ (často kladené dotazy)", "docs-dropdown-12": "Bezpečnost", "hero-overlay-card-3-p-3": "Trail of Bits přezkoumala kryptografický design síťových protokolů SimpleX v červenci 2024. Přečíst více.", - "docs-dropdown-14": "SimpleX pro podnikání" + "docs-dropdown-14": "SimpleX pro podnikání", + "directory": "Složka", + "about-and-contact-us": "O nás & Kontakt" } diff --git a/website/langs/de.json b/website/langs/de.json index ccfb813e6f..c3d4035374 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -237,16 +237,16 @@ "stable-versions-built-by-f-droid-org": "Von F-Droid.org erstellte stabile Versionen", "f-droid-page-f-droid-org-repo-section-text": "SimpleX-Chat- und F-Droid.org-Repositorys signieren ihre Builds mit verschiedenen Schlüsseln. Zum Umschalten bitte die Chat-Datenbank exportieren und die App neu installieren.", "releases-to-this-repo-are-done-1-2-days-later": "Die Versionen für dieses Repository werden einige Tage später erstellt", - "docs-dropdown-8": "SimpleX-Verzeichnisdienst", + "docs-dropdown-8": "SimpleX-Verzeichnis", "simplex-chat-via-f-droid": "SimpleX Chat per F-Droid", "simplex-chat-repo": "SimpleX-Chat-Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen, scannen Sie den QR-Code oder nutzen Sie diese URL:", "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen", - "hero-overlay-3-title": "Sicherheits-Gutachten", + "hero-overlay-3-title": "Security-Gutachten", "hero-overlay-card-3-p-2": "Trail of Bits untersuchte im November 2022 die kryptografischen und Netzwerk-Komponenten der SimpleX-Plattform. Lesen Sie mehr dazu in der Ankündigung.", "jobs": "Treten Sie dem Team bei", - "hero-overlay-3-textlink": "Sicherheits-Gutachten", + "hero-overlay-3-textlink": "Security-Gutachten", "hero-overlay-card-3-p-1": "Trail of Bits ist eine führende Security- und Technologie-Unternehmensberatung, deren Kunden aus den Bereichen Big-Tech, Regierungsbehörden und großen Blockchain-Projekten stammen.", "docs-dropdown-9": "Downloads", "please-enable-javascript": "Bitte aktivieren Sie JavaScript, um den QR-Code zu sehen.", @@ -255,5 +255,61 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Sicherheit", "hero-overlay-card-3-p-3": "Trail of Bits hat das kryptografische Design des Netzwerk-Protokolls von SimpleX im Juli 2024 überprüft. Hier finden Sie weitere Informationen dazu.", - "docs-dropdown-14": "SimpleX für geschäftliche Anwendungen" + "docs-dropdown-14": "SimpleX für geschäftliche Anwendungen", + "about-and-contact-us": "Über uns & Kontakt", + "directory": "Verzeichnis", + "index-hero-h1": "Sei
frei", + "index-hero-h2": "Freiheit & Sicherheit
Ihrer Kommunikation", + "index-hero-p1": "Das erste Netzwerk, in dem Sie Ihre Identität, Kontakte und Gruppen besitzen.", + "index-hero-download-desktop-btn-title": "Download der SimpleX Desktop-App", + "index-testflight-title": "Öffentlicher iOS-Preview auf TestFlight", + "index-f-droid-title": "SimpleX F-Droid-Repository", + "index-security-assessment-title": "Security-Gutachten", + "index-security-review-2022-title": "Security-Überprüfung 2022", + "index-security-review-2024-title": "Security-Überprüfung 2024", + "index-security-audits-label": "Security-
Audits", + "index-publications-privacy-guides-title": "Empfehlungen für Messenger", + "index-publications-whonix-title": "Whonix - Empfehlungen für Messenger", + "index-publications-heise-title": "Veröffentlichung", + "index-publications-kuketz-title": "Überprüfung", + "index-publications-optout-title": "Podcast-Interview", + "worlds-most-secure-messaging": "Das sicherste Messaging-System der Welt", + "index-messaging-p1": "SimpleX-Messaging verfügt über modernste Ende-zu-Ende-Verschlüsselung.", + "index-messaging-p2": "Zu Ihrer Sicherheit und zum Schutz Ihrer Privatsphäre können Server Ihre Nachrichten weder sehen, noch mit wem Sie kommunizieren.", + "index-messaging-cta": "Lernen Sie mehr über SimpleX-Messaging", + "index-nextweb-h2": "Sie besitzen
The Next Web", + "index-nextweb-p1": "SimpleX basiert auf der Überzeugung, dass Sie Eigentümer Ihrer Identität, Ihrer Kontakte und Ihrer Communities sein müssen.", + "index-nextweb-p2": "Ein offenes und dezentrales Netzwerk ermöglicht es Ihnen, mit Menschen in Kontakt zu treten und Ideen auszutauschen: Seien Sie frei und sicher.", + "index-token-h2": "Communities, die Bestand haben", + "index-token-p1": "Sie werden Ihre Lieblingsgruppen mit zukünftigen Community-Gutscheinen unterstützen.", + "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communities kostenlos und unabhängig bleiben können.", + "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT
für 20 % Rabatt auf SimpleX-Token!", + "index-roadmap-h2": "SimpleX - Roadmap zum freien Internet", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Skalierung auf große Communities", + "index-roadmap-2025-desc": "Ausstieg aus zentralisierten Plattformen", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Nachhaltige Communities & Server", + "index-roadmap-2026-desc": "Einführung von Community-Gutscheinen", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Lassen Sie Ihre Communities wachsen", + "index-roadmap-2027-desc": "Tools zur Förderung Ihrer Communities", + "index-directory-h2": "Treten Sie SimpleX-Communities bei", + "index-directory-p1": "Hunderttausende Menschen vertrauen bereits SimpleX-Messaging.", + "index-directory-p2": "Finden Sie Ihre Communities im SimpleX-Verzeichnis und erstellen Sie Ihre Eigenen!", + "index-directory-cta": "SimpleX-Verzeichnis anzeigen", + "index-directory-users-group-title": "SimpleX-Nutzergruppe", + "how-secure-comparison-title": "Wie sicher ist die Ende-zu-Ende-Verschlüsselung in verschiedenen Messengern?", + "how-secure-message-padding": "Nachrichten-Padding", + "how-secure-repudiation-deniability": "Glaubhafte Abstreitbarkeit", + "how-secure-forward-secrecy": "Forward Secrecy", + "how-secure-break-in-recovery": "Sicherheit nach Kompromittierung", + "how-secure-two-factor-key-exchange": "2-Faktor-Schlüsselaustausch", + "how-secure-post-quantum-hybrid-crypto": "Post-Quanten-Hybridkryptografie", + "messengers-comparison-section-list-point-1": "Briar verwendet Padding, um Nachrichten auf eine Länge von mindestens 1024 Byte aufzurunden; Signal - auf 160 Byte", + "messengers-comparison-section-list-point-2": "Abstreitbarkeit gilt nicht für die Client-Server-Verbindung.", + "messengers-comparison-section-list-point-3": "Es scheint, dass die Verwendung kryptografischer Signaturen die Abstreitbarkeit beeinträchtigt, aber dies muss noch geklärt werden.", + "messengers-comparison-section-list-point-4": "Die Multi-Geräte-Implementierung beeinträchtigt die Sicherheit nach Kompromittierung des Double Ratchet-Algorithmus", + "messengers-comparison-section-list-point-5": "Ein 2-Faktor-Schlüsselaustausch ist optional und kann über die Überprüfung eines Sicherheitscodes erfolgen.", + "messengers-comparison-section-list-point-6": "Die Schlüsselvereinbarung per Post-Quanten-Security ist nicht durchgängig — Sie schützt lediglich ausgewählte Schritte innerhalb des Ratchet-Prozesses." } diff --git a/website/langs/en.json b/website/langs/en.json index f9691e2594..eeddf9db4a 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -1,5 +1,6 @@ { "home": "Home", + "directory": "Directory", "developers": "Developers", "reference": "Reference", "blog": "Blog", @@ -14,7 +15,7 @@ "simplex-explained-tab-1-p-1": "You can create contacts and groups, and have two-way conversations, as in any other messenger.", "simplex-explained-tab-1-p-2": "How can it work with unidirectional queues and without user profile identifiers?", "simplex-explained-tab-2-p-1": "For each connection you use two separate messaging queues to send and receive messages via different servers.", - "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", + "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", "simplex-explained-tab-3-p-1": "The servers have separate anonymous credentials for each queue, and do not know which users they belong to.", "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing corellation by IP address.", "chat-bot-example": "Chat bot example", @@ -24,6 +25,7 @@ "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", + "about-and-contact-us": "About & Contact us", "terms-and-privacy-policy": "Privacy Policy", "hero-header": "Privacy redefined", "hero-subheader": "The first messenger
without user IDs", @@ -96,7 +98,7 @@ "hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.", "hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.", "hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.", - "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", "hero-overlay-card-2-p-1": "When users have persistent identities, even if this is just a random number, like a Session ID, there is a risk that the provider or an attacker can observe how the users are connected and how many messages they send.", "hero-overlay-card-2-p-2": "They could then correlate this information with the existing public social networks, and determine some real identities.", "hero-overlay-card-2-p-3": "Even with the most private apps that use Tor v3 services, if you talk to two different contacts via the same profile they can prove that they are connected to the same person.", @@ -104,26 +106,26 @@ "hero-overlay-card-3-p-1": "Trail of Bits is a leading security and technology consultancy whose clients include big tech, governmental agencies and major blockchain projects.", "hero-overlay-card-3-p-2": "Trail of Bits reviewed SimpleX network cryptography and networking components in November 2022. Read more.", "hero-overlay-card-3-p-3": "Trail of Bits reviewed cryptographic design of SimpleX network protocols in July 2024. Read more.", - "simplex-network-overlay-card-1-p-1": "P2P messaging protocols and apps have various problems that make them less reliable than SimpleX, more complex to analyse, and vulnerable to several types of attack.", - "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", + "simplex-network-overlay-card-1-p-1": "P2P messaging protocols and apps have various problems that make them less reliable than SimpleX, more complex to analyse, and vulnerable to several types of attack.", + "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", "simplex-network-overlay-card-1-li-2": "SimpleX design, unlike most P2P networks, has no global user identifiers of any kind, even temporary, and only uses temporary pairwise identifiers, providing better anonymity and metadata protection.", - "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", - "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", - "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", - "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", - "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", + "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", + "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", + "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", + "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", + "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", - "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as 'poverty premium'.", + "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as \"poverty premium\".", "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", - "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", + "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication network that does not have access to your social graph.", - "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", + "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", - "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", - "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their 'anonymous' accounts, even in democratic countries.", + "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", + "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their \"anonymous\" accounts, even in democratic countries.", "privacy-matters-overlay-card-3-p-4": "It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks — who we are connected with.", "simplex-unique-overlay-card-1-p-1": "Unlike other messaging networks, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — SimpleX server operators don't know how many people use their servers.", - "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", + "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX network servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-overlay-card-2-p-2": "Even with the optional user address, while it can be used to send spam contact requests, you can change or completely delete it without losing any of your connections.", @@ -132,8 +134,8 @@ "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", - "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", - "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", @@ -149,7 +151,7 @@ "sign-up-to-receive-our-updates": "Sign up to receive our updates", "enter-your-email-address": "Enter your email address", "get-simplex": "Get SimpleX desktop app", - "why-simplex-is-unique": "Why SimpleX is unique", + "why-simplex-is-unique": "Why SimpleX is unique", "learn-more": "Learn more", "more-info": "More info", "hide-info": "Hide info", @@ -164,7 +166,7 @@ "install-simplex-app": "Install SimpleX app", "connect-in-app": "Connect in app", "open-simplex-app": "Open Simplex app", - "tap-the-connect-button-in-the-app": "Tap the ‘connect’ button in the app", + "tap-the-connect-button-in-the-app": "Tap the \"connect\" button in the app", "scan-the-qr-code-with-the-simplex-chat-app": "Scan the QR code with the SimpleX Chat app", "scan-the-qr-code-with-the-simplex-chat-app-description": "The public keys and message queue address in this link are NOT sent over the network when you view this page —
they are contained in the hash fragment of the link URL.", "installing-simplex-chat-to-terminal": "Installing SimpleX chat to terminal", @@ -176,12 +178,12 @@ "if-you-already-installed": "If you already installed", "simplex-chat-for-the-terminal": "SimpleX Chat for the terminal", "copy-the-command-below-text": "copy the command below and use it in the chat:", - "privacy-matters-section-header": "Why privacy matters", - "privacy-matters-section-subheader": "Preserving the privacy of your metadata — who you talk with — protects you from:", - "privacy-matters-section-label": "Make sure your messenger can't access your data!", - "simplex-private-section-header": "What makes SimpleX private", + "privacy-matters-section-header": "Why privacy matters", + "privacy-matters-section-subheader": "Preserving the privacy of your metadata — who you talk with — protects you from:", + "privacy-matters-section-label": "Make sure your messenger can't access your data!", + "simplex-private-section-header": "What makes SimpleX private", "tap-to-close": "Tap to close", - "simplex-network-section-header": "SimpleX Network", + "simplex-network-section-header": "SimpleX Network", "simplex-network-section-desc": "Simplex Chat provides the best privacy by combining the advantages of P2P and federated networks.", "simplex-network-1-header": "Unlike P2P networks", "simplex-network-1-desc": "All messages are sent via the servers, both providing better metadata privacy and reliable asynchronous message delivery, while avoiding many", @@ -189,7 +191,7 @@ "simplex-network-2-header": "Unlike federated networks", "simplex-network-2-desc": "SimpleX relay servers do NOT store user profiles, contacts and delivered messages, do NOT connect to each other, and there is NO servers directory.", "simplex-network-3-header": "SimpleX network", - "simplex-network-3-desc": "servers provide unidirectional queues to connect the users, but they have no visibility of the network connection graph — only the users do.", + "simplex-network-3-desc": "servers provide unidirectional queues to connect the users, but they have no visibility of the network connection graph — only the users do.", "comparison-section-header": "Comparison with other protocols", "protocol-1-text": "Signal, big platforms", "protocol-2-text": "XMPP, Matrix", @@ -210,8 +212,8 @@ "comparison-section-list-point-2": "DNS-based addresses", "comparison-section-list-point-3": "Public key or some other globally unique ID", "comparison-section-list-point-4a": "SimpleX relays cannot compromise e2e encryption. Verify security code to mitigate attack on out-of-band channel", - "comparison-section-list-point-4": "If operator’s servers are compromised. Verify security code in Signal and some other apps to mitigate it", - "comparison-section-list-point-5": "Does not protect users' metadata privacy", + "comparison-section-list-point-4": "If operator's servers are compromised. Verify security code in Signal and some other apps to mitigate it", + "comparison-section-list-point-5": "Does not protect users' metadata privacy", "comparison-section-list-point-6": "While P2P are distributed, they are not federated — they operate as a single network", "comparison-section-list-point-7": "P2P networks either have a central authority or the whole network can be compromised", "see-here": "see here", @@ -232,7 +234,7 @@ "docs-dropdown-5": "Host XFTP Server", "docs-dropdown-6": "WebRTC servers", "docs-dropdown-7": "Translate SimpleX Chat", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "docs-dropdown-9": "Downloads", "docs-dropdown-10": "Transparency", "docs-dropdown-11": "FAQ", @@ -247,13 +249,67 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "SimpleX Chat repo", "stable-and-beta-versions-built-by-developers": "Stable and beta versions built by the developers", - "f-droid-page-simplex-chat-repo-section-text": "To add it to your F-Droid client, scan the QR code or use this URL:", + "f-droid-page-simplex-chat-repo-section-text": "To add it to your F-Droid client, scan the QR code or use this URL:", "signing-key-fingerprint": "Signing key fingerprint (SHA-256)", "f-droid-org-repo": "F-Droid.org repo", "stable-versions-built-by-f-droid-org": "Stable versions built by F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "The releases to this repo are done several days later", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please export the chat database and re-install the app.", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please export the chat database and re-install the app.", "jobs": "Join team", "please-enable-javascript": "Please enable JavaScript to see the QR code.", - "please-use-link-in-mobile-app": "Please use the link in the mobile app" + "please-use-link-in-mobile-app": "Please use the link in the mobile app", + "index-hero-h1": "Be
Free", + "index-hero-h2": "Freedom & Security
of Your Communications", + "index-hero-p1": "The first network where you own your identity, contacts, and groups.", + "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", + "index-testflight-title": "SimpleX iOS beta-release on TestFlight", + "index-f-droid-title": "SimpleX app via F-Droid", + "index-security-assessment-title": "Security Audits", + "index-security-review-2022-title": "Security Audit 2022", + "index-security-review-2024-title": "Security Audit 2024", + "index-security-audits-label": "Security
Audits", + "index-publications-privacy-guides-title": "Privacy Guides messenger recommendations", + "index-publications-whonix-title": "Whonix messenger recommendations", + "index-publications-heise-title": "Heise Online publications", + "index-publications-kuketz-title": "Review by Mike Kuketz", + "index-publications-optout-title": "OptOut podcast interview", + "worlds-most-secure-messaging": "World's Most Secure Messaging", + "index-messaging-p1": "SimpleX messaging has cutting-edge end-to-end encryption.", + "index-messaging-p2": "For your security and privacy, servers can't see your messages and who you talk to.", + "index-messaging-cta": "Learn more about SimpleX messaging", + "index-nextweb-h2": "You Own
The Next Web", + "index-nextweb-p1": "SimpleX is founded on the belief that you must own your identity, contacts and communities.", + "index-nextweb-p2": "Open and decentralized network lets you connect with people and share ideas: be free and secure.", + "index-token-h2": "Communities That Last", + "index-token-p1": "You will support your favorite groups with future Community Vouchers.", + "index-token-p2": "Vouchers will pay for servers, to let your communities stay free and independent.", + "index-token-cta": "Learn more and get your free NFT
for 20% discount on SimpleX Token!", + "index-roadmap-h2": "SimpleX Roadmap to Free Internet", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Scale to Large Communities", + "index-roadmap-2025-desc": "Escaping centralized platforms", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Sustainable Communities & Servers", + "index-roadmap-2026-desc": "Launching Community Vouchers", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Make Your Communities Grow", + "index-roadmap-2027-desc": "Tools to promote your communities", + "index-directory-h2": "Join SimpleX Communities", + "index-directory-p1": "Hundreds of thousands people already trust SimpleX messaging.", + "index-directory-p2": "Find your communities in SimpleX directory and create your own!", + "index-directory-cta": "View SimpleX Directory", + "index-directory-users-group-title": "SimpleX users group", + "how-secure-comparison-title": "Comparison of end-to-end encryption security in different messengers", + "how-secure-message-padding": "Message padding", + "how-secure-repudiation-deniability": "Repudiation (deniability)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Post-compromise security", + "how-secure-two-factor-key-exchange": "2-factor key exchange", + "how-secure-post-quantum-hybrid-crypto": "Post-quantum hybrid crypto", + "messengers-comparison-section-list-point-1": "Briar pads messages to the size rounded up to 1024 bytes, Signal - to 160 bytes", + "messengers-comparison-section-list-point-2": "Repudiation does not include client-server connection.", + "messengers-comparison-section-list-point-3": "It appears that the usage of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified.", + "messengers-comparison-section-list-point-4": "Multi-device implementation compromises post-compromise security of Double Ratchet", + "messengers-comparison-section-list-point-5": "2-factor key exchange is optional via security code verification.", + "messengers-comparison-section-list-point-6": "Post-quantum key agreement is \"sparse\" — it protects only some of the ratchet steps." } \ No newline at end of file diff --git a/website/langs/es.json b/website/langs/es.json index c4db631c95..0feb776f34 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -234,7 +234,7 @@ "glossary": "Glosario", "stable-and-beta-versions-built-by-developers": "Versiones estables y beta compilados por los desarrolladores", "f-droid-page-simplex-chat-repo-section-text": "Para añadirlo a tu cliente F-Droid escanea el código QR usa esta URL:", - "docs-dropdown-8": "Servicio Simplex Directory", + "docs-dropdown-8": "Directorio SimpleX", "simplex-chat-repo": "Repositorio Simplex Chat", "simplex-chat-via-f-droid": "SimpleX Chat en F-Droid", "f-droid-org-repo": "Repositorio F-Droid.org", @@ -255,5 +255,61 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Seguridad", "docs-dropdown-14": "SimpleX para empresas", - "hero-overlay-card-3-p-3": "Trail of Bits ha revisado el diseño criptográfico de los protocolos de red SimpleX en julio del 2024. Saber más." + "hero-overlay-card-3-p-3": "Trail of Bits ha revisado el diseño criptográfico de los protocolos de red SimpleX en julio del 2024. Saber más.", + "directory": "Directorio", + "about-and-contact-us": "Acerca de & Contacta con nosotros", + "index-hero-h1": "Sea
Libre", + "index-hero-h2": "Libertad y Seguridad
en Tus Comunicaciones", + "index-hero-p1": "La primera red donde su identidad, contactos y grupos le pertenecen.", + "index-hero-download-desktop-btn-title": "Descargar SimpleX Desktop App", + "index-testflight-title": "Vista previa pública de iOS en TestFlight", + "index-f-droid-title": "Repositorio SimpleX F-Droid", + "index-security-assessment-title": "Evaluación de seguridad", + "index-security-review-2022-title": "Revisión de Seguridad 2022", + "index-security-review-2024-title": "Revisión de Seguridad 2024", + "index-security-audits-label": "Auditorías de
Seguridad", + "index-publications-privacy-guides-title": "Recomendaciones de mensajería", + "index-publications-whonix-title": "Recomendación de mensajería de Whonix", + "index-publications-heise-title": "Publicación", + "index-publications-kuketz-title": "Revisión", + "index-publications-optout-title": "Entrevista en podcast", + "worlds-most-secure-messaging": "La Mensajería Más Segura del Mundo", + "index-messaging-p1": "Le mensajería SimpleX dispone de cifrado de extremo a extremo de vanguardia.", + "index-messaging-p2": "Por su seguridad y privacidad los servidores no pueden ver sus mensajes ni con quién habla.", + "index-messaging-cta": "Sepa más sobre la mensajería SimpleX", + "index-nextweb-h2": "Usted Posee
La Web Futura", + "index-nextweb-p1": "SimpleX se funda en la creencia de que usted debe ser propietario de su identidad, contactos y comunidades.", + "index-nextweb-p2": "Una red abierta y descentralizada que le permite conectarse con personas y compartir ideas: libre y segura.", + "index-token-h2": "Comunidades Que Perduran", + "index-token-p1": "Usted apoyará a sus grupos favoritos con futuros Vales Comunitarios.", + "index-token-p2": "Los vales pagarán los servidores para que sus comunidades sigan siendo libres e independientes.", + "index-token-cta": "Conoce más y obten tu NFT gratuito
para un 20% de descuento en SimpleX Token!", + "index-roadmap-h2": "Hoja de ruta SimpleX hacia el Internet Libre", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Escalar a Grandes Comunidades", + "index-roadmap-2025-desc": "Huyendo de plataformas centralizadas", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Comunidades y Servidores Sostenibles", + "index-roadmap-2026-desc": "Lanzamiento de los Vales Comunitarios", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Haga Crecer Sus Comunidades", + "index-roadmap-2027-desc": "Herramientas para promocionar sus comunidades", + "index-directory-h2": "Únase a las Comunidades SimpleX", + "index-directory-p1": "Cientos de miles de personas ya confían en la mensajería SimpleX.", + "index-directory-p2": "¡Encuentre sus comunidades en el directorio SimpleX y cree la suya!", + "index-directory-cta": "Ver el Directorio SimpleX", + "index-directory-users-group-title": "SimpleX users group", + "how-secure-comparison-title": "¿Qué nivel de seguridad ofrece el cifrado de extremo a extremo en los distintos servicios de mensajería?", + "how-secure-message-padding": "Relleno de mensajes", + "how-secure-repudiation-deniability": "Repudio (negabilidad)", + "how-secure-forward-secrecy": "Secreto hacia adelante", + "how-secure-break-in-recovery": "Seguridad posterior a ser comprometido", + "how-secure-two-factor-key-exchange": "Intercambio de claves de dobre factor", + "how-secure-post-quantum-hybrid-crypto": "Cifrado híbrido postcuántico", + "messengers-comparison-section-list-point-1": "Briar rellena los mensajes hasta el tamaño redondeado a 1024 bytes, Signal a 160 bytes", + "messengers-comparison-section-list-point-2": "El repudio no incluye la conexión cliente‑servidor.", + "messengers-comparison-section-list-point-3": "Parece que el uso de firmas criptográficas compromete el repudio (negabilidad) pero es necesario aclararlo.", + "messengers-comparison-section-list-point-4": "La implementación multi‑dispositivo compromete la seguridad posterior a ser comprometida del Double Ratchet", + "messengers-comparison-section-list-point-5": "El intercambio de clave de doble factor es opcional mediante la verificación del código de seguridad.", + "messengers-comparison-section-list-point-6": "El acuerdo de claves postcuántico es \"parcial\" — solo protege determinados pasos del ratchet." } diff --git a/website/langs/fa.json b/website/langs/fa.json index 5fce03d04a..250d1800ca 100644 --- a/website/langs/fa.json +++ b/website/langs/fa.json @@ -177,7 +177,7 @@ "docs-dropdown-5": "میزبانی سرور XFTP", "docs-dropdown-6": "سرورهای WebRTC", "docs-dropdown-7": "SimpleX Chat را ترجمه کنید", - "docs-dropdown-8": "سرویس دایرکتوری SimpleX", + "docs-dropdown-8": "سرویس دایرکتوری", "docs-dropdown-9": "دانلودها", "docs-dropdown-10": "شفافیت", "docs-dropdown-11": "سؤالات متداول", @@ -255,5 +255,7 @@ "simplex-network-overlay-card-1-li-1": "شبکه‌های P2P به نوعی از DHT برای مسیریابی پیام‌ها وابسته هستند. طراحی‌های DHT باید بین تضمین تحویل و تأخیر تعادل برقرار کنند. SimpleX هم تضمین تحویل بهتری دارد و هم تأخیر کمتری نسبت به P2P، زیرا پیام می‌تواند به صورت اضافی از طریق چندین سرور به طور موازی منتقل شود و از سرورهای انتخاب‌شده توسط گیرنده استفاده کند. در شبکه‌های P2P، پیام به صورت ترتیبی از طریق O(log N) گره منتقل می‌شود و این گره‌ها توسط الگوریتم انتخاب می‌شوند.", "simplex-network-overlay-card-1-li-2": "طراحی SimpleX، برخلاف اکثر شبکه‌های P2P، هیچ شناسه کاربری جهانی از هیچ نوعی، حتی موقتی، ندارد و فقط از شناسه‌های جفت‌به‌جفت موقتی استفاده می‌کند که این امر حریم خصوصی و محافظت از فراداده را بهتر فراهم می‌کند.", "simplex-network-overlay-card-1-li-3": "P2P مشکل حملات MITM را حل نمی‌کند و بیشتر پیاده‌سازی‌های موجود از پیام‌های خارج از باند برای تبادل کلید اولیه استفاده نمی‌کنند. SimpleX از پیام‌های خارج از باند یا در برخی موارد، از اتصالات امن و مورد اعتماد پیشین برای تبادل کلید اولیه استفاده می‌کند.", - "simplex-network-overlay-card-1-li-4": "پیاده‌سازی‌های P2P می‌توانند توسط برخی از ارائه‌دهندگان اینترنت (مانند BitTorrent) مسدود شوند. SimpleX مستقل از نوع حمل و نقل است — این امکان را دارد که بر روی پروتکل‌های وب استاندارد، مانند WebSockets، کار کند." + "simplex-network-overlay-card-1-li-4": "پیاده‌سازی‌های P2P می‌توانند توسط برخی از ارائه‌دهندگان اینترنت (مانند BitTorrent) مسدود شوند. SimpleX مستقل از نوع حمل و نقل است — این امکان را دارد که بر روی پروتکل‌های وب استاندارد، مانند WebSockets، کار کند.", + "directory": "دایرکتوری", + "about-and-contact-us": "درباره ما و تماس با ما" } diff --git a/website/langs/hu.json b/website/langs/hu.json index 7b643c2d97..6590f11cc2 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -29,7 +29,7 @@ "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX azonban nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", - "hero-overlay-3-textlink": "Biztonsági auditok", + "hero-overlay-3-textlink": "Biztonsági felmérések", "hero-2-header": "Privát kapcsolat létrehozása", "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat a partneréhez egy egyszer használható QR-kód segítségével, személyesen vagy videóhíváson keresztül. Ugyanakkor egy meghívási hivatkozás megosztásával is kapcsolódhat.", "hero-overlay-1-title": "Hogyan működik a SimpleX?", @@ -139,7 +139,7 @@ "sign-up-to-receive-our-updates": "Regisztráljon a hírleveleinkre, hogy ne maradjon le semmiről", "enter-your-email-address": "Adja meg az e-mail-címét", "get-simplex": "A SimpleX számítógépes alkalmazásának letöltése", - "why-simplex-is-unique": "A SimpleX mitől egyedülálló", + "why-simplex-is-unique": "Miért egyedülálló a SimpleX", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", @@ -167,13 +167,13 @@ "privacy-matters-section-subheader": "A metaadatok — pédául, hogy kivel beszélget — védelmének megőrzése biztonságot nyújt a következők ellen:", "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataihoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", - "simplex-network-section-header": "SimpleXhálózat", + "simplex-network-section-header": "SimpleX hálózat", "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálókönyvtár.", "simplex-network-3-header": "SimpleX hálózat", - "simplex-network-3-desc": "a kiszolgálók egyirányú üzenetvárólistákat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", + "simplex-network-3-desc": "A kiszolgálók egyirányú üzenetvárólistákat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", "protocol-1-text": "Signal, nagy platformok", "protocol-2-text": "XMPP, Matrix", @@ -209,7 +209,7 @@ "docs-dropdown-1": "SimpleX hálózat", "docs-dropdown-2": "Android fájlok elérése", "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", - "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", + "docs-dropdown-8": "SimpleX csoportjegyzék", "docs-dropdown-9": "Letöltések", "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt a webcímet:", "signing-key-fingerprint": "Az aláírókulcs ujjlenyomata (SHA-256)", @@ -255,5 +255,61 @@ "simplex-chat-repo": "A SimpleX Chat tárolója", "stable-and-beta-versions-built-by-developers": "A fejlesztők által készített stabil és béta verziók", "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában ismét auditálta a SimpleX protokollok kriptográfiai és hálózati komponenseit. További információk.", - "docs-dropdown-14": "SimpleX üzleti célra" + "docs-dropdown-14": "SimpleX üzleti célra", + "directory": "Csoportjegyzék", + "about-and-contact-us": "Névjegy és kapcsolat", + "index-hero-h1": "
Legyen
szabad
", + "index-hero-p1": "Az első olyan hálózat, ahol Ön a tulajdonosa saját identitásának, partnereinek és csoportjainak.", + "index-hero-download-desktop-btn-title": "SimpleX számítógépes alkalmazásának letöltése", + "index-security-assessment-title": "biztonsági felmérés", + "index-security-review-2022-title": "Biztonsági audit 2022", + "index-security-review-2024-title": "Biztonsági audit 2024", + "index-security-audits-label": "Biztonsági
auditok", + "index-publications-heise-title": "kiadvány", + "index-hero-h2": "Az Ön kommunikációjának
szabadsága és biztonsága", + "index-testflight-title": "Nyilvános betekintés az iOS alkalmazás fejlesztésébe a TestFlighton", + "index-f-droid-title": "SimpleX F-Droid-tároló", + "index-publications-privacy-guides-title": "ajánlások", + "index-publications-whonix-title": "Whonix ajánlás", + "index-publications-kuketz-title": "áttekintés", + "index-publications-optout-title": "podcast interjú", + "worlds-most-secure-messaging": "A világ legbiztonságosabb üzenetváltó alkalmazása", + "index-messaging-p1": "Az üzenetváltás a SimpleXben élvonalbeli végpontok közötti titkosítással rendelkezik.", + "index-messaging-p2": "Az Ön biztonsága és magánszférájának védelme érdekében a kiszolgálók nem látják az üzeneteit, sem azt, hogy kivel beszélget.", + "index-messaging-cta": "Tudjon meg többet a SimpleX üzenetváltó alkalmazásról", + "index-nextweb-h2": "A következő háló
Az Öné", + "index-nextweb-p1": "A SimpleX arra a meggyőződésre épül, hogy Önnek kell birtokolnia a saját identitását, a kapcsolatait és a közösségeit.", + "index-nextweb-p2": "A nyílt és decentralizált hálózat lehetővé teszi, hogy kapcsolatba lépjen másokkal és ötleteket osszon meg: legyen szabad biztonságban.", + "index-token-h2": "Időtálló közösségek", + "index-token-p1": "A jövőben közösségi utalványokkal támogathatja a kedvenc csoportjait.", + "index-token-p2": "Az utalványokkal fizetni tudja a kiszolgálókat, hogy a közösségek szabadok és függetlenek maradhassanak.", + "index-token-cta": "Tudjon meg többet, és szerezzen ingyenes NFT-t
20% kedvezménnyel a SimpleX Tokenre!", + "index-roadmap-h2": "A SimpleX ütemterve a szabad internethez", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Skálázódás nagy közösségekre", + "index-roadmap-2025-desc": "Központosított platformok elhagyása", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Fenntartható közösségek és kiszolgálók", + "index-roadmap-2026-desc": "Közösségi utalványok elindítása", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Növelje közösségeit", + "index-roadmap-2027-desc": "Eszközök a közösségei népszerűsítéséhez", + "index-directory-h2": "Csatlakozzon a SimpleX közösségekhez", + "index-directory-p1": "Már emberek százezrei bíznak a SimpleXen való üzenetváltásban.", + "index-directory-p2": "Találja meg kedvenc közösségeit a SimpleX csoportjegyzékében, vagy hozza létre a sajátját!", + "index-directory-cta": "SimpleX csoportjegyzék megtekintése", + "index-directory-users-group-title": "SimpleX felhasználók csoportja", + "how-secure-comparison-title": "Mennyire biztonságos a végpontok közötti titkosítás a különböző üzenetváltó alkalmazásokban?", + "how-secure-message-padding": "Üzenetkitöltés", + "how-secure-repudiation-deniability": "Letagadhatóság", + "how-secure-forward-secrecy": "Kompromittálás előtti titkosságvédelem", + "how-secure-break-in-recovery": "Kompromittálás utáni biztonság", + "how-secure-two-factor-key-exchange": "Kétlépcsős kulcscsere", + "how-secure-post-quantum-hybrid-crypto": "Hibrid kriptográfia a kvantum számítógépek megjelenése utánra", + "messengers-comparison-section-list-point-1": "A Briar 1024 bájtra, a Signal pedig 160 bájtra kerekítve tölti fel az üzeneteket", + "messengers-comparison-section-list-point-2": "A letagadhatóság nem foglalja magába a kliens és a kiszolgáló közötti kapcsolatot.", + "messengers-comparison-section-list-point-3": "Úgy tűnik, hogy a kriptográfiai aláírások használata rontja a letagadhatóságot, de ez tisztázásra szorul.", + "messengers-comparison-section-list-point-4": "A többeszközös megvalósítás rontja a dupla racsni kompromittálás utáni biztonságát", + "messengers-comparison-section-list-point-5": "A kétlépcsős kulcscsere nem követelmény a biztonsági kód ellenőrzéséhez.", + "messengers-comparison-section-list-point-6": "A posztkvantum kulcscsere „ritka” — csak a racsnis lépések egy részét védi." } diff --git a/website/langs/id.json b/website/langs/id.json index 0967ef424b..51bd14f976 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -1 +1,315 @@ -{} +{ + "home": "Beranda", + "directory": "Direktori", + "developers": "Pengembang", + "reference": "Referensi", + "blog": "Blog", + "features": "Fitur", + "why-simplex": "Kenapa SimpleX", + "simplex-privacy": "Privasi SimpleX", + "simplex-network": "Jaringan SimpleX", + "simplex-explained": "SimpleX dijelaskan", + "donate": "Donasi", + "hero-overlay-2-textlink": "Bagaimana cara kerja SimpleX?", + "feature-5-title": "Pesan menghilang", + "hero-overlay-1-title": "Bagaimana cara kerja SimpleX?", + "terms-and-privacy-policy": "Kebijakan Privasi", + "about-and-contact-us": "Tentang & Hubungi Kami", + "feature-2-title": "Gambar, video, dan berkas
terenkripsi E2E", + "feature-4-title": "Pesan suara terenkripsi E2E", + "feature-6-title": "Panggilan suara dan video
terenkripsi E2E", + "hero-2-header": "Buat koneksi pribadi", + "simplex-private-1-title": "2 lapisan
enkripsi end-to-end", + "simplex-private-card-4-point-1": "Untuk melindungi alamat IP Anda, Anda dapat mengakses server melalui Tor atau lapisan jaringan transport lainnya.", + "simplex-unique-3-overlay-1-title": "Kepemilikan, kontrol, dan keamanan data Anda", + "simplex-private-card-10-point-2": "Mengirim pesan tanpa pengenal profil pengguna, menyediakan privasi meta-data yang lebih baik daripada alternatif lain.", + "terminal-cli": "Terminal CLI", + "hero-overlay-3-textlink": "Penilaian keamanan", + "chat-protocol": "Protokol obrolan", + "simplex-explained-tab-1-text": "1. Apa pengalaman pengguna", + "simplex-explained-tab-2-text": "2. Bagaimana cara kerjanya", + "simplex-chat-protocol": "Protokol SimpleX Chat", + "hero-overlay-2-title": "Mengapa ID pengguna buruk untuk privasi?", + "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", + "simplex-explained-tab-3-text": "3. Apa yang dilihat server", + "smp-protocol": "Protokol SMP", + "please-use-link-in-mobile-app": "Mohon gunakan tautan di aplikasi seluler", + "docs-dropdown-12": "Keamanan", + "please-enable-javascript": "Harap aktifkan JavaScript untuk melihat kode QR.", + "jobs": "Gabung tim", + "docs-dropdown-14": "SimpleX untuk bisnis", + "guide-dropdown-8": "Pengaturan aplikasi", + "guide-dropdown-9": "Membuat koneksi", + "guide-dropdown-6": "Panggilan Suara & Video", + "guide-dropdown-7": "Privasi & keamanan", + "guide-dropdown-5": "Mengelola data", + "guide-dropdown-4": "Profil obrolan", + "docs-dropdown-9": "Unduhan", + "docs-dropdown-11": "FAQ", + "back-to-top": "Kembali ke atas", + "docs-dropdown-10": "Transparansi", + "click-to-see": "Ketuk untuk lihat", + "guide": "Panduan", + "docs-dropdown-1": "Jaringan SimpleX", + "privacy-matters-3-overlay-1-title": "Privasi melindungi kebebasan Anda", + "privacy-matters-1-overlay-1-title": "Privasi menghemat uang Anda", + "privacy-matters-1-overlay-1-linkText": "Privasi menghemat uang Anda", + "simplex-unique-3-title": "Data Anda kendali Anda", + "privacy-matters-3-overlay-1-linkText": "Privasi melindungi kebebasan Anda", + "privacy-matters-1-title": "Privasi melindungi kebebasan Anda", + "simplex-unique-1-title": "Anda memiliki privasi penuh", + "simplex-unique-4-title": "Anda memiliki jaringan SimpleX", + "simplex-unique-4-overlay-1-title": "Sepenuhnya terdesentralisasi — pengguna memiliki jaringan SimpleX", + "hero-overlay-card-1-p-2": "Untuk mengirim pesan, alih-alih ID pengguna yang digunakan oleh semua jaringan lain, SimpleX menggunakan pengenal bersifat anonim sementara dari antrean pesan, terpisah untuk setiap koneksi — tidak ada pengenal jangka panjang.", + "hero-overlay-card-1-p-1": "Banyak pengguna bertanya: jika SimpleX tidak ada ID pengguna, bagaimana itu mengetahui ke mana pesan dikirim?", + "sign-up-to-receive-our-updates": "Daftar untuk menerima pembaruan kami", + "enter-your-email-address": "Masukkan alamat email Anda", + "learn-more": "Lebih lanjut", + "more-info": "Info lanjut", + "hide-info": "Tutup info", + "contact-hero-header": "Anda menerima alamat untuk terhubung di SimpleX Chat", + "invitation-hero-header": "Anda menerima tautan 1 kali untuk terhubung di SimpleX Chat", + "simplex-explained-tab-1-p-1": "Anda dapat membuat kontak dan grup, dan melakukan percakapan dua arah, seperti pada aplikasi perpesanan lainnya.", + "simplex-explained-tab-1-p-2": "Bagaimana cara kerjanya dengan antrean searah dan tanpa ID profil pengguna?", + "simplex-explained-tab-2-p-1": "Untuk setiap koneksi, Anda menggunakan dua antrean pesan terpisah untuk mengirim dan menerima pesan melalui server yang berbeda.", + "simplex-explained-tab-2-p-2": "Server hanya menyampaikan pesan satu arah, tanpa memiliki gambaran lengkap mengenai percakapan atau koneksi pengguna.", + "simplex-explained-tab-3-p-1": "Server memiliki kredensial anonim terpisah untuk setiap antrean, dan tidak mengetahui pengguna mana yang menjadi milik mereka.", + "simplex-explained-tab-3-p-2": "Pengguna dapat tingkatkan privasi metadata dengan memakai Tor untuk akses server, mencegah korelasi berdasarkan alamat IP.", + "chat-bot-example": "Contoh chat bot", + "hero-header": "Privasi diredefinisikan", + "hero-subheader": "Perpesanan pertama
tanpa ID pengguna", + "hero-p-1": "Aplikasi lain memiliki ID pengguna: Signal, Matrix, Session, Briar, Jami, Cwtch, dll.
SimpleX tidak, bahkan angka acak.
Ini secara radikal meningkatkan privasi Anda.", + "hero-overlay-1-textlink": "Mengapa ID pengguna buruk untuk privasi?", + "hero-2-header-desc": "Video ini menunjukkan cara terhubung dengan teman Anda melalui kode QR sekali pakai, secara langsung, atau melalui tautan video. Anda juga dapat terhubung dengan bagikan tautan undangan.", + "simplex-network-overlay-1-title": "Perbandingan dengan protokol perpesanan P2P", + "hero-overlay-3-title": "Penilaian keamanan", + "simplex-private-2-title": "Lapisan enkripsi
tambahan server", + "feature-1-title": "Pesan terenkripsi E2E dengan markdown dan pengeditan", + "feature-3-title": "Grup terenkripsi E2E yang terdesentralisasi — hanya pengguna yang tahu keberadaannya", + "docs-dropdown-6": "Server WebRTC", + "docs-dropdown-7": "Terjemahkan SimpleX Chat", + "docs-dropdown-2": "Mengakses berkas Android", + "guide-dropdown-1": "Mulai cepat", + "no": "Tidak", + "no-private": "Tidak - privat", + "no-secure": "Tidak - aman", + "no-resilient": "Tidak - tangguh", + "no-decentralized": "Tidak - terdesentralisasi", + "no-federated": "Tidak - terfederasi", + "comparison-section-list-point-1": "Biasanya berdasarkan nomor telepon, dalam beberapa kasus berdasarkan nama pengguna", + "protocol-2-text": "XMPP, Matrix", + "protocol-3-text": "Protokol P2P", + "comparison-point-1-text": "Membutuhkan identitas global", + "comparison-point-2-text": "Kemungkinan MITM", + "simplex-network-3-header": "Jaringan SimpleX", + "simplex-chat-for-the-terminal": "SimpleX Chat untuk terminal", + "copy-the-command-below-text": "salin perintah di bawah ini dan gunakan dalam obrolan:", + "protocol-1-text": "Signal, platform besar", + "guide-dropdown-2": "Mengirim pesan", + "guide-dropdown-3": "Grup rahasia", + "comparison-section-list-point-2": "Alamat berbasis DNS", + "comparison-section-list-point-3": "Kunci publik atau ID unik global lainnya", + "feature-7-title": "Penyimpanan aplikasi terenkripsi portabel — pindahkan profil ke perangkat lain", + "feature-8-title": "Mode penyamaran —
unik untuk SimpleX Chat", + "simplex-private-3-title": "Transport TLS terautentikasi
yang aman", + "simplex-private-4-title": "Akses opsional
via Tor", + "simplex-private-5-title": "Beberapa lapisan
kuat konten", + "simplex-private-6-title": "Pertukaran kunci
out-of-band", + "simplex-private-7-title": "Verifikasi integritas
pesan", + "simplex-private-8-title": "Campuran pesan
untuk kurangi korelasi", + "simplex-private-9-title": "Antrean pesan
searah", + "simplex-private-10-title": "Pengidentifikasi berpasangan anonim sementara", + "simplex-private-card-1-point-1": "Protokol double-ratchet —
Pesan OTR dengan perfect forward secrecy dan pemulihan pembobolan.", + "simplex-private-card-9-point-2": "Ini mengurangi vektor serangan, dibandingkan dengan perantara pesan tradisional, dan meta-data yang ada.", + "simplex-private-card-10-point-1": "SimpleX menggunakan alamat terhubung anonim sementara dan kredensial untuk setiap kontak pengguna atau anggota grup.", + "simplex-private-card-3-point-1": "Hanya TLS 1.2/1.3 dengan algoritma kuat yang digunakan untuk koneksi client-server.", + "simplex-private-card-3-point-2": "Sidikjari server dan pengikatan saluran mencegah serangan MITM secara berulang.", + "simplex-private-card-3-point-3": "Pemulihan koneksi dinonaktifkan untuk mencegah serangan sesi.", + "simplex-private-card-4-point-2": "Untuk menggunakan SimpleX melalui Tor, silakan instal aplikasi Orbot dan aktifkan proksi SOCKS5 (atau VPN di iOS).", + "simplex-private-card-5-point-1": "SimpleX menggunakan pengunci konten untuk setiap lapisan enkripsi guna menggagalkan serangan pesan.", + "simplex-private-card-5-point-2": "Ini membuat pesan dengan ukuran berbeda terlihat sama bagi server dan pengamat jaringan.", + "simplex-private-card-6-point-1": "Banyak jaringan komunikasi yang rentan terhadap serangan MITM oleh server atau penyedia jaringan.", + "simplex-private-card-6-point-2": "Untuk mencegahnya, aplikasi SimpleX meneruskan kunci 1-kali di luar jalur, saat bagikan alamat sebagai tautan atau kode QR.", + "simplex-unique-2-overlay-1-title": "Perlindungan terbaik dari spam dan penyalahgunaan", + "simplex-unique-2-title": "Anda terlindungi dari
spam dan penyalahgunaan", + "privacy-matters-2-overlay-1-title": "Privasi memberi Anda kekuatan", + "privacy-matters-2-overlay-1-linkText": "Privasi memberi Anda kekuatan", + "privacy-matters-3-title": "Penuntutan karena keterlibatan yang tidak bersalah", + "simplex-unique-1-overlay-1-title": "Privasi penuh atas identitas, profil, kontak, dan metadata Anda", + "privacy-matters-2-title": "Manipulasi pemilihan", + "simplex-private-card-7-point-1": "Untuk menjamin integritas, pesan diberi nomor urut dan menyertakan hash dari pesan sebelumnya.", + "simplex-private-card-7-point-2": "Jika ada pesan yang ditambah, dihapus atau diubah, penerima akan diberi tahu.", + "simplex-private-card-8-point-1": "Server SimpleX bertindak sebagai node campuran latensi rendah — pesan masuk dan keluar memiliki urutan yang berbeda.", + "simplex-private-card-9-point-1": "Setiap antrean pesan meneruskan pesan dalam satu arah, dengan alamat kirim dan terima yang berbeda.", + "simplex-private-card-1-point-2": "Kotak kripto NaCL di setiap antrean untuk mencegah korelasi lalu lintas antara antrean pesan jika TLS disusupi.", + "simplex-private-card-2-point-1": "Lapisan enkripsi server tambahan untuk pengiriman ke penerima, untuk mencegah korelasi antara lalu lintas server yang diterima dan dikirim jika TLS disusupi.", + "hero-overlay-card-1-p-5": "Hanya perangkat klien yang menyimpan profil pengguna, kontak, dan grup; pesan dikirim dengan enkripsi end-to-end 2 lapis.", + "hero-overlay-card-1-p-6": "Selengkapnya di Whitepaper SimpleX.", + "hero-overlay-card-2-p-1": "Bila pengguna memiliki identitas persisten, meskipun ini hanya angka acak, seperti ID Sesi, ada risiko bahwa penyedia atau penyerang dapat mengamati bagaimana pengguna terhubung dan berapa banyak pesan yang mereka kirim.", + "hero-overlay-card-2-p-2": "Mereka kemudian dapat menghubungkan informasi ini dengan jaringan sosial publik yang ada, dan menentukan beberapa identitas sebenarnya.", + "hero-overlay-card-2-p-3": "Bahkan dengan aplikasi paling pribadi yang menggunakan layanan Tor v3, jika Anda berbicara dengan dua kontak berbeda melalui profil yang sama, mereka dapat membuktikan bahwa mereka terhubung dengan orang yang sama.", + "hero-overlay-card-1-p-3": "Anda menentukan server mana yang akan digunakan untuk menerima pesan, dan kontak Anda — server yang Anda gunakan untuk mengirimkan pesan kepada mereka. Setiap percakapan kemungkinan akan menggunakan dua server yang berbeda.", + "hero-overlay-card-1-p-4": "Desain ini mencegah kebocoran metadata pengguna di tingkat aplikasi. Untuk meningkatkan privasi dan melindungi alamat IP Anda, Anda dapat terhubung ke server perpesanan melalui Tor.", + "join": "Gabung", + "we-invite-you-to-join-the-conversation": "Kami mengundang Anda untuk gabung ke percakapan", + "join-the-REDDIT-community": "Bergabung dengan komunitas REDDIT", + "join-us-on-GitHub": "Gabung dengan kami di GitHub", + "donate-here-to-help-us": "Donasi untuk bantu kami", + "why-simplex-is-unique": "Mengapa SimpleX unik", + "simplex-unique-card-1-p-2": "Tidak seperti jaringan perpesanan lain yang ada, SimpleX tidak memiliki ID tetap kepada pengguna — bahkan nomor acak.", + "simplex-unique-card-2-p-1": "Karena Anda tidak memiliki ID atau alamat tetap di jaringan SimpleX, tidak seorang pun dapat menghubungi Anda kecuali Anda membagikan alamat pengguna 1-kali atau sementara, seperti kode QR atau tautan.", + "simplex-unique-card-3-p-1": "SimpleX menyimpan semua data pengguna pada perangkat klien dalam format basis data terenkripsi portabel — data tersebut dapat ditransfer ke perangkat lain.", + "simplex-unique-card-3-p-2": "Pesan yang dienkripsi end-to-end disimpan sementara di server relay SimpleX hingga diterima, lalu dihapus secara permanen.", + "simplex-unique-card-4-p-1": "Jaringan SimpleX sepenuhnya terdesentralisasi dan independen dari crypto-currency atau jaringan lain apa pun, selain Internet.", + "simplex-unique-card-4-p-2": "Anda dapat menggunakan SimpleX dengan server Anda sendiri atau dengan server yang disediakan oleh kami — dan tetap terhubung dengan pengguna mana pun.", + "contact-hero-subheader": "Pindai kode QR dengan aplikasi SimpleX Chat di ponsel atau tablet Anda.", + "contact-hero-p-1": "Kunci publik dan alamat antrean pesan dalam tautan ini TIDAK dikirimkan melalui jaringan saat Anda melihat halaman ini — kunci tersebut terdapat dalam fragmen hash URL tautan.", + "contact-hero-p-2": "Belum unduh SimpleX Chat?", + "contact-hero-p-3": "Gunakan tautan di bawah untuk unduh aplikasi.", + "scan-qr-code-from-mobile-app": "Pindai kode QR dari aplikasi seluler", + "to-make-a-connection": "Untuk membuat koneksi:", + "install-simplex-app": "Instal aplikasi SimpleX", + "connect-in-app": "Hubungkan di aplikasi", + "open-simplex-app": "Buka aplikasi SimpleX", + "tap-the-connect-button-in-the-app": "Ketuk ‘hubungkan’ di aplikasi", + "scan-the-qr-code-with-the-simplex-chat-app": "Pindai kode QR dengan aplikasi SimpleX Chat", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Kunci publik dan alamat antrean pesan dalam tautan ini TIDAK dikirim melalui jaringan saat Anda melihat halaman ini —
keduanya terdapat dalam fragmen hash URL tautan.", + "installing-simplex-chat-to-terminal": "Menginstal SimpleX Chat ke terminal", + "use-this-command": "Gunakan perintah ini:", + "see-simplex-chat": "Lihat SimpleX Chat", + "github-repository": "Repositori GitHub", + "the-instructions--source-code": "untuk petunjuk cara mengunduh atau mengompilasinya dari kode sumber.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Jika Anda sudah menginstal SimpleX Chat untuk terminal", + "if-you-already-installed": "Jika Anda sudah menginstal", + "privacy-matters-section-header": "Mengapa privasi penting", + "privacy-matters-section-subheader": "Menjaga privasi metadata Anda — dengan siapa Anda berbicara — melindungi Anda dari:", + "privacy-matters-section-label": "Pastikan messenger Anda tidak dapat mengakses data Anda!", + "simplex-private-section-header": "Apa yang membuat SimpleX privat", + "tap-to-close": "Ketuk untuk tutup", + "simplex-network-section-header": "Jaringan SimpleX", + "simplex-network-section-desc": "SimpleX Chat memberikan privasi terbaik dengan menggabungkan keunggulan P2P dan jaringan terfederasi.", + "simplex-network-1-header": "Tidak seperti jaringan P2P", + "simplex-network-1-desc": "Semua pesan dikirim melalui server, keduanya memberikan privasi metadata yang lebih baik dan pengiriman pesan asinkron yang andal, sekaligus menghindari banyak", + "simplex-network-1-overlay-linktext": "masalah jaringan P2P", + "simplex-network-2-header": "Tidak seperti jaringan terfederasi", + "simplex-network-2-desc": "Server relay SimpleX TIDAK menyimpan profil pengguna, kontak dan pesan yang terkirim, TIDAK terhubung satu sama lain, dan TIDAK ada direktori server.", + "simplex-network-3-desc": "server menyediakan antrian searah untuk hubungkan pengguna, tetapi mereka tidak dapat melihat grafik koneksi jaringan — hanya pengguna yang dapat melihatnya.", + "comparison-section-header": "Perbandingan dengan protokol lain", + "comparison-point-3-text": "Ketergantungan pada DNS", + "comparison-point-4-text": "Jaringan tunggal atau terpusat", + "comparison-point-5-text": "Komponen pusat atau serangan di seluruh jaringan lainnya", + "yes": "Ya", + "see-here": "lihat disini", + "comparison-section-list-point-4a": "Relay SimpleX tidak dapat membahayakan enkripsi e2e. Verifikasi kode keamanan untuk memitigasi serangan pada saluran out-of-band", + "comparison-section-list-point-4": "Jika server operator disusupi. Verifikasi kode keamanan di Signal dan beberapa aplikasi lain untuk mengatasinya", + "comparison-section-list-point-5": "Tidak melindungi privasi metadata pengguna", + "comparison-section-list-point-6": "Meskipun P2P didistribusikan, mereka tidak terfederasi — mereka beroperasi sebagai jaringan tunggal", + "comparison-section-list-point-7": "Jaringan P2P memiliki otoritas pusat atau seluruh jaringan dapat terkompromi", + "docs-dropdown-3": "Mengakses database obrolan", + "docs-dropdown-4": "Host Server SMP", + "docs-dropdown-5": "Host Server XFTP", + "docs-dropdown-8": "Direktori SimpleX", + "newer-version-of-eng-msg": "Ada versi terbaru halaman ini dalam bahasa Inggris.", + "menu": "Menu", + "on-this-page": "Di halaman ini", + "glossary": "Glosarium", + "simplex-chat-via-f-droid": "SimpleX Chat melalui F-Droid", + "simplex-chat-repo": "Repo SimpleX Chat", + "stable-and-beta-versions-built-by-developers": "Versi stable dan beta yang dibuat oleh pengembang", + "f-droid-page-simplex-chat-repo-section-text": "Untuk menambahkannya ke klien F-Droid Anda, pindai kode QR atau gunakan URL ini:", + "signing-key-fingerprint": "Penandatanganan sidikjari kunci (SHA-256)", + "f-droid-org-repo": "Repo F-Droid.org", + "stable-versions-built-by-f-droid-org": "Versi stable yang dibuat oleh F-Droid.org", + "releases-to-this-repo-are-done-1-2-days-later": "Rilisan ke repo ini dilakukan beberapa hari kemudian", + "f-droid-page-f-droid-org-repo-section-text": "Repositori SimpleX Chat dan F-Droid.org menandatangani build dengan kunci berbeda. Untuk beralih, silakan ekspor basis data obrolan dan instal ulang aplikasi.", + "hero-overlay-card-2-p-4": "SimpleX melindungi dari serangan ini dengan tidak memiliki ID pengguna dalam desainnya. Dan, jika Anda gunakan mode Samaran, Anda akan memiliki nama tampilan berbeda untuk setiap kontak, sehingga mencegah data dibagikan di antara mereka.", + "hero-overlay-card-3-p-1": "Trail of Bits adalah konsultan keamanan dan teknologi terkemuka yang kliennya meliputi perusahaan teknologi besar, lembaga pemerintah, dan proyek blockchain besar.", + "hero-overlay-card-3-p-2": "Trail of Bits meninjau kriptografi jaringan SimpleX dan komponen jaringan pada November 2022. Baca selengkapnya.", + "hero-overlay-card-3-p-3": "Trail of Bits mengulas desain kriptografi protokol jaringan SimpleX pada Juli 2024. Baca selengkapnya.", + "simplex-network-overlay-card-1-p-1": "Protokol dan aplikasi perpesanan P2P memiliki berbagai masalah yang membuatnya kurang dapat diandalkan dibandingkan SimpleX, lebih rumit untuk dianalisis, dan rentan terhadap beberapa jenis serangan.", + "simplex-network-overlay-card-1-li-1": "Jaringan P2P andalkan beberapa varian DHT untuk merutekan pesan. Desain DHT harus menyeimbangkan jaminan pengiriman dan latensi. SimpleX memiliki jaminan pengiriman lebih baik dan latensi yang lebih rendah daripada P2P, karena pesan dapat diteruskan secara redundan melalui beberapa server secara paralel, menggunakan server yang dipilih oleh penerima. Dalam jaringan P2P, pesan diteruskan melalui node O(log N) secara berurutan, menggunakan node yang dipilih oleh algoritma.", + "simplex-network-overlay-card-1-li-2": "Desain SimpleX, tidak seperti kebanyakan jaringan P2P, tidak memiliki ID pengguna global apa pun, bahkan yang sementara, dan hanya menggunakan pengenal penghubung sementara, sehingga memberikan anonimitas dan perlindungan metadata yang lebih baik.", + "simplex-network-overlay-card-1-li-3": "P2P tidak menyelesaikan masalah serangan MITM, dan sebagian besar implementasi yang ada tidak menggunakan pesan out-of-band untuk pertukaran kunci awal. SimpleX menggunakan pesan out-of-band atau, dalam beberapa kasus, koneksi aman dan tepercaya yang sudah ada sebelumnya untuk pertukaran kunci awal.", + "simplex-network-overlay-card-1-li-4": "Implementasi P2P dapat diblokir oleh beberapa penyedia internet (seperti BitTorrent). SimpleX bersifat agnostik transportasi — ia dapat bekerja melalui protokol web standar, misalnya WebSockets.", + "simplex-network-overlay-card-1-li-5": "Semua jaringan P2P yang diketahui mungkin rentan terhadap serangan Sybil, karena setiap node dapat ditemukan, dan jaringan beroperasi secara keseluruhan. Langkah-langkah yang diketahui untuk memitigasinya memerlukan komponen terpusat atau Bukti kerja yang mahal. Jaringan SimpleX tidak memiliki kemampuan untuk ditemukan oleh server, terfragmentasi, dan beroperasi sebagai beberapa sub-jaringan yang terisolasi, sehingga mustahil untuk menyerang seluruh jaringan.", + "simplex-network-overlay-card-1-li-6": "Jaringan P2P mungkin rentan terhadap serangan DRDoS, ketika klien dapat menyiarkan ulang dan memperkuat lalu lintas, yang mengakibatkan penolakan layanan di seluruh jaringan. Klien SimpleX hanya meneruskan lalu lintas dari koneksi yang diketahui dan tidak dapat digunakan oleh penyerang untuk memperkuat lalu lintas di seluruh jaringan.", + "privacy-matters-overlay-card-1-p-1": "Banyak perusahaan besar menggunakan informasi mengenai siapa yang terhubung dengan Anda untuk memperkirakan pendapatan Anda, menjual produk yang sebenarnya tidak Anda butuhkan, dan menentukan harga.", + "privacy-matters-overlay-card-1-p-2": "Pengecer daring mengetahui bahwa orang-orang berpenghasilan rendah cenderung melakukan pembelian mendesak, sehingga mereka mungkin mengenakan harga lebih tinggi atau menghapus diskon.", + "privacy-matters-overlay-card-1-p-3": "Beberapa perusahaan keuangan dan asuransi menggunakan grafik sosial untuk menentukan suku bunga dan premium. Hal ini seringkali membuat orang berpenghasilan rendah membayar lebih banyak — dikenal sebagai 'premium kemelaratan'.", + "privacy-matters-overlay-card-1-p-4": "Jaringan SimpleX melindungi privasi koneksi Anda lebih baik daripada alternatif lainnya, sepenuhnya mencegah grafik sosial Anda diakses oleh perusahaan atau organisasi mana pun. Bahkan ketika orang menggunakan server yang telah dikonfigurasi sebelumnya di aplikasi SimpleX Chat, operator server tidak mengetahui jumlah pengguna atau koneksi mereka.", + "privacy-matters-overlay-card-2-p-1": "Belum lama ini kita menyaksikan pemilu besar dimanipulasi oleh sebuah perusahaan konsultan terkemuka yang menggunakan grafik sosial kita untuk mendistorsi pandangan kita terhadap dunia nyata dan memanipulasi suara kita.", + "privacy-matters-overlay-card-2-p-2": "Agar objektif dan dapat membuat keputusan yang independen, Anda perlu mengendalikan ruang informasi Anda. Hal ini hanya mungkin jika Anda menggunakan jaringan komunikasi pribadi yang tidak memiliki akses ke grafik sosial Anda.", + "privacy-matters-overlay-card-2-p-3": "SimpleX adalah jaringan pertama yang tidak memiliki ID pengguna apa pun secara desain, dengan cara ini melindungi grafik koneksi Anda lebih baik daripada alternatif yang diketahui.", + "privacy-matters-overlay-card-3-p-1": "Setiap orang harus peduli dengan privasi dan keamanan komunikasi mereka — percakapan yang tidak berbahaya dapat membahayakan Anda, bahkan jika Anda tidak menyembunyikan apa pun.", + "privacy-matters-overlay-card-3-p-2": "Salah satu kisah paling mengejutkan adalah pengalaman Mohamedou Ould Salahi yang diceritakan dalam memoarnya dan ditampilkan dalam film The Mauritanian. Ia dimasukkan ke kamp Guantanamo tanpa diadili, dan disiksa di sana selama 15 tahun setelah menelepon kerabatnya di Afghanistan, karena dicurigai terlibat dalam serangan 9/11, meskipun ia pernah tinggal di Jerman selama 10 tahun sebelumnya.", + "privacy-matters-overlay-card-3-p-3": "Orang biasa ditangkap karena apa yang mereka bagikan secara daring, bahkan melalui akun 'anonim' mereka, bahkan di negara demokrasi.", + "privacy-matters-overlay-card-3-p-4": "Tidaklah cukup jika hanya menggunakan layanan pesan terenkripsi end-to-end, kita semua harus menggunakan layanan pesan yang melindungi privasi jaringan pribadi kita — dengan siapa kita terhubung.", + "simplex-unique-overlay-card-1-p-1": "Berbeda dengan jaringan perpesanan lainnya, SimpleX tidak memiliki ID tetap kepada pengguna. SimpleX tidak bergantung pada nomor telepon, alamat berbasis domain (seperti email atau XMPP), nama pengguna, kunci publik, atau bahkan angka acak untuk mengidentifikasi penggunanya — operator server SimpleX tidak tahu berapa banyak orang yang menggunakan server mereka.", + "simplex-unique-overlay-card-1-p-2": "Untuk menyampaikan pesan, SimpleX menggunakan alamat anonim penghubung dari antrean pesan searah, terpisah untuk pesan yang diterima dan dikirim, biasanya melalui server yang berbeda.", + "simplex-unique-overlay-card-1-p-3": "Desain ini melindungi privasi orang yang Anda ajak berkomunikasi, menyembunyikannya dari server jaringan SimpleX dan dari pengamat mana pun. Untuk menyembunyikan alamat IP Anda dari server, Anda dapat terhubung ke server SimpleX melalui Tor.", + "simplex-unique-overlay-card-2-p-1": "Karena Anda tidak memiliki ID di jaringan SimpleX, tidak seorang pun dapat menghubungi Anda kecuali Anda membagikan alamat pengguna 1-kali atau sementara, seperti kode QR atau tautan.", + "simplex-unique-overlay-card-2-p-2": "Bahkan dengan alamat pengguna opsional, meskipun dapat digunakan untuk mengirim permintaan kontak spam, Anda dapat mengubah atau menghapusnya sepenuhnya tanpa kehilangan koneksi apa pun.", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat menyimpan semua data pengguna hanya pada perangkat klien menggunakan format basis data terenkripsi portabel yang dapat diekspor dan ditransfer ke perangkat apa pun yang didukung.", + "simplex-unique-overlay-card-3-p-2": "Pesan yang dienkripsi end-to-end disimpan sementara di server relay SimpleX hingga diterima, lalu dihapus secara permanen.", + "simplex-unique-overlay-card-3-p-3": "Tidak seperti server jaringan terfederasi (email, XMPP atau Matrix), server SimpleX tidak menyimpan akun pengguna, mereka hanya menyampaikan pesan, melindungi privasi kedua belah pihak.", + "simplex-unique-overlay-card-3-p-4": "Tidak ada pengenal atau teks sandi yang sama antara lalu lintas server yang dikirim dan diterima — jika ada orang yang mengamatinya, mereka tidak dapat dengan mudah menentukan siapa yang berkomunikasi dengan siapa, bahkan jika TLS disusupi.", + "simplex-unique-overlay-card-4-p-1": "Anda dapat menggunakan SimpleX dengan server Anda sendiri dan tetap berkomunikasi dengan orang yang menggunakan server yang dikonfigurasikan sebelumnya dalam aplikasi.", + "simplex-unique-overlay-card-4-p-2": "Jaringan SimpleX menggunakan protokol terbuka dan menyediakan SDK untuk membuat bot obrolan, yang memungkinkan penerapan layanan yang dapat berinteraksi dengan pengguna melalui aplikasi SimpleX Chat — kami benar-benar menantikan untuk melihat layanan SimpleX apa yang akan Anda buat.", + "simplex-unique-overlay-card-4-p-3": "Jika Anda mempertimbangkan pengembangan untuk jaringan SimpleX, misalnya, bot obrolan untuk pengguna aplikasi SimpleX, atau integrasi pustaka Obrolan SimpleX ke dalam aplikasi seluler Anda, silakan hubungi kami untuk saran dan dukungan apa pun.", + "simplex-unique-card-1-p-1": "SimpleX melindungi privasi profil, kontak, dan metadata Anda, menyembunyikannya dari server jaringan SimpleX dan pengamat mana pun.", + "get-simplex": "Dapatkan aplikasi desktop SimpleX", + "index-hero-h1": "Bebaslah", + "index-hero-h2": "Kebebasan & Keamanan
Komunikasi Anda", + "index-hero-p1": "Jaringan pertama tempat Anda memiliki identitas, kontak, dan grup Anda.", + "index-hero-download-desktop-btn-title": "Unduh Aplikasi Desktop SimpleX", + "index-testflight-title": "Pratinjau iOS publik di TestFlight", + "index-f-droid-title": "Repositori SimpleX F-Droid", + "index-security-assessment-title": "penilaian keamanan", + "index-security-review-2022-title": "Tinjauan Keamanan 2022", + "index-security-review-2024-title": "Tinjauan Keamanan 2024", + "index-security-audits-label": "Audit
Keamanan", + "index-publications-privacy-guides-title": "rekomendasi perpesanan", + "index-publications-whonix-title": "Rekomendasi perpesanan Whonix", + "index-publications-heise-title": "publikasi", + "index-publications-kuketz-title": "tinjauan", + "index-publications-optout-title": "wawancara podcast", + "worlds-most-secure-messaging": "Perpesanan Paling Aman di Dunia", + "index-messaging-p1": "Perpesanan SimpleX memiliki enkripsi end-to-end yang canggih.", + "index-messaging-p2": "Demi keamanan dan privasi Anda, server tidak dapat melihat pesan Anda atau dengan siapa Anda bicara.", + "index-messaging-cta": "Pelajari lebih lanjut tentang perpesanan SimpleX", + "index-nextweb-h2": "Anda Pemilik
Web Berikutnya", + "index-nextweb-p1": "SimpleX didirikan atas keyakinan bahwa Anda harus memiliki identitas, kontak, dan komunitas Anda sendiri.", + "index-nextweb-p2": "Jaringan yang terbuka dan terdesentralisasi membuat Anda terhubung dengan orang lain dan berbagi ide: bebas dan aman.", + "index-token-h2": "Komunitas yang Bertahan Lama", + "index-token-p1": "Anda akan mendukung grup favorit Anda dengan voucher Komunitas di masa mendatang.", + "index-token-p2": "Voucher akan membayar server, agar komunitas Anda tetap bebas dan independen.", + "index-token-cta": "Pelajari lebih lanjut tentang Voucher Komunitas", + "index-roadmap-h2": "SimpleX Roadmap Menuju Internet Bebas", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Skala ke Komunitas Besar", + "index-roadmap-2025-desc": "Menghindari platform terpusat", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Komunitas & Server Berkelanjutan", + "index-roadmap-2026-desc": "Perilisan Voucher Komunitas", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Buat Komunitas Anda Meningkat", + "index-roadmap-2027-desc": "Alat untuk promosikan komunitas Anda", + "index-directory-h2": "Gabung ke Komunitas SimpleX", + "index-directory-p1": "Ratusan ribu orang sudah memercayai perpesanan SimpleX.", + "index-directory-p2": "Temukan komunitas Anda di direktori SimpleX dan buat komunitas Anda sendiri!", + "index-directory-cta": "Lihat Direktori SimpleX", + "index-directory-users-group-title": "Grup pengguna SimpleX", + "how-secure-comparison-title": "Seberapa amankah enkripsi end-to-end di berbagai aplikasi perpesanan?", + "how-secure-message-padding": "Lapisan pesan", + "how-secure-repudiation-deniability": "Penolakan (penyangkalan)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Keamanan post-compromise", + "how-secure-two-factor-key-exchange": "Pertukaran kunci 2 faktor", + "how-secure-post-quantum-hybrid-crypto": "Gabungan kriptografi dan post-quantum", + "messengers-comparison-section-list-point-1": "Lapisan pesan ke ukuran yang dibulatkan hingga 1024 byte, Signal - hingga 160 byte", + "messengers-comparison-section-list-point-2": "Penolakan tidak mencakup koneksi klien-server.", + "messengers-comparison-section-list-point-3": "Tampaknya penggunaan signature kriptografi membahayakan penolakan (penyangkalan), tetapi hal ini perlu diperjelas.", + "messengers-comparison-section-list-point-4": "Implementasi multi-perangkat membahayakan keamanan post-compromise Double Ratchet", + "messengers-comparison-section-list-point-5": "Pertukaran kunci 2 faktor bersifat opsional via verifikasi kode keamanan.", + "messengers-comparison-section-list-point-6": "Kesepakatan kunci Post-quantum \"jarang\" — hanya melindungi beberapa langkah ratchet." +} diff --git a/website/langs/it.json b/website/langs/it.json index 7fdea37909..b7b11ccd72 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -237,7 +237,7 @@ "signing-key-fingerprint": "Impronta della chiave di firma (SHA-256)", "f-droid-org-repo": "Repo di F-Droid.org", "stable-versions-built-by-f-droid-org": "Versioni stabili compilate da F-Droid.org", - "docs-dropdown-8": "Servizio directory di SimpleX", + "docs-dropdown-8": "Directory di SimpleX", "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "Repo di SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versioni stabili e beta compilate dagli sviluppatori", @@ -255,5 +255,61 @@ "docs-dropdown-12": "Sicurezza", "docs-dropdown-11": "Domande frequenti", "hero-overlay-card-3-p-3": "Trail of Bits ha analizzato la progettazione crittografica dei protocolli della rete SimpleX nel luglio 2024. Leggi di più.", - "docs-dropdown-14": "SimpleX per il lavoro" + "docs-dropdown-14": "SimpleX per il lavoro", + "about-and-contact-us": "Informazioni e contatti", + "directory": "Directory", + "index-hero-h2": "Libertà e sicurezza
delle tue comunicazioni", + "index-hero-p1": "La prima rete in cui possiedi la tua identità, i contatti e i gruppi.", + "index-hero-download-desktop-btn-title": "Scarica l'app desktop di SimpleX", + "index-testflight-title": "Anteprima pubblica per iOS su TestFlight", + "index-f-droid-title": "Repository F-Droid di SimpleX", + "index-security-assessment-title": "valutazione della sicurezza", + "index-security-review-2022-title": "Revisione della sicurezza 2022", + "index-security-review-2024-title": "Revisione della sicurezza 2024", + "index-hero-h1": "Sii
libero", + "index-security-audits-label": "Analisi della
sicurezza", + "index-publications-privacy-guides-title": "consigli sulla messaggistica", + "index-publications-whonix-title": "consigli sulla messaggistica di Whonix", + "index-publications-heise-title": "pubblicazione", + "index-publications-kuketz-title": "revisione", + "index-publications-optout-title": "intervista podcast", + "worlds-most-secure-messaging": "La messaggistica più sicuro del mondo", + "index-messaging-p1": "SimpleX usa una crittografia end-to-end all'avanguardia.", + "index-messaging-p2": "Per la tua sicurezza e privacy, i server non possono vedere i messaggi o con chi parli.", + "index-messaging-cta": "Scopri di più sui messaggi di SimpleX", + "index-nextweb-h2": "Il nuovo web
è tuo", + "index-nextweb-p1": "SimpleX è fondato sulla convinzione che devi possedere la tua identità, i tuoi contatti e le tue comunità.", + "index-nextweb-p2": "La rete aperta e decentralizzata consente di connetterti con persone e condividere idee: sii libero e al sicuro.", + "index-token-h2": "Comunità che durano", + "index-token-p1": "Sosterrai i tuoi gruppi preferiti con futuri buoni comunitari.", + "index-token-p2": "I buoni pagheranno i server, per consentire alle tue comunità di rimanere libere e indipendenti.", + "index-token-cta": "Scopri di più e ricevi un NFT gratuito
per uno sconto del 20% su SimpleX Token!", + "index-roadmap-h2": "Tabella di marcia di SimpleX per un internet libero", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Scalabilità per comunità numerose", + "index-roadmap-2025-desc": "Fuga da piattaforme centralizzate", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Comunità e server sostenibili", + "index-roadmap-2026-desc": "Pubblicazione di buoni comunitari", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Fare crescere le tue comunità", + "index-roadmap-2027-desc": "Strumenti per promuovere le tue comunità", + "index-directory-h2": "Entra nelle comunità di SimpleX", + "index-directory-p1": "Centinaia di migliaia di persone si fidano già di SimpleX.", + "index-directory-p2": "Trova le comunità nella directory di SimpleX e creane una tua!", + "index-directory-cta": "Vedi la directory di SimpleX", + "index-directory-users-group-title": "Gruppo utenti SimpleX", + "how-secure-comparison-title": "Quanto è sicura la crittografia end-to-end in diverse app di messaggistica?", + "how-secure-message-padding": "Riempimento dei messaggi", + "how-secure-repudiation-deniability": "Ripudio (negabilità)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Sicurezza post-compromissione", + "how-secure-two-factor-key-exchange": "Scambio di chiavi a 2 fattori", + "how-secure-post-quantum-hybrid-crypto": "Crittografia ibrida quantistica", + "messengers-comparison-section-list-point-1": "Briar riempie i messaggi ad una dimensione arrotondata fino a 1024 byte, Signal a 160 byte", + "messengers-comparison-section-list-point-2": "Il ripudio non include la connessione client-server.", + "messengers-comparison-section-list-point-3": "Sembra che l'uso di firme crittografiche comprometta il ripudio (negabilità), ma occorrono chiarimenti.", + "messengers-comparison-section-list-point-4": "L'implementazione multi-dispositivo compromette la sicurezza post-compromissione di Double Ratchet", + "messengers-comparison-section-list-point-5": "Lo scambio di chiavi a 2 fattori è facoltativo tramite la verifica del codice di sicurezza.", + "messengers-comparison-section-list-point-6": "L'accordo sulle chiavi post-quantistico è “scarno” — protegge solo alcuni dei passaggi del ratchet." } diff --git a/website/langs/nl.json b/website/langs/nl.json index a6a0a740ae..1cdffc6ae5 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -241,7 +241,7 @@ "stable-versions-built-by-f-droid-org": "Stabiele versies gebouwd door F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "De releases voor deze repository vinden enkele dagen later plaats", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- en F-Droid.org-repository's ondertekenen builds met de verschillende sleutels. Om over te stappen, alstublieft exporteer de chatdatabase en installeer de app opnieuw.", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken", "hero-overlay-3-title": "Beveiligings beoordeling", "hero-overlay-card-3-p-2": "Trail of Bits heeft in november 2022 de cryptografie en netwerkcomponenten van het SimpleX-platform beoordeeld. Lees meer in de aankondiging.", @@ -255,5 +255,7 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Beveiliging", "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer.", - "docs-dropdown-14": "SimpleX voor bedrijven" + "docs-dropdown-14": "SimpleX voor bedrijven", + "directory": "Directory", + "about-and-contact-us": "Over ons & Contact" } diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 77854f5be1..b1a1f2decd 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -64,17 +64,17 @@ "privacy-matters-overlay-card-1-p-1": "Muitas grandes empresas usam informações sobre com quem você está conectado para estimar sua renda, vender produtos que você realmente não precisa e determinar os preços.", "privacy-matters-overlay-card-1-p-2": "Os varejistas online sabem que as pessoas com renda mais baixa têm maior probabilidade de fazer compras urgentes e, por isso, podem cobrar preços mais altos ou remover descontos.", "privacy-matters-overlay-card-1-p-4": "A plataforma SimpleX protege a privacidade de suas conexões melhor do que qualquer alternativa, impedindo totalmente que seu gráfico social fique disponível para quaisquer empresas ou organizações. Mesmo quando as pessoas usam servidores fornecidos pelo SimpleX Chat, não sabemos o número de usuários ou suas conexões.", - "privacy-matters-overlay-card-2-p-2": "Para ser objetivo e tomar decisões independentes, você precisa ter o controle do seu espaço de informações. Isso só é possível se você usar uma plataforma de comunicação privada que não tenha acesso ao seu gráfico social.", + "privacy-matters-overlay-card-2-p-2": "Para ser objetivo e tomar decisões independentes, você precisa ter o controle do seu espaço de informações. Isso só é possível se você usar uma rede de comunicação privada que não tenha acesso ao seu gráfico social.", "privacy-matters-overlay-card-3-p-1": "Todos devem se preocupar com a privacidade e a segurança de suas comunicações — conversas inofensivas podem colocá-lo em perigo, mesmo que você não tenha nada a esconder.", "simplex-unique-overlay-card-3-p-1": "O SimpleX Chat armazena todos os dados do usuário somente em dispositivos clientes usando um formato de banco de dados criptografado portátil que pode ser exportado e transferido para qualquer dispositivo compatível.", "privacy-matters-overlay-card-3-p-2": "Uma das histórias mais chocantes é a experiência de Mohamedou Ould Salahi, descrita em seu livro de memórias e mostrada no filme O Mauritano. Ele foi colocado no campo de Guantánamo, sem julgamento, e lá foi torturado por 15 anos após um telefonema para seu parente no Afeganistão, sob suspeita de estar envolvido nos ataques de 11 de setembro, embora tenha vivido na Alemanha nos 10 anos anteriores.", - "simplex-unique-overlay-card-1-p-1": "Ao contrário de outras plataformas de mensagens, o SimpleX não tem identificadores atribuídos aos usuários. Ele não depende de números de telefone, endereços baseados em domínio (como email ou XMPP), nomes de usuário, chaves públicas ou mesmo números aleatórios para identificar seus usuários — não sabemos quantas pessoas usam nossos servidores SimpleX.", + "simplex-unique-overlay-card-1-p-1": "Ao contrário de outras redes de mensagens, o SimpleX não tem identificadores atribuídos aos usuários. Ele não depende de números de telefone, endereços baseados em domínio (como email ou XMPP), nomes de usuário, chaves públicas ou mesmo números aleatórios para identificar seus usuários — Os operadores dos servidores não sabem quantas pessoas usam os servidores SimpleX.", "simplex-private-card-1-point-1": "Protocolo de dupla catraca —
mensagens OTR com Sigilo de Encaminhamento Perfeito (Perfect Forward Secrecy) e recuperação de invasão.", "simplex-private-8-title": "Mistura de mensagens
para reduzir a correlação", - "simplex-unique-overlay-card-4-p-2": "A plataforma SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", + "simplex-unique-overlay-card-4-p-2": "A rede SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", "simplex-unique-overlay-card-3-p-4": "Não há identificadores ou texto cifrado em comum entre o tráfego de servidor enviado e recebido — se alguém estiver observando, não poderá determinar facilmente quem se comunica com quem, mesmo que o TLS esteja comprometido.", - "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a plataforma SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", - "simplex-unique-card-2-p-1": "Como você não tem um identificador ou endereço fixo na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que você compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", + "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a rede SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", + "simplex-unique-card-2-p-1": "Como você não tem um identificador ou endereço fixo na rede SimpleX, ninguém pode entrar em contato com você, a menos que você compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", "simplex-unique-card-3-p-1": "O SimpleX armazena todos os dados do usuário nos dispositivos clientes em um formato de banco de dados criptografado portátil — que pode ser transferido para outro dispositivo.", "hide-info": "Ocultar informações", "contact-hero-p-2": "Ainda não baixou o SimpleX Chat?", @@ -124,20 +124,20 @@ "simplex-network-overlay-card-1-li-6": "As redes P2P podem ser vulneráveis a ataques DRDoS, quando os clientes podem retransmitir e amplificar o tráfego, resultando em uma negação de serviço em toda a rede. Os clientes SimpleX apenas retransmitem o tráfego de uma conexão conhecida e não podem ser usados por um invasor para amplificar o tráfego em toda a rede.", "privacy-matters-overlay-card-1-p-3": "Algumas empresas financeiras e de seguros usam gráficos sociais para determinar taxas de juros e prêmios. Isso geralmente faz com que as pessoas com renda mais baixa paguem mais — isso é conhecido como \"prêmio de pobreza\".", "privacy-matters-overlay-card-2-p-1": "Não faz muito tempo que observamos as eleições sendo manipuladas por uma empresa de consultoria respeitável que usou nossos gráficos sociais para distorcer nossa visão do mundo real e manipular nossos votos.", - "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira plataforma que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", + "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira rede que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", "privacy-matters-overlay-card-3-p-4": "Não basta usar um mensageiro criptografado de ponta-a-ponta, todos nós devemos usar os mensageiros que protegem a privacidade de nossas redes pessoais — com quem estamos conectados.", "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes.", - "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade de quem está se comunicando com você, ocultando-a dos servidores da plataforma SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", + "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade com quem você está se comunicando, ocultando-a dos servidores da rede SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", "simplex-unique-overlay-card-2-p-2": "Mesmo com o endereço de usuário opcional, embora ele possa ser usado para enviar solicitações de contato de spam, você pode alterá-lo ou excluí-lo completamente sem perder nenhuma das suas conexões.", - "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", + "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na rede SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", "simplex-unique-overlay-card-3-p-2": "As mensagens criptografadas de ponta-a-ponta são mantidas temporariamente nos servidores de retransmissão SimpleX até serem recebidas e, em seguida, são excluídas permanentemente.", "simplex-unique-overlay-card-3-p-3": "Diferente dos servidores de redes federadas (email, XMPP ou Matrix), os servidores SimpleX não armazenam contas de usuários, apenas retransmitem mensagens, protegendo a privacidade de ambas as partes.", "simplex-unique-overlay-card-4-p-1": "Você pode usar o SimpleX com seus próprios servidores e ainda se comunicar com pessoas que usam os servidores pré-configurados fornecidos por nós.", - "simplex-unique-card-1-p-1": "O SimpleX protege a privacidade do seu perfil, contatos e metadados, ocultando-os dos servidores da plataforma SimpleX e de quaisquer observadores.", - "simplex-unique-card-1-p-2": "Diferente de qualquer outra plataforma de mensagens existente, o SimpleX não tem identificadores atribuídos aos usuários — nem mesmo números aleatórios.", + "simplex-unique-card-1-p-1": "O SimpleX protege a privacidade do seu perfil, contatos e metadados, ocultando-os dos servidores da rede SimpleX e de quaisquer observadores.", + "simplex-unique-card-1-p-2": "Diferente de qualquer outra rede de mensagens existente, o SimpleX não tem identificadores atribuídos aos usuários — nem mesmo números aleatórios.", "simplex-unique-card-3-p-2": "As mensagens criptografadas de ponta-a-ponta são mantidas temporariamente nos servidores de retransmissão SimpleX até serem recebidas e, em seguida, são excluídas permanentemente.", - "simplex-unique-card-4-p-1": "A rede SimpleX é totalmente descentralizada e independente de qualquer criptomoeda ou de qualquer outra plataforma, exceto a Internet.", + "simplex-unique-card-4-p-1": "A rede SimpleX é totalmente descentralizada e independente de qualquer criptomoeda ou de qualquer outra rede, exceto a Internet.", "simplex-unique-card-4-p-2": "Você pode usar o SimpleX com seus próprios servidores ou com os servidores fornecidos por nós — e ainda assim se conectar a qualquer usuário.", "we-invite-you-to-join-the-conversation": "Convidamos você a participar da conversa", "join": "Junte-se ao", @@ -190,7 +190,7 @@ "guide-dropdown-3": "Grupos secretos", "guide-dropdown-7": "Privacidade e segurança", "guide": "Manual", - "docs-dropdown-1": "Plataforma SimpleX", + "docs-dropdown-1": "Rede SimpleX", "docs-dropdown-3": "Acessando o banco de dados do bate-papo", "on-this-page": "Nesta página", "newer-version-of-eng-msg": "Há uma versão mais recente desta página em inglês.", @@ -201,7 +201,7 @@ "scan-the-qr-code-with-the-simplex-chat-app-description": "As chaves públicas e o endereço da fila de mensagens nesse link NÃO são enviados pela rede quando você visualiza essa página —
eles estão contidos no fragmento de hash do URL do link.", "installing-simplex-chat-to-terminal": "Instalação do SimpleX chat para o terminal", "see-simplex-chat": "Veja o SimpleX Chat", - "the-instructions--source-code": "as instruções sobre como fazer download ou compilá-lo a partir do código-fonte.", + "the-instructions--source-code": "para as instruções sobre como fazer download ou compilá-lo a partir do código-fonte.", "if-you-already-installed": "Se você já instalou", "simplex-chat-for-the-terminal": "SimpleX Chat para terminal", "simplex-network-1-desc": "Todas as mensagens são enviadas por meio dos servidores, o que proporciona melhor privacidade de metadados e entrega de mensagens assíncronas confiáveis, além de evitar muitos dos", @@ -227,7 +227,7 @@ "no-federated": "Não - federado", "comparison-section-list-point-1": "Geralmente com base em um número de telefone, em alguns casos em nomes de usuário", "comparison-section-list-point-2": "Endereços baseados no DNS", - "comparison-section-list-point-6": "Embora os P2P sejam distribuídos, eles não são federados - operam como uma única rede", + "comparison-section-list-point-6": "Embora os P2P sejam distribuídos, eles não são federados &mdash - operam como uma única rede", "comparison-section-list-point-7": "As redes P2P têm uma autoridade central ou toda a rede pode ser comprometida", "protocol-2-text": "Matrix, XMPP", "see-here": "veja aqui", @@ -249,11 +249,13 @@ "f-droid-page-f-droid-org-repo-section-text": "Os repositórios SimpleX Chat e F-Droid.org assinam compilações com chaves diferentes. Para mudar, exporte o banco de dados de bate-papo e reinstale o aplicativo.", "please-enable-javascript": "Por favor habilite o JavaScript para ver o QR code.", "jobs": "Junte-se à equipe", - "docs-dropdown-8": "Serviço de Diretório SimpleX", + "docs-dropdown-8": "Diretório SimpleX", "docs-dropdown-9": "Baixar", "docs-dropdown-11": "FAQ", "docs-dropdown-10": "Transparência", "docs-dropdown-12": "Segurança", - "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024", - "docs-dropdown-14": "SimpleX para negócios" + "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024. Read more.", + "docs-dropdown-14": "SimpleX para negócios", + "directory": "Diretório", + "about-and-contact-us": "Sobre e Contato" } diff --git a/website/langs/ro.json b/website/langs/ro.json index 0967ef424b..d129c1a7d1 100644 --- a/website/langs/ro.json +++ b/website/langs/ro.json @@ -1 +1,56 @@ -{} +{ + "home": "Acasă", + "developers": "Dezvoltatori", + "reference": "Referință", + "features": "­Funcționalități", + "why-simplex": "De ce SimpleX", + "simplex-privacy": "Confidențialitatea SimpleX", + "simplex-network": "Rețeaua SimpleX", + "simplex-explained": "SimpleX, explicat", + "simplex-explained-tab-1-text": "1. Ce experimentează utilizatorii", + "simplex-explained-tab-2-text": "2. Cum funcționează", + "simplex-explained-tab-3-text": "3. Ce văd serverele", + "simplex-explained-tab-1-p-1": "Poți crea contacte și grupuri, și poți avea conversații bidirecționale, ca în oricare altă aplicație de mesagerie.", + "simplex-explained-tab-1-p-2": "Cum poate funcționa cu liste de așteptare unidirecționale și fără identificatori de profil ai utilizatorului?", + "simplex-explained-tab-2-p-1": "Pentru fiecare conexiune, folosești două cozi de mesaje separate pentru a trimite și a primi mesaje prin servere diferite.", + "simplex-explained-tab-2-p-2": "Serverele doar transmit mesaje într-un singur sens, fără a deține imaginea de ansamblu a conversațiilor sau a conexiunilor utilizatorului.", + "simplex-explained-tab-3-p-1": "Serverele au credențiale anonime separate pentru fiecare coadă și nu știu cui aparțin acestea.", + "simplex-explained-tab-3-p-2": "Utilizatorii pot îmbunătăți și mai mult confidențialitatea metadatelor folosind Tor pentru a accesa serverele, prevenind astfel corelarea prin adresa IP.", + "chat-bot-example": "Exemplu de bot de chat", + "smp-protocol": "Protocolul SMP", + "chat-protocol": "Protocol de chat", + "donate": "Donează", + "copyright-label": "© 2020-2025 SimpleX | Proiect Open-Source", + "simplex-chat-protocol": "Protocolul SimpleX Chat", + "terminal-cli": "Terminal CLI", + "terms-and-privacy-policy": "Politică de confidențialitate", + "hero-header": "Confidențialitate redefinită", + "hero-subheader": "Primul serviciu de mesagerie
fără identificatori de utilizator", + "hero-p-1": "Alte aplicații au identificatori de utilizator: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX nu are, nici măcar numere aleatorii.
Acest lucru îți îmbunătățește radical confidențialitatea.", + "hero-overlay-1-textlink": "De ce ID-urile de utilizator compromit confidențialitatea?", + "hero-overlay-2-textlink": "Cum funcționează SimpleX?", + "hero-overlay-3-textlink": "Evaluări de securitate", + "hero-2-header": "Creează o conexiune privată", + "hero-2-header-desc": "Videoclipul arată cum te conectezi cu prietenul tău cu ajutorul codului QR de unică folosință al acestuia, fie față în față, fie printr-un link video. De asemenea, te poți conecta prin partajarea unui link de invitație.", + "invitation-hero-header": "Ai primit un link de unică folosință pentru a te conecta pe SimpleX Chat", + "hero-overlay-1-title": "Cum funcționează SimpleX?", + "hero-overlay-2-title": "De ce ID-urile de utilizator compromit confidențialitatea?", + "hero-overlay-3-title": "Evaluări de securitate", + "feature-1-title": "Mesaje criptate E2E, cu formatare Markdown și editare", + "feature-2-title": "Imagini, videoclipuri și fișiere
criptate E2E", + "feature-3-title": "Grupuri descentralizate criptate E2E — doar utilizatorii știu că există", + "feature-4-title": "Mesaje vocale criptate E2E", + "feature-5-title": "Mesaje care dispar", + "feature-6-title": "Apeluri audio și video
criptate E2E", + "feature-7-title": "Stocare portabilă criptată a aplicației — mută profilul pe alt dispozitiv", + "feature-8-title": "Mod incognito —
unic în SimpleX Chat", + "simplex-network-overlay-1-title": "Comparație cu protocoalele de mesagerie P2P", + "simplex-private-1-title": "2 straturi de
criptare end-to-end", + "simplex-private-2-title": "Strat adițional de
criptare pe server", + "simplex-private-3-title": "Transport TLS
securizat și autentificat", + "simplex-private-4-title": "Acces opțional
prin Tor", + "simplex-private-5-title": "Straturi multiple de
padding al conținutului", + "simplex-private-6-title": "Schimb de chei
în afara benzii", + "simplex-private-7-title": "Verificarea integrității
mesajului", + "simplex-private-8-title": "Amestecare mesaje
pentru a reduce corelația" +} diff --git a/website/langs/ru.json b/website/langs/ru.json index ea7766db1b..6ab4827496 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -4,7 +4,7 @@ "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", - "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хеш-фрагменте URL-адреса ссылки.", "guide-dropdown-5": "Управление данными", "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными серверами доставки сообщений.", @@ -14,7 +14,7 @@ "hero-2-header": "Установите конфиденциальное соединение", "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", "hero-subheader": "Первый мессенджер
без идентификаторов пользователей", - "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из-за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", "signing-key-fingerprint": "Идентификатор ключа подписи (SHA-256)", "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", "simplex-privacy": "Конфиденциальность SimpleX", @@ -55,7 +55,7 @@ "docs-dropdown-1": "Сеть SimpleX", "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым сквозным шифрованием.", "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", - "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", "the-instructions--source-code": "SimpleX Chat.", "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", "privacy-matters-section-subheader": "Сохранение конфиденциальности Ваших метаданных — с кем Вы общаетесь — защищает Вас от:", @@ -103,7 +103,7 @@ "install-simplex-app": "Установите приложение SimpleX", "hero-overlay-3-textlink": "Аудит безопасности", "comparison-point-2-text": "Возможность MITM", - "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хеш-фрагменте URL-адреса ссылки.", "guide-dropdown-8": "Настройки приложения", "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной информации о разговорах и контактах пользователя.", "smp-protocol": "Протокол SMP", @@ -127,7 +127,7 @@ "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, Вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", "guide-dropdown-2": "Отправка сообщений", - "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", "simplex-private-2-title": "Дополнительный уровень
шифрования с сервером", "hero-overlay-card-1-p-4": "Этот подход предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты Вашего IP-адреса Вы можете подключиться к серверам обмена сообщениями через сеть Tor.", "f-droid-org-repo": "Репозиторий F-Droid.org", @@ -172,6 +172,7 @@ "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, и обеспечивает лучшую конфиденциальность метаданных, чем альтернативы.", "privacy-matters-2-title": "Манипулирование выборами", "home": "Главная", + "directory": "Каталог", "chat-protocol": "Протокол чата", "simplex-private-card-5-point-2": "Это делает сообщениям разного размера одинаковыми для серверов и сети.", "hero-overlay-card-1-p-1": "Многие спрашивают: Если у SimpleX нет никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", @@ -182,11 +183,11 @@ "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", "blog": "Блог", - "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хэш предыдущего сообщения.", + "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хеш предыдущего сообщения.", "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", - "why-simplex-is-unique": "Почему SimpleX уникальный", + "why-simplex-is-unique": "Почему SimpleX уникален", "simplex-network-section-header": "Сеть SimpleX", "simplex-private-10-title": "Временные анонимные парные идентификаторы", "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит Ваши деньги", @@ -217,7 +218,7 @@ "hero-2-header-desc": "В видео показано, как подключиться к Вашему другу через одноразовый QR-код, при встрече или во время видеосвязи. Вы также можете соединится, поделившись ссылкой-приглашением.", "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и увеличивать трафик, что приводит к отказу всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для создания трафика во всей сети.", "if-you-already-installed-simplex-chat-for-the-terminal": "Если Вы уже установили SimpleX Chat для терминала", - "docs-dropdown-8": "Служба Каталогов SimpleX", + "docs-dropdown-8": "Каталог SimpleX", "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома.", "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", @@ -233,9 +234,10 @@ "simplex-private-7-title": "Проверка неизменности
сообщений", "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX защищает конфиденциальность Ваших контактов лучше, чем альтернативы, предотвращая доступ к Вашей социальной сети каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", - "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", + "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", - "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хэш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", + "about-and-contact-us": "O нас и наши контакты", + "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хеш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", "privacy-matters-section-label": "Убедитесь, что Ваш мессенджер не может получить доступ к Вашим данным!", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", @@ -255,5 +257,59 @@ "docs-dropdown-10": "Запросы данных", "docs-dropdown-12": "Безопасность", "docs-dropdown-11": "Часто задаваемые вопросы", - "docs-dropdown-14": "SimpleX для бизнеса" + "docs-dropdown-14": "SimpleX для бизнеса", + "index-hero-h1": "
Жить
Свободно
", + "index-hero-h2": "Свобода и Безопасность
Ваших Коммуникаций", + "index-hero-p1": "Первая сеть, в которой Вам принадлежат Ваши данные, контакты и группы.", + "index-hero-download-desktop-btn-title": "Загрузить приложение SimpleX для компьютера", + "index-testflight-title": "Бета-релиз для iOS на TestFlight", + "index-f-droid-title": "Загрузить через F-Droid", + "index-security-assessment-title": "Аудиты безопасности", + "index-security-review-2022-title": "Аудит безопасности в 2022", + "index-security-review-2024-title": "Аудит безопасности в 2024", + "index-security-audits-label": "Аудиты
безопасности", + "index-publications-privacy-guides-title": "Рекомендации мессенджеров Privacy Guides", + "index-publications-whonix-title": "Рекомендации мессенджеров от Whonix", + "index-publications-heise-title": "Публикации Heise Online", + "index-publications-kuketz-title": "Обзор от Mike Kuketz", + "index-publications-optout-title": "OptOut подкаст интервью", + "worlds-most-secure-messaging": "Самый Безопасный Мессенджер в Мире", + "index-messaging-p1": "Сообщения в SimpleX имеют самое передовое сквозное шифрование (end-to-end).", + "index-messaging-p2": "Для Вашей безопасности, серверы не могут видеть ваши сообщения и с кем Вы разговариваете.", + "index-messaging-cta": "Узнать больше про сообщения в SimpleX", + "index-nextweb-h2": "Ваш Интернет Будущего", + "index-nextweb-p1": "SimpleX основан на идее что Ваши данные, контакты и группы должны принадлежать Вам.", + "index-nextweb-p2": "Открытая децентрализованная сеть позволяет Вам соединяться с другими людьми и общаться свободно и безопасно.", + "index-token-h2": "Стабильные Сообщества", + "index-token-p1": "Вы сможете поддерживать Ваши любимые группы с помощью будущих Ваучеров Групп.", + "index-token-p2": "Ваучеры будут использоваться для оплаты за серверы, чтобы группы оставались свободными и независимыми.", + "index-token-cta": "Узнайте больше и возьмите Ваш бесплатный NFT, чтобы получить скидку 20% на SimpleX токены!", + "index-roadmap-h2": "Путь Сети SimpleX к Свободному Интернету", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Большие каналы и группы", + "index-roadmap-2025-desc": "Чтобы Вы могли покинуть централизованные платформы", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Самодостаточные группы и серверы", + "index-roadmap-2026-desc": "Запуск Ваучеров Групп", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Поддержка роста Ваших групп", + "index-roadmap-2027-desc": "Инструменты для продвижения групп", + "index-directory-h2": "Вступайте в Группы SimpleX", + "index-directory-p1": "Сотни тысяч людей уже доверяют мессенджеру SimpleX.", + "index-directory-p2": "Найдите группы по душе в каталоге SimpleX и создайте свои!", + "index-directory-cta": "Открыть каталог SimpleX", + "index-directory-users-group-title": "Группа пользователей SimpleX", + "how-secure-comparison-title": "Сравнение безопасности сквозного шифрования в мессенджерах", + "how-secure-message-padding": "Дополнение сообщений", + "how-secure-repudiation-deniability": "Отрицаемые сообщения", + "how-secure-forward-secrecy": "Прямая секретность", + "how-secure-break-in-recovery": "Безопасность после взлома", + "how-secure-two-factor-key-exchange": "2-факторный обмен ключами", + "how-secure-post-quantum-hybrid-crypto": "Гибридное постквантовое шифрование", + "messengers-comparison-section-list-point-1": "Briar дополняет сообщения до размера кратного 1024 байтов, Signal - до кратного 160 байтов.", + "messengers-comparison-section-list-point-2": "Отрицаемость сообщений не включает в себя соединеие между клиентом и сервером.", + "messengers-comparison-section-list-point-3": "По всей видимости, использование криптографической подписи исключает отражаемость, но это требует уточнения.", + "messengers-comparison-section-list-point-4": "Реализация поддержки нескольких устройств подрывает безопасность после взлома алгоритма Double Ratchet.", + "messengers-comparison-section-list-point-5": "2-факторный обмен ключами опциональный, через верификацию кода безопасности.", + "messengers-comparison-section-list-point-6": "Пост-квантовое согласование ключей происходит не всегда, защищая только часть согласований." } diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c0b843ca9e..6dbd261864 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -116,7 +116,7 @@ "hero-overlay-card-2-p-4": "为防止这些攻击,SimpleX 在其设计中不包含任何用户 ID 。 而且,如果您使用隐身模式,您将为每个联系人显示不同的名称,避免他们之间共享任何数据。", "simplex-network-overlay-card-1-li-1": "P2P 网络依赖于 分布式散列表(DHT) 的某些变体来路由消息。 DHT 在设计上必须平衡可达性和延迟。 SimpleX 比 P2P 具有更好的可达性和更低的延迟,因为消息可以通过通讯双方选择的多个服务器并行地冗余传递。若是在 P2P 网络中,消息则需要使用算法选择,并依次通过 O(log N) 个节点。", "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关 — 它可以在标准网络协议上工作,例如 WebSockets。", - "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", + "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到 Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 DRDoS 攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", "privacy-matters-overlay-card-1-p-2": "在线零售商知道收入较低的人更有可能在紧急情况下购买商品,因此他们可能会收取更高的价格或取消折扣。", @@ -241,7 +241,7 @@ "stable-and-beta-versions-built-by-developers": "开发人员构建的稳定版和测试版", "f-droid-page-simplex-chat-repo-section-text": "要将其添加到您的 F-Droid 客户端,请扫描二维码或使用以下 URL:", "comparison-section-list-point-4a": "SimpleX 中继无法破坏 e2e 加密。 验证安全代码以减轻对带外通道的攻击", - "docs-dropdown-8": "SimpleX 目录服务", + "docs-dropdown-8": "SimpleX 目录", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。", "hero-overlay-3-title": "安全性评估", "hero-overlay-card-3-p-2": "2022年11月份,Trail of Bits 审核了 SimpleX 平台的密码学和网络部件。更多内容见 该公告。", @@ -255,5 +255,7 @@ "docs-dropdown-11": "常问问题", "docs-dropdown-12": "安全性", "hero-overlay-card-3-p-3": "Trail of Bits 于 2024 年 7 月审核了 SimpleX 网络协议的加密设计。了解更多信息。", - "docs-dropdown-14": "企业版 SimpleX" + "docs-dropdown-14": "企业版 SimpleX", + "directory": "目录", + "about-and-contact-us": "关于 & 联系我们" } diff --git a/website/run.sh b/website/run.sh new file mode 100755 index 0000000000..a0a8dd0e50 --- /dev/null +++ b/website/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +cd .. +./website/web.sh +cd website +npm run start diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index 340473ab3e..7f86200fb0 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -7,6 +7,26 @@
+ - @@ -56,7 +64,7 @@ - + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index fc7a80458c..762686d5cf 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -12,15 +12,23 @@ - + - {% if ('blog' not in page.url) and ('jobs' not in page.url) %} + {% if ('blog' not in page.url) and ('jobs' not in page.url) and ('about' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) %}

{{ language.name }}

- {% endif %} + {% endif %} {% endif %} {% endfor %} @@ -198,7 +198,7 @@

{{ language.name }}

- {% endif %} + {% endif %} {% endif %} {% endfor %} diff --git a/website/src/blog.html b/website/src/blog.html index 571b240451..4a0460511a 100644 --- a/website/src/blog.html +++ b/website/src/blog.html @@ -1,36 +1,42 @@ --- layout: layouts/main.html title: "SimpleX blog: the latest news" -description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a private connection via link / QR code to send messages and make calls." +description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a +private connection via link / QR code to send messages and make calls." path: /blog templateEngineOverride: njk active_blog: true --- {% block css_links %} - + {% endblock %}
@@ -39,42 +45,48 @@ active_blog: true {% for blog in collections.blogs %} {% if not(blog.data.draft) %} -
-
-
- {% if blog.data.image %} - {% if blog.data.imageBottom %} - - {% elif blog.data.imageWide %} - - {% else %} - - {% endif %} - {% else %} - - - {% endif %} -
+
+
+
+ {% if blog.data.image %} + {% if blog.data.imageBottom %} + + {% elif blog.data.imageWide %} + + {% else %} + + {% endif %} + {% else %} + + + {% endif %}
-
-
-

- {{ blog.data.title | safe }} -

-

- {{ blog.data.date.toUTCString().split(' ').slice(1, 4).join(' ') }} -

- {% if blog.data.previewBody %} -
- {% include blog.data.previewBody %} -
- {% elif blog.data.preview %} -

{{ blog.data.preview | safe }}

- {% endif %} +
+
+
+

+ {{ blog.data.title | safe }} +

+

+ {{ blog.data.date.toUTCString().split(' ').slice(1, 4).join(' ') }} +

+ {% if blog.data.previewBody %} +
+ {% include blog.data.previewBody %}
- Read More + {% elif blog.data.preview %} +

{{ blog.data.preview | safe }}

+ {% endif %}
-
+ Read More +
+
{% endif %} {% endfor %} diff --git a/website/src/directory.html b/website/src/directory.html new file mode 100644 index 0000000000..4304fb0559 --- /dev/null +++ b/website/src/directory.html @@ -0,0 +1,274 @@ +--- +layout: layouts/main.html +title: "SimpleX Directory" +description: "Find communities on SimpleX network and create your own" +templateEngineOverride: njk +active_directory: true +--- + +{% set lang = page.url | getlang %} +{% block js_scripts %} + + +{% endblock %} + + + +
+
+

SimpleX Directory

+

Welcome to the selected users' communities that you can join via SimpleX Chat + app.

+

SimpleX Directory is also available as a SimpleX chat bot.

+

Read about how to add your community.

+
+ + +
+
+ +
+
\ No newline at end of file diff --git a/website/src/img/group.svg b/website/src/img/group.svg new file mode 100644 index 0000000000..2a262ef38a --- /dev/null +++ b/website/src/img/group.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/website/src/js/directory.js b/website/src/js/directory.js new file mode 100644 index 0000000000..eb8b457c59 --- /dev/null +++ b/website/src/js/directory.js @@ -0,0 +1,511 @@ +(function() { +const directoryDataURL = 'https://directory.simplex.chat/data/'; + +// const directoryDataURL = 'http://localhost:8080/directory-data/'; + +let allEntries = []; + +let filteredEntries = []; + +let currentSortMode = ''; + +let currentSearch = ''; + +let currentPage = 1; + +async function initDirectory() { + const listing = await fetchJSON(directoryDataURL + 'listing.json') + const liveBtn = document.querySelector('#top-pagination .live'); + const newBtn = document.querySelector('#top-pagination .new'); + const topBtn = document.querySelector('#top-pagination .top'); + const searchInput = document.getElementById('search'); + allEntries = listing.entries + renderEntries('top', bySortPriority, topBtn) + searchInput.addEventListener('input', (e) => renderEntries('top', bySortPriority, topBtn, e.target.value.trim())); + liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); + newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); + topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + + function renderEntries(mode, comparator, btn, search = '') { + if (currentSortMode === mode && search == currentSearch) return; + currentSortMode = mode; + if (location.hash) location.hash = ''; + liveBtn.classList.remove('active'); + newBtn.classList.remove('active'); + topBtn.classList.remove('active'); + if (search == '') { + currentSearch = ''; + currentPage = 1; + searchInput.value = ''; + btn.classList.add('active'); + } else { + currentSearch = search; + } + filteredEntries = filterEntries(mode, search ?? '').sort(comparator); + renderDirectoryPage(); + } +} + +function renderDirectoryPage() { + const currentEntries = addPagination(filteredEntries); + displayEntries(currentEntries); +} + +function filterEntries(mode, s) { + if (s === '' && mode == 'top') return allEntries.slice(); + const query = s.toLowerCase(); + return allEntries.filter(entry => + ( mode === 'top' + || (mode === 'new' && entry.createdAt) + || (mode === 'live' && entry.activeAt) + ) && + ( query === '' + || (entry.displayName || '').toLowerCase().includes(query) + || includesQuery(entry.shortDescr, query) + || includesQuery(entry.welcomeMessage, query) + ) + ); +} + +function includesQuery(field, query) { + return field + && Array.isArray(field) + && field.some(ft => { + switch (ft.format?.type) { + case 'uri': return uriIncludesQuery(ft.text, query); + case 'hyperLink': return textIncludesQuery(ft.format.showText, query) || uriIncludesQuery(ft.format.linkUri, query); + case 'simplexLink': return textIncludesQuery(ft.format.showText, query); + default: return textIncludesQuery(ft.text, query); + } + }); +} + +function textIncludesQuery(text, query) { + return text ? text.toLowerCase().includes(query) : false +} + +function uriIncludesQuery(uri, query) { + if (!uri) return false; + uri = uri.toLowerCase(); + return !uri.includes('simplex') && uri.includes(query); +} + +async function fetchJSON(url) { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP status: ${response.status}`) + return await response.json() + } catch (e) { + console.error(e) + } +} + +function bySortPriority(entry1, entry2) { + return entrySortPriority(entry2) - entrySortPriority(entry1); +} + +function byActiveAtDesc(entry1, entry2) { + return (roundedTs(entry2.activeAt) - roundedTs(entry1.activeAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function byCreatedAtDesc(entry1, entry2) { + return (roundedTs(entry2.createdAt) - roundedTs(entry1.createdAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function roundedTs(s) { + try { + return new Date(s).valueOf(); + } catch { + return 0; + } +} + +const simplexUsersGroup = 'SimpleX users group'; + +function entrySortPriority(entry) { + return entry.displayName === simplexUsersGroup + ? Number.MAX_VALUE + : entryMemberCount(entry) +} + +function entryMemberCount(entry) { + return entry.entryType.type == 'group' + ? (entry.entryType.summary?.currentMembers ?? 0) + : 0 +} + +const now = new Date(); +const nowVal = now.valueOf(); +const today = new Date(now); +today.setHours(0, 0, 0, 0); +const todayVal = today.valueOf(); +const todayYear = today.getFullYear(); + +const dateFormatter = Intl?.DateTimeFormat?.(undefined, {month: '2-digit', day: '2-digit'}); +const dateYearFormatter = Intl?.DateTimeFormat?.(undefined, {year: 'numeric', month: '2-digit', day: '2-digit'}); + +function showDate(d) { + return dateFormatter && d.getFullYear() == todayYear + ? dateFormatter.format(d) + : dateYearFormatter?.format(d) ?? d.toLocaleDateString(); +} + +function showCreatedOn(s) { + const d = new Date(s) + d.setHours(0, 0, 0, 0); + return 'Created' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function showActiveOn(s) { + const d = new Date(s) + const ago = nowVal - d.valueOf(); + if (ago <= 1200000) return 'Active now'; // 20 minutes + if (ago <= 10800000) return 'Active recently'; // 3 hours + d.setHours(0, 0, 0, 0); + return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function displayEntries(entries) { + const directory = document.getElementById('directory'); + directory.innerHTML = ''; + + for (let entry of entries) { + try { + const { entryType, displayName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const entryDiv = document.createElement('div'); + entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#11182F] mb-8'; + + const textContainer = document.createElement('div'); + textContainer.className = 'text-container'; + + const nameElement = document.createElement('h2'); + nameElement.textContent = displayName; + nameElement.className = 'text-grey-black dark:text-white !text-lg md:!text-xl font-bold'; + textContainer.appendChild(nameElement); + + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { + const descrElement = document.createElement('p'); + descrElement.innerHTML = renderMarkdown(shortDescr); + textContainer.appendChild(descrElement); + } + + if (welcomeMessageHTML) { + const messageElement = document.createElement('p'); + messageElement.innerHTML = welcomeMessageHTML; + textContainer.appendChild(messageElement); + + const readMore = document.createElement('p'); + readMore.textContent = 'Read more'; + readMore.className = 'read-more'; + readMore.style.display = 'none'; + textContainer.appendChild(readMore); + + setTimeout(() => { + const computedStyle = window.getComputedStyle(messageElement); + const lineHeight = parseFloat(computedStyle.lineHeight); + const maxLines = 5; + const maxHeight = maxLines * lineHeight + const maxHeightPx = `${maxHeight}px`; + messageElement.style.maxHeight = maxHeightPx; + messageElement.style.overflow = 'hidden'; + + if (messageElement.scrollHeight > maxHeight + 4) { + readMore.style.display = 'block'; + readMore.addEventListener('click', () => { + if (messageElement.style.maxHeight === maxHeightPx) { + messageElement.style.maxHeight = 'none'; + readMore.className = 'read-less'; + readMore.innerHTML = '▲'; + } else { + messageElement.style.maxHeight = maxHeightPx; + readMore.className = 'read-more'; + readMore.textContent = 'Read more'; + } + }); + } + }, 0); + } + + const entryTimestamp = currentSortMode === 'new' && entry.createdAt + ? showCreatedOn(entry.createdAt) + : entry.activeAt + ? showActiveOn(entry.activeAt) + : ''; + if (entryTimestamp) { + timestampElement = document.createElement('p'); + timestampElement.textContent = entryTimestamp; + timestampElement.className = 'text-sm'; + textContainer.appendChild(timestampElement); + } + + const memberCount = entryMemberCount(entry); + if (typeof memberCount == 'number' && memberCount > 0) { + const memberCountElement = document.createElement('p'); + memberCountElement.textContent = `${memberCount} members`; + memberCountElement.className = 'text-sm'; + textContainer.appendChild(memberCountElement); + } + + const imgLinkElement = document.createElement('a'); + const groupLinkUri = groupLink.connShortLink ?? groupLink.connFullLink + try { + imgLinkElement.href = platformSimplexUri(groupLinkUri); + } catch(e) { + console.log(e); + imgLinkElement.href = groupLinkUri; + } + imgLinkElement.target = "_blank"; + imgLinkElement.title = `Join ${displayName}`; + + const imgElement = document.createElement('img'); + imgElement.src = imageFile ? directoryDataURL + imageFile : '/img/group.svg'; + imgElement.alt = displayName; + imgElement.addEventListener('error', () => imgElement.src = '/img/group.svg'); + imgLinkElement.appendChild(imgElement); + entryDiv.appendChild(imgLinkElement); + + entryDiv.appendChild(textContainer); + directory.appendChild(entryDiv); + } catch (e) { + console.log(e); + } + } + + for (let el of document.querySelectorAll('.secret')) { + el.addEventListener('click', () => el.classList.toggle('visible')); + } + + directory.style.height = ''; +} + +function goToPage(p) { + currentPage = p; + renderDirectoryPage(); +} + +function addPagination(entries) { + const entriesPerPage = 10; + const totalPages = Math.ceil(entries.length / entriesPerPage); + if (currentPage < 1) currentPage = 1; + if (currentPage > totalPages) currentPage = totalPages; + + const startIndex = (currentPage - 1) * entriesPerPage; + const endIndex = Math.min(startIndex + entriesPerPage, entries.length); + const currentEntries = entries.slice(startIndex, endIndex); + + // addPaginationElements('top-pagination') + addPaginationElements('bottom-pagination') + return currentEntries; + + function addPaginationElements(paginationId) { + const pagination = document.getElementById(paginationId); + if (!pagination) { + return currentEntries; + } + pagination.innerHTML = ''; + + try { + let startPage, endPage; + const pageButtonCount = 8 + if (totalPages <= pageButtonCount) { + startPage = 1; + endPage = totalPages; + } else { + startPage = Math.max(1, currentPage - 4); + endPage = Math.min(totalPages, startPage + pageButtonCount - 1); + if (endPage - startPage + 1 < pageButtonCount) { + startPage = Math.max(1, endPage - pageButtonCount + 1); + } + } + + // if (currentPage > 1 && startPage > 1) { + // const firstBtn = document.createElement('button'); + // firstBtn.textContent = 'First'; + // firstBtn.classList.add('text-btn'); + // firstBtn.addEventListener('click', () => goToPage(1)); + // pagination.appendChild(firstBtn); + // } + + if (currentPage > 1) { + const prevBtn = document.createElement('button'); + prevBtn.textContent = 'Prev'; + prevBtn.classList.add('text-btn'); + prevBtn.addEventListener('click', () => goToPage(currentPage - 1)); + pagination.appendChild(prevBtn); + } + + for (let p = startPage; p <= endPage; p++) { + const pageBtn = document.createElement('button'); + pageBtn.textContent = p.toString(); + if (p === currentPage) { + pageBtn.classList.add('active'); + } else if (p === currentPage - 1 || p === currentPage + 1 || (currentPage === 1 && p === 3) || (currentPage === totalPages && p === totalPages - 2)) { + pageBtn.classList.add('neighbor'); + } + pageBtn.addEventListener('click', () => goToPage(p)); + pagination.appendChild(pageBtn); + } + + if (currentPage < totalPages) { + const nextBtn = document.createElement('button'); + nextBtn.textContent = 'Next'; + nextBtn.classList.add('text-btn'); + nextBtn.addEventListener('click', () => goToPage(currentPage + 1)); + pagination.appendChild(nextBtn); + } + + // if (endPage < totalPages) { + // const lastBtn = document.createElement('button'); + // lastBtn.textContent = 'Last'; + // lastBtn.classList.add('text-btn'); + // lastBtn.addEventListener('click', () => goToPage(totalPages)); + // pagination.appendChild(lastBtn); + // } + + } catch (e) { + console.log(e); + } + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDirectory); +} else { + initDirectory(); +} + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${viaHost(smpHosts)})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} +})(); diff --git a/website/src/js/script.js b/website/src/js/script.js index 5f863f48ee..cf240dd375 100644 --- a/website/src/js/script.js +++ b/website/src/js/script.js @@ -26,7 +26,8 @@ const uniqueSwiper = new Swiper('.unique-swiper', { const isMobile = { Android: () => navigator.userAgent.match(/Android/i), - iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i) + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) }; const privateSwiper = new Swiper('.private-swiper', {